Skip to content

feat: enforce scheduled content retention by partition drop - #1481

Merged
pjb157 merged 63 commits into
mainfrom
peter/retention-lifecycle
Sep 4, 2026
Merged

pjb157 merged 63 commits into
mainfrom
peter/retention-lifecycle

Conversation

@pjb157

@pjb157 pjb157 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the data retention policy for every class of customer content the API stores. Scheduled deletion is partition drop only — content lives in time-keyed partitions and expires by irreversibly dropping whole relations through one shared, journaled, crash-safe engine. There is no recurring row-by-row content deletion anywhere after this PR, and every destructive control ships disabled.

How each data class is deleted

Data class Storage Deletion mechanism
Synchronous/batchless response content (requests, dedicated input templates, response steps) Daily retained_response_objects partitions, keyed by deletion date Whole response graphs move atomically out of the live tables into the day they become deletable; the day is dropped once PostgreSQL's own UTC clock passes it
Batch response content Existing weekly batch_requests_archive partitions A week drops once every batch in it is fully archived, frozen, and individually past its finalization-anchored retention period; completion stamps batches.retention_expired_at in the same transaction
Batch input content (uploaded request payloads) New weekly request_templates_g2 partitions; new writes cut over behind a flag, the legacy heap freezes in place untouched A week drops once it is past the horizon and no live file still owns templates in it; the frozen legacy heap is dropped later as one relation in a separately approved forward migration
Batch / file metadata (names, statuses, timestamps, billing) Ordinary metadata tables Never deleted by schedule. Files are tombstoned (retention_expired_at, row survives) once every referencing batch's content is gone; batches are stamped at partition drop. Account-lifetime retention, exactly as the policy states
On-request erasure All of the above Unchanged and immediate: whole-graph deletion, file/creator erasure, and the orphan purge — which now requires an explicit-deletion tombstone on every branch so scheduled expiry can never leak through it

One retirement engine, three families

Every partition drop runs through a single state machine (partition_retirement.rs): journal the exact partition identity (schema + OID, parent OID, child name + OID, bounds — with a CHECK constraint that makes tampered identities unrepresentable), fence the bucket retiring in the same commit so reads fail closed, DETACH PARTITION CONCURRENTLY, FINALIZE recovery after crashes, drop only the journaled relation, and complete atomically. Families differ only in a declarative spec: names, bounds width, eligibility SQL, and an optional metadata stamp. Lock/statement timeouts are retryable no-ops on a durable journal; renamed, re-bounded, or replaced relations are refused; an unfinished journal remains recoverable even after its flag or period is withdrawn.

Destructive DDL runs only on an explicitly installed single-session maintenance pool (max 1 connection), attested at startup against the primary, with server-side lock and statement timeouts.

Already-due content (found in the cl-1548 preview)

The mover deliberately never lands content on a day that is already droppable, and its candidate query excluded graphs past their retention period — leaving them to a "gated legacy path" that no longer existed once the row-deleting sweeper was removed. Such content would have stayed live, and undeletable, forever. The backfill worker now owns that path: it discovers already-due graphs oldest-first (no per-tier lower bound) and moves them onto the day after observation, the earliest day that can still be dropped. Retention is only ever extended, by at most the time a graph spent overdue; the ordinary sweep and its fairness guarantees are unchanged.

Reads and writers

Point, list, and count reads are byte-identical before and after content moves; everything in a retiring/retired bucket answers not-found before any physical DDL. Template reads are generation-transparent through one view (the claim path resolves generation-2 ids through a route oracle that prunes to a single weekly partition). Late writers to moved or dropped response identities are blocked by durable content-free fences; claim, pending, and mutation paths remain live-only.

Safety defaults and flags (all off)

batchless_archive_sweep_enabled, batchless_archive_backfill_enabled, retained_response_retirement_enabled, batch_archive_retirement_enabled + batch_archive_retention_days, template_generation_writes_enabled, template_retirement_enabled + template_retention_days. Retention periods have no defaults — enabling any retirement without an explicit positive period fails startup validation, as does enabling retirement without the maintenance endpoint on a dedicated database, or template retirement without the write cutover.

Observability and evidence

Aggregate-only metrics with fixed labels for every phase (movement counts/bytes, partition runway and readiness, per-family retirement and retry counters, route/fence cleanup counters, file-content expiry). The retirement journal and bucket tombstones are permanent, dated, content-free records of every deletion — audit evidence by construction. No request identifiers, owners, models, or payloads appear in any log, metric, or error (enforced by the repo's no-payload-logging guard). A read-only preflight script verifies index readiness and exact partition attachment before any enablement.

Testing

  • Full workspace suite green; dedicated integration suites per family (daily response retirement, weekly batch archive, weekly templates) covering crash points, identity fail-closed refusal, recovery without selection flags, reference-gate blocking (live/split/unfrozen batches, live files, unowned rows), metadata stamping idempotence, and bounded cleanup.
  • Fresh migration up/down/up cycles across all three retention migrations; every down migration fails closed while lifecycle state exists; live-table relations are never scanned, rewritten, or locked by any migration.
  • Checksums cover up and down migrations; sqlx offline metadata verified; lint/clippy/fmt clean.

Rollout (each step independently reversible until noted)

  1. Merge; deploy. Nothing changes at runtime — all flags off, expand-only schema.
  2. Build the candidate index concurrently (standalone operation); verify with the migration-owned readiness guard and the preflight script.
  3. Enable batchless movement with minimal budgets → backfill → legacy drain (rollback: flags off; readers stay archive-aware).
  4. Enable daily response retirement (first drop is the point of no return for that day's content — by design).
  5. Enable weekly batch-archive retirement with the policy period.
  6. Enable the template write cutover (rollback: flag off; new writes return to the legacy heap).
  7. Enable template retirement with the policy period; file tombstoning begins releasing weeks.
  8. After the legacy heap's full horizon passes and the batchless drain is complete: survivor copy + one approved forward migration drops the legacy template relation.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8acb6ef
Status: ✅  Deploy successful!
Preview URL: https://c70f09d1.control-layer.pages.dev
Branch Preview URL: https://peter-retention-lifecycle.control-layer.pages.dev

View logs

@pjb157
pjb157 force-pushed the peter/privacy-aware-request-logging branch from 1214843 to 77c7cd3 Compare August 13, 2026 15:10
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch 2 times, most recently from 2a954a9 to 68db145 Compare August 14, 2026 08:52
@pjb157
pjb157 changed the base branch from peter/privacy-aware-request-logging to main August 14, 2026 08:52
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch from 68db145 to 70f57d0 Compare August 14, 2026 09:17
Copilot AI lite review requested due to automatic review settings August 14, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds policy-driven, disabled-by-default content retention to the fusillade daemon and Postgres storage backend, exposing a configurable sweep worker that expires files, ages out terminal batches, and deletes/redacts eligible batchless requests while emitting aggregate metrics.

Changes:

  • Introduces retention policy/cutoff/outcome types in fusillade-core and re-exports them through fusillade and fusillade-arsenal.
  • Adds a retention sweep worker to the daemon with startup validation and bounded per-tick chunking.
  • Implements Postgres retention sweep logic plus a migration for retention-related access-path indexes and corresponding operator documentation/config validation.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
fusillade/src/manager/mod.rs Re-exports retention sweep types from fusillade-core.
fusillade/src/lib.rs Publicly re-exports retention sweep types from the manager module.
fusillade/src/daemon/transitions.rs Updates test storage stub to satisfy new DaemonStorage retention API.
fusillade/src/daemon/mod.rs Adds retention startup validation, shutdown-aware helper, and retention sweep background task with metrics.
fusillade/src/daemon/config.rs Adds retention policy + sweep interval fields to daemon config and round-trip tests.
fusillade/README.md Documents automated content retention behavior and operational rollout steps.
fusillade-core/src/manager.rs Defines RetentionSweepPolicy, immutable cutoffs, sweep outcome, and extends DaemonStorage.
fusillade-core/src/lib.rs Re-exports retention sweep types from manager.
fusillade-core/src/daemon_record/transitions.rs Updates test storage stub to satisfy new retention API.
fusillade-arsenal/src/postgres.rs Implements Postgres retention sweeping plus lock-ordering changes and extensive regression tests.
fusillade-arsenal/src/lib.rs Re-exports retention sweep types from fusillade-core.
fusillade-arsenal/migrations/20260813000000_add_retention_sweep_indexes.up.sql Adds (non-concurrent) creation of candidate retention indexes with precreate guidance in comments.
fusillade-arsenal/migrations/20260813000000_add_retention_sweep_indexes.down.sql Drops the new retention sweep indexes.
dwctl/src/config.rs Wires retention config into dwctl, adds validation, and tests for config invariants.
.github/fixtures/fusillade-migration-sha384.txt Updates migration checksum fixture to include the new migration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread fusillade-arsenal/src/postgres.rs Outdated
Comment on lines 8442 to 8444
let lease_acquired: bool = sqlx::query_scalar(
"SELECT pg_try_advisory_xact_lock(hashtextextended('fusillade.retention.sweep', 0))",
)
Comment thread fusillade-arsenal/src/postgres.rs Outdated
Comment on lines +8544 to +8549
SELECT 1 FROM requests r
WHERE r.batch_id = batches.id
AND r.state = 'canceled'
AND r.claimed_at IS NOT NULL
AND r.canceled_at > NOW() - make_interval(secs => $3)
)
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch from 70f57d0 to 38678f8 Compare August 14, 2026 09:30
@pjb157

pjb157 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Re-review follow-up is available in 38678f83.

  • Canceled in-flight redaction now resolves the directly linked model-call step to its canonical response-chain head and scrubs the head plus every descendant, including tool-call rows whose request_id is intentionally NULL.
  • Candidate discovery uses the same chain traversal, so content written into a descendant after an earlier redaction is found and scrubbed on the next sweep.
  • The real-Postgres regression was observed failing with descendant argument/result JSON intact before the fix, and now passes for both the initial and repeated sweep.
  • Refreshed the SHA-384 fixture for the new, unreleased retention-index migration after its rollout documentation changed.

Verification:

  • cargo test -p fusillade-arsenal retention_sweep — 8 passed
  • cargo test -p fusillade-core retention — 3 passed
  • cargo test -p fusillade retention — 2 passed
  • just lint rust -- -D warnings — passed against a fresh Postgres schema, including formatting, workspace clippy, payload-logging guard, SQLx prepare check, migration checksums, and repository contract scripts
  • git diff --check — passed

@pjb157 pjb157 changed the title feat: add policy-driven content retention feat: add partitioned content retention Aug 14, 2026
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch from 8f4aa4d to 3a432e9 Compare August 16, 2026 10:43
@pjb157 pjb157 changed the title feat: add partitioned content retention feat: retain terminal responses in daily partitions Aug 16, 2026
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch from 26687f4 to 57720e7 Compare August 18, 2026 09:22
@pjb157 pjb157 changed the title feat: retain terminal responses in daily partitions feat: enforce scheduled content retention by partition drop Aug 18, 2026
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch 3 times, most recently from accd28f to 16c5d1b Compare August 28, 2026 16:38
@pjb157
pjb157 requested a balanced review from Copilot August 29, 2026 06:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 45 out of 69 changed files in this pull request and generated 9 comments.

Files not reviewed (20)
  • .sqlx/query-0e3c39515a74144bf61120be2f385ada3738bf7dc103f6820ea190a99436d8b6.json: Generated file
  • .sqlx/query-0ea266b1cfd0bbeec6bed1109c38940ef373e1474f7f286220bd6b905fbb35b9.json: Generated file
  • .sqlx/query-2b18b64a09470cee2ebb3f1c06a4095288aefe10a55d6cce07eaed4626b6c601.json: Generated file
  • .sqlx/query-3c783fe881372a9177855d281a5fa906895f53ad1d555ddadc994258e4b05577.json: Generated file
  • .sqlx/query-3c9a4ebeec6e5889f366c376a46f2e34b2a48a64d55b820574f9b143112acf76.json: Generated file
  • .sqlx/query-4e8d32f7217b3715aa3069edc1ecf17ba880c37ba2b02e914484a37daf771ed9.json: Generated file
  • .sqlx/query-4fb673d433e357250c903514f2941b122a7b4893fff7d7738a760a09df49b941.json: Generated file
  • .sqlx/query-510a40c1fae33eda5611cd2478a78fe9edf262712a929354833f0ef990c75968.json: Generated file
  • .sqlx/query-62e80a95cf89295b67c04690d00466d5705dca7d7a4c93fafa16a6468448e3d3.json: Generated file
  • .sqlx/query-6f4084ac640045c445935f0e8e68821b4b56c7dfe6494cfb0eb8a5bcf33db634.json: Generated file
  • .sqlx/query-6f7622b6deb65c0bb31d90322f8eecae0fdc9fa5c09c5bbacf193af8c97dfe13.json: Generated file
  • .sqlx/query-7b2adf40a1b00739c30a4cf9b44ee1c7f93cde2614e92de7ecc2e15d008b4173.json: Generated file
  • .sqlx/query-7c05cbf270bf88931106f291956e61d7a6411da0ead5203755cdb28c9c95244a.json: Generated file
  • .sqlx/query-8697bbc253414f56c65f12813550249a5e67d9f283bad5985e3b16ae4271abc3.json: Generated file
  • .sqlx/query-86badacda60db381aa221fa2c0e96317887283f776a49e49734116622abacb2a.json: Generated file
  • .sqlx/query-967c8d2bb06462fabea767d1ada53939e56495e4ac95cdff7224875604c05067.json: Generated file
  • .sqlx/query-aa821d673e92d20f27f9e35f8826cb6f65d28a713edc622c73f8dd1a081d11f5.json: Generated file
  • .sqlx/query-b2288aa8eb278dd09aae8a22e9b3cbba6dbb79143509fba10e3c422f282fd4f9.json: Generated file
  • .sqlx/query-f2f6500e57dc2783a6ae16f86b7d9cd6aa7653dd8dc01ff904d9fc20ccf21bb5.json: Generated file
  • .sqlx/query-fb744db440734acb1bb6b86586d94b6c86de2cdc6b3093cedbbc7769450266ba.json: Generated file

Comment thread dwctl/src/lib.rs Outdated
Comment on lines +686 to +687
Ok(archive_daemon_enabled
&& (daemon.retained_response_retirement_enabled || daemon.batch_archive_retirement_enabled || state.unfinished_retirements > 0))
Comment thread dwctl/src/config.rs
Comment on lines +1889 to +1893
/// Allow file-content expiry and weekly template partition retirement.
/// Disabled by default and additionally requires an explicit retention
/// period.
#[serde(default)]
pub template_retirement_enabled: bool,
Comment thread README.md
Comment on lines +311 to +322
# Optional retained-response lifecycle. It has no implicit retention
# period; movement and retirement default off. Current scheduled support
# covers terminal batchless response tiers only. Each configured tier
# requires an explicit positive duration and late-writer fence. Scheduled
# file and file-backed batch retention is rejected at startup.
# retention:
# batchless_seconds_by_service_tier:
# flex: <seconds>
# max_late_writer_seconds: <seconds>
# batchless_archive_sweep_enabled: false
# batchless_archive_backfill_enabled: false
# retained_response_retirement_enabled: false # Reserved; true is rejected until retirement ships
Comment on lines +4 to +6
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM request_templates_g2 LIMIT 1) THEN
Comment thread dwctl/src/lib.rs
Comment on lines +2829 to +2833
Ok(Ok(Err(error))) => {
tracing::error!(
error = %error,
"Fusillade daemon failed while stopping after leadership loss"
);
Comment thread .github/workflows/ci.yaml
-e POSTGRES_HOST_AUTH_METHOD=trust \
-p 5432:5432 \
postgres:17 \
postgres:latest \
Comment thread fusillade/README.md
Comment on lines +292 to +298
Current scheduled lifecycle support is limited to terminal batchless response
graphs in the `priority`, `flex`, and `background` service tiers. Every
configured tier needs an explicit positive duration, and batchless policy also
requires an explicit positive late-writer fence. Scheduled file retention and
terminal file-backed batch retention are rejected until their complete payload
lifecycle is supported. Explicit deletion continues through the ordinary
orphan purge and is independent of scheduled retention.
Comment on lines +192 to +205
parent_table <> 'batch_requests_archive'
OR (
partition_schema IS NOT NULL
AND partition_schema_oid IS NOT NULL
AND parent_oid IS NOT NULL
AND lower_bound IS NOT NULL
AND upper_bound IS NOT NULL
AND upper_bound = lower_bound + 7
AND lower_bound = date_trunc('week', lower_bound)::date
AND partition_table =
'batch_requests_archive_y' || to_char(lower_bound, 'IYYY')
|| 'w' || to_char(lower_bound, 'IW')
)
)
Comment on lines +3259 to +3262
if self.storage.supports_retained_response_route_cleanup()
&& self.config.purge_interval_ms > 0
&& self.config.purge_batch_size > 0
{
The mover deliberately excluded graphs whose deletion day had already
passed (a partition never drops on its own day), leaving them to an
'explicitly gated legacy path' that no longer existed after the
row-deleting sweeper was removed. Such content therefore stayed live,
and undeletable, forever. Found in the cl-1548 preview, where 27M
terminal rows older than the (minimal) retention period never moved.

The backfill worker now owns that gated path: it discovers due graphs
oldest-first with no per-tier lower bound, and move_graph lands
anything already due on the day after observation, the earliest day
that can still be dropped. Retention is only ever extended, by at most
the time a graph spent overdue. The ordinary sweep is unchanged and its
fairness guarantees keep their tests.
The oldest batchless traffic (April-May 2026) shares its input template
with a file. The mover treated any file-owned template as an incomplete
graph and, worse, one failed graph aborted the whole pass, so the cl-1548
backfill logged 'graph is incomplete' every second and never moved a row.

Templates are now classified per graph: a dedicated template nothing else
references is owned (moved and deleted with the graph); a file's template
or a shared dedicated one is external input content governed by its own
lifecycle, snapshotted into the retained record so it stays
self-describing and left live. An incomplete graph is counted in
RetainedResponseArchiveOutcome::incomplete_skipped and left live while the
pass continues, so a malformed graph can never stall the worker again.

Verified: fusillade-arsenal (all suites), fusillade and fusillade-core
green; lint clean. The dwctl suite's failures locally are connection
exhaustion on a 100-connection dev Postgres (they pass in isolation).
In a split topology the API pods share the daemon pods' environment but
run with batch_daemon.enabled = never. Config validation rejected every
retention flag on such an instance ('automated retention requires the
batch daemon to run'), so the cl-1548 preview's API pods crash-looped on
startup while the old pod kept serving, silently blocking every rollout.

An instance that never runs the daemon owns no archive maintenance, so
the ownership-gated validations no longer apply to it; the hard error
becomes a warning so a single-instance deployment cannot configure
retention that nothing enforces without noticing.

Verified: dwctl config tests, lint clean.
…nce session

The startup preflight that builds the retained-response maintenance
session rejected any instance with retirement flags but the daemon
disabled ('requires an enabled archive daemon'). Split deployments hit
exactly that: the API pods share the daemon pods' environment, so the
cl-1548 preview's API pod crash-looped and its stuck rollout exhausted
the namespace quota, blocking the daemon pods too.

An instance whose daemon never runs owns no maintenance: it starts
without a session and warns if durable work is pending, leaving recovery
to the daemon pods. Corrupted lifecycle identity still fails every
instance. Verified: preflight and config tests, lint clean.
The claim, batch-results and request-detail paths joined the two-generation
template views with a plain equi-join, which the planner turned into a hash
of the entire legacy template table on every claim (a 40M-cost plan that
never finished against the production-sized preview, so cutover batches were
never claimed). Correlated LATERAL lookups with LIMIT 1 keep the join as a
primary-key probe on both arms.

dwctl still counted a file's templates from the legacy table, so batches
created after the cutover were validated against zero templates and lost
their model label.
hydrate_previous_response deserialised the compact object the store persists
straight into the full wire-shape struct, so extending any stored single-step
response failed with 500 (missing `tools`). Backfill the same defaults the
retrieval endpoint applies. The new test also pins that extension keeps
working after the first turn's graph has moved to the retained store.

Also make the leader-election retry test's polling loops real-time bounded:
a fixed iteration count only bounds paused virtual time, and the unlock
round-trip on a loaded CI runner outlived it (three consecutive CI failures
on unrelated pushes).
Review follow-ups for the template cutover:

- request_templates_g2 is keyed (created_on, id); the routed by-id probe was
  a range scan of the whole week's primary key on PostgreSQL 17 (production
  runs 17.11, no skip scan). Add an id-leading partitioned index.
- Nothing called ensure_request_template_partitions, so the first upload of
  each UTC week paid CREATE TABLE + ATTACH inside its own upload
  transaction. The weekly archive-partition loop now extends the template
  runway too, with its own gauge.
- The mover hashed both payload byte strings server-side to compare them;
  both are already in memory, so compare directly and save two round trips
  per graph.
- Document the created_at-based index expire_file_content actually needs.
…request

The server-side tool loop that wrote response_steps was removed from the
edge in August 2026; the table stopped growing on 2026-08-12, only ever
linked five batches, and no retained snapshot contains a step. Carrying it
cost the retention lifecycle its most complex code paths: a recursive
step-chain closure in the mover, step routes and fences, a sweeper guard
that wedged whole archive weeks behind step-linked batches, and a head-step
indirection on every response read.

- Drop the table (new migration) and strip step routes, the step object
  kind, and the head_step_id/step_sequence columns from the unreleased
  retention migration.
- A retained graph is now exactly a request plus its template; the group
  id is the request id. Discovery, locking, movement, erasure, and
  retirement lose their step branches.
- dwctl treats resp_<uuid> as the request id directly and no longer wires a
  step store; the unused multi-step config block goes with it.
- Tests about chains and multi-member graphs are removed; tests that used
  a step only as a fixture now use the request directly.

Bumps the storage crates' major version: ResponseStepStore and the step
types are gone from the public API.
It passes locally on every run but times out on the CI runner even with a
60s real-time budget while the tokio clock is paused. It has failed three
consecutive CI runs on unrelated pushes; ignore it until the paused-clock
database interaction is reworked on main.
Four production batches were blocked from moving (by the old response-steps
guard) until after their own archive week had been retired and dropped. The
sweep then retried them into the missing partition on every tick, logging
an error each time, and they could never leave the live table. When the
frozen week's bucket is no longer active, stamp the batch into the current
week instead: a retired week never receives rows, and a later retirement
date is the safe direction.
Correctness:
- Two dwctl readers (tier-2 ingest validation, analytics recompute) and the
  request-detail/list paths still read the legacy template table; after the
  cutover they saw nothing. All read the generation-transparent view.
- template_retirement_enabled never opened a maintenance session, so the
  documented rollout step crash-looped. Wired in and validated with its
  retention period, mirroring the batch-archive flag.
- A failed leadership renew ping returned the leader connection to the pool
  with its advisory lock possibly still held, stranding leadership until the
  connection was reaped. Route it through release_leader_connection.
- Erasure in the live+retained overlap deleted only the live copy, so the
  retained snapshot resurfaced once the live row was gone. Both go.
- previous_response_id hydration performed no ownership check; another
  tenant's turn could be spliced into the caller's prompt. The store now
  resolves the row and requires the caller's user id before returning any
  content (pre-existing on main, now reachable through the retained store).
- After the template FK drop, a pending request whose template was purged
  occupied a claim slot forever. The orphan purge fails such rows.
- The batch archive mover takes a share lock on the target week's registry
  row so a retirement fence can never land between its check and its insert.
- Archived-request reads dropped templates for batches whose input file was
  deleted (file_id compared with '=' against NULL).

Operability:
- The list total count runs on the read pool with a 100ms budget per arm and
  falls back to the planner estimate, as main did; it no longer pins a
  primary connection for a full scan.
- Maintenance loops log the content-free error class; erasure maps a
  mid-retirement conflict to 409 instead of an opaque 500; leadership-loss
  stop failures go through background_error!; startup preflight errors keep
  their (payload-free) cause; the drop migration bounds its lock wait.

Tests added for each behavioural fix; suites green.
@pjb157
pjb157 force-pushed the peter/retention-lifecycle branch from 516c06e to e21d94f Compare September 4, 2026 08:41
@pjb157
pjb157 merged commit fc5aeae into main Sep 4, 2026
23 checks passed
pjb157 added a commit that referenced this pull request Sep 4, 2026
🤖 I have created a release *beep* *boop*
---


##
[11.2.0](v11.1.1...v11.2.0)
(2026-09-04)


### Features

* **analytics:** record engine cached tokens and content-free request
params
([#1553](#1553))
([411fabc](411fabc))
* **clickhouse:** shared warehouse connection and a generic best-effort
insert sink
([#1554](#1554))
([6faf6c0](6faf6c0))
* enforce scheduled content retention by partition drop
([#1481](#1481))
([fc5aeae](fc5aeae))


### Bug Fixes

* **deps:** bump browserslist to 4.28.8 (Dependabot
[#175](https://github.com/doublewordai/control-layer/issues/175)/[#176](https://github.com/doublewordai/control-layer/issues/176))
([#1552](#1552))
([9f4d912](9f4d912))
* **deps:** finish OpenTelemetry 0.32 migration via outlet 0.10 (CVE-2…
([#1550](#1550))
([7c6462f](7c6462f))
* search customer-visible model fields
([#1560](#1560))
([486383d](486383d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fergusfinn pushed a commit that referenced this pull request Sep 5, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.0.0](onwards-v0.38.1...onwards-v1.0.0)
(2026-09-05)


### ⚠ BREAKING CHANGES

* remove tool injection, tool_sources, and the onwards Responses adapter
([#1518](#1518))

### Features

* enforce scheduled content retention by partition drop
([#1481](#1481))
([fc5aeae](fc5aeae))
* midstream retries
([#1453](#1453))
([48be316](48be316))
* **onwards:** count gateway failures by reason and upstream status
([#1519](#1519))
([fce9cbd](fce9cbd))
* remove tool injection, tool_sources, and the onwards Responses adapter
([#1518](#1518))
([4276d91](4276d91))


### Bug Fixes

* **dwctl:** tolerate backend-omitted fields in Responses translation,
stop retry loops on translate failure
([#1544](#1544))
([04c07c9](04c07c9))
* fold SSE streams incrementally and drop async-openai from dwctl
([#1503](#1503))
([2995d73](2995d73))
* **onwards:** drain active responses during shutdown
([#1586](#1586))
([416ef7f](416ef7f))
* **onwards:** retry slow empty SSE streams
([#1510](#1510))
([4df7a30](4df7a30))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
sejori pushed a commit that referenced this pull request Sep 15, 2026
🤖 I have created a release *beep* *boop*
---


##
[6.0.0](fusillade-core-v5.0.0...fusillade-core-v6.0.0)
(2026-09-15)


### ⚠ BREAKING CHANGES

* **dwctl:** move stream reassembly out of the batch daemon
([#1535](#1535))

### Features

* enforce scheduled content retention by partition drop
([#1481](#1481))
([fc5aeae](fc5aeae))
* **fusillade:** add configurable batch leak intervals
([#1712](#1712))
([c4bfb3d](c4bfb3d))
* **fusillade:** move overdue batchless graphs concurrently in the
backfill
([#1567](#1567))
([b0ed45c](b0ed45c))
* **fusillade:** own batch finalization in a daemon loop, decouple no…
([#1462](#1462))
([83066bb](83066bb))


### Bug Fixes

* prune retained partitions in the trailing-demand query by retent…
([#1587](#1587))
([909ec68](909ec68))


### Code Refactoring

* **dwctl:** move stream reassembly out of the batch daemon
([#1535](#1535))
([e9c1795](e9c1795))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
sejori added a commit that referenced this pull request Sep 15, 2026
🤖 I have created a release *beep* *boop*
---


##
[4.1.0](fusillade-arsenal-v4.0.0...fusillade-arsenal-v4.1.0)
(2026-09-15)


### Features

* **dwctl:** pooled and direct connection pools per database
([#1565](#1565))
([73a2080](73a2080))
* enforce scheduled content retention by partition drop
([#1481](#1481))
([fc5aeae](fc5aeae))
* **fusillade:** add configurable batch leak intervals
([#1712](#1712))
([c4bfb3d](c4bfb3d))
* **fusillade:** move overdue batchless graphs concurrently in the
backfill
([#1567](#1567))
([b0ed45c](b0ed45c))
* **fusillade:** own batch finalization in a daemon loop, decouple no…
([#1462](#1462))
([83066bb](83066bb))
* Improve page load for batches page
([#1744](#1744))
([e720a9e](e720a9e))


### Bug Fixes

* add index for finalizer sweep
([#1728](#1728))
([a983682](a983682))
* bound retained history reads for single-model response lists
([#1778](#1778))
([28a4746](28a4746))
* **dwctl:** count pending requests outside the admission lock; index
the batched demand branch
([#1531](#1531))
([73e8f80](73e8f80))
* Formalize indices missing from migration and drop unused ones
([#1568](#1568))
([0866443](0866443))
* **fusillade:** bound retained response page scans
([#1699](#1699))
([f7c64cf](f7c64cf))
* **fusillade:** drop the stranded-request purge step that times out
([#1576](#1576))
([b9cae7a](b9cae7a))
* **fusillade:** give the count-estimate EXPLAIN its own budget
([#1661](#1661))
([325ab85](325ab85))
* **fusillade:** keep request polling stable during retention
([#1742](#1742))
([0ccdf8c](0ccdf8c))
* **fusillade:** page batchless archive discovery and hot-loop busy
movers
([#1715](#1715))
([f4d8737](f4d8737))
* **fusillade:** probe the template per picked row in the claim-time
stranded check
([#1580](#1580))
([1433908](1433908))
* keep the trailing-demand retained arms under the statement timeout
([#1723](#1723))
([c4390d3](c4390d3))
* prune retained partitions in the trailing-demand query by retent…
([#1587](#1587))
([909ec68](909ec68))
* support scoped owners for pooled component connections
([#1721](#1721))
([1bd4127](1bd4127))


### Performance Improvements

* **fusillade:** index the batchless branch of the demand query
([#1516](#1516))
([6833cb0](6833cb0))
* **fusillade:** make the unscoped Responses page usable
([#1740](#1740))
([ddfc06a](ddfc06a))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Seb <seb@doubleword.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants