Plan: Retention Enforcement — Transport Sweeps (#965) + Archive-Then-Prune (#1129)
On this page
Status
| Unit | Description | Status |
|---|---|---|
A1 |
Step 0: D-A resolution recorded on #965 + #1129; labels/relations/epic; both plans updated + nav |
Done (2026-07-26) — notes 3603012745/3603012780; #965 → epic &71 |
A2 |
#965 Steps 2–4: retention settings (30/31 split, |
Done (2026-07-26) — craig-mq retention.rs + OutboxWorker integration; 4 scratch-DB tests green live |
A3 |
Extensions: |
Done (2026-07-26) — spawn_local_sweep shared loop; exchange gained its minimal lib target; clamp threaded via ServerOptions |
A4 |
Watchdog invariants (4 transport) + grace constants + catalog mapping + red-harness (scratch-DB seams) |
Done (2026-07-26) — committed with A3 (the red-harness folds into A3’s scratch suites) |
A5 |
ADR-058 + ADR-022 correction + docs + CHANGELOG; MR-A battery/merge; close #965 |
Done (2026-07-26) — MR !1062 merged (a3e8a459); #965 closed; + f7cfc0ed rustdoc module-doc link fix |
B1 |
|
Done (2026-07-26) — 20 unit/property + 6 scratch-DB engine tests green live; J-reviewed (consent re-check blocker fixed in-commit) |
B2 |
craig-security: engine wiring + |
Done (2026-07-26) — 7-test direct-router suite (enabled end-to-end, D14 purge matrix, D16 forge/replay/divergence, D11 403) green live; live-API consent tests flip green at the battery’s container rebuild |
B3 |
craig-rules: LOCAL |
Done (2026-07-26) — production-spec scratch suite green live (ledger + contract-shaped event + atomicity); archiver OFF in compose per D20 |
B4 |
CLI + web/model consumers; archive invariants (3) + red-harness; the non-vacuous AC3 gate |
Done (2026-07-26) — CLI destructive rework + 3 watchdogs red-harnessed in the B2/B3 suites; 2 CLI consent tests flip at the battery rebuild |
B5 |
Runbook + |
Done (2026-07-26) — this commit (runbook, archive-fetch, ADR-058 as-built amendments, ADR-003, config/data-model/shared-crates/CHANGELOG, plan → Archive); #1129 closes at MR-B merge |
Issues: #1129 (program, epic &71, weight 5) + #965 (MR-A vehicle)
Branches: feature/1129-retention-sweeps (MR-A, Closes #965 + Relates to #1129); feature/1129-archive-engine (MR-B, Closes #1129)
Related: #1073 (⁂ money/policy readings — the DFCS retention schedule rides it); the #965 plan (MR-A’s detailed spec — settings, lock, batch loop, loop integration, its own test plan; archived at MR-A merge)
ADRs: ADR-058 (new, lands in MR-A), ADR-022 §Retention correction, ADR-003 additions
|
This program plan was reshaped by an external stop-ship review (2026-07-26; ~10 stop-ships ~15 high-severity findings). The Review-Disposition Index at the end maps every finding to its fix. The single largest correction: MR-A does not invent a new sweeper — it implements the pre-existing, adversarially-reviewed #965 plan, whose one BLOCKING open decision (D-A, compliance classification) was resolved by the user on 2026-07-26 and is recorded on both issues. |
Context
The 2026-07-25 performance pass (epic &71) found every churn table unbounded; only
idempotency_responses and jws_seen_jti have working cleanup. #965 already carries the corrected
transport-sweep design — published-only outbox pruning, the corrected §D3.1 window invariant, the
pinned-connection advisory lock, anomaly caps — and was blocked solely on the records-classification
question the user resolved: transport tables are delivery/dedup artifacts, not records of the
system of truth; the audit-class tables (audit_log, dead_letter_audit, rule_evaluations — the
latter the SINGLE copy of every evaluation’s input/output since #1130) are records surfaces and are
never hard-deleted while unarchived.
CRAIG is a child-welfare system of record: fair hearings, child-fatality reviews, and federal audits can demand the trail years later, and retention length is DFCS’s call, not a code constant. Deletes are irreversible; archives preserve every possible compliance answer. Hence the two classes.
The flow at a glance
-
Transport sweep (inside the existing
OutboxWorkerloop — zero new tasks): hourly + jittered, after the drain; batched deletes under a pinned-connection advisory lock; outbox published-only at 30 d, inbox all-aged at 31 d (inbox > outbox + cadence + skew); cycle cap against clock jumps. Exchange/financial reuse the exposed batch helper for their two terminal-state tables. -
Archive pass (consent knob, default OFF everywhere including devstack): select rows older than the 90-day hot window → NDJSON + manifest to the object store with no-overwrite conditional puts → ONE transaction: id-keyed DELETE + the service’s LOCAL ledger row (+ staged event). Bounded per-pass budgets; poison rows quarantined, never wedging the pass.
-
Ledgers: each archiving service owns a durable local ledger written in the prune transaction (security:
archive_records; rules: newarchive_ledger). Therules.evaluations_archivedevent to security is secondary fleet bookkeeping — validated, idempotent, divergence-detected. Orphan GC is defined ONLY against the local ledger. -
Purge (operator, bounded, attributed): every destructive statement independently requires
retention_until IS NOT NULL AND retention_until ⇐ CURRENT_DATE AND NOT legal_hold— refusing everything until DFCS names a schedule.
Decisions
| # | Fork | Choice | Why |
|---|---|---|---|
D1 |
Transport sweep home |
|
Zero new tasks/wiring on 8 services; drain-before-prune ordering comes free. |
D2 |
Outbox predicate |
Published-only: |
#965 D-B: pending rows are the ONLY evidence of a lost cross-service notification (no outbox retry-cap/DLQ exists); published-only also never touches an active claim (claims exist only on unpublished rows). ADR-022’s "both" wording is corrected; the migration comments are sqlx-checksummed (immutable) and noted as superseded. |
D3 |
Windows |
Outbox 30 d / inbox 31 d, separate knobs; |
#965’s corrected §D3.1: equality fails at the boundary (two DBs prune asynchronously); inbox ≥ outbox + cadence + skew, and the 1-day margin dominates both. |
D4 |
Replay boundary |
|
Replay currently has NO retention cutoff — the dedup row for an older replay may already be pruned → re-executed consumer side effects. Mechanical, not runbook. |
D5 |
Single-pruner guard |
|
The on-the-pool version leaks the lock onto an idle connection and retention silently self-disables. |
D6 |
Anomaly guard |
|
A clock jump / NTP glitch must not mass-delete; bounds WAL and shutdown latency. |
D7 |
Archiver home |
New crate |
Two consumers; per-service copies bust the B8 budget; craig-store stays storage-only. |
D8 |
Local ledgers |
rules gets its OWN |
An async event can DLX or (post-MR-A) age out while pending — ledger absence must never imply orphan-hood. GC is local-ledger-only. |
D9 |
Store hardening |
|
The archive must be immutable and cross-service durable; |
D10 |
Boot posture |
When |
Initialization only constructs a client today; consent means the operator asked for durable archives. |
D11 |
Consent at every entry |
The scheduled worker AND |
An endpoint bypassing the knob would prune into an unchecked store. |
D12 |
Bounded everything |
|
Unbounded HTTP drains die silently on client disconnect; purge needs the same discipline. |
D13 |
Poison rows |
A row alone exceeding |
A halt-on-oversize would wedge archival forever on one row. |
D14 |
Purge predicate |
Every destructive SELECT/UPDATE/DELETE independently requires |
The boolean alone can be stale (the seed proves it); legal holds model DFCS’s future per-record reality. |
D15 |
Attribution |
Manual |
Irreversible acts carry actors. |
D16 |
Event trust |
Security’s typed branch validates BEFORE any write: |
The wildcard subscriber consumes |
D17 |
Addressability |
Ledgers carry |
After 90 d an |
D18 |
Immutability precondition |
All three tables verified insert-only (no UPDATE sites in-tree); ADR-058 records "archive-then-prune requires insert-only sources" as the onboarding precondition |
Selection-then-delete archives a version; mutation in between would silently diverge. |
D19 |
Watchdogs vs monitors |
4 transport + 3 archive invariant specs, report-only, thresholds = default windows + grace, documented as devstack/CI drift tools; production observability = |
The invariant runner is a pre-push tool; hard-coded thresholds are config-blind by nature. |
D20 |
Devstack posture |
Archiver default OFF in compose; devstack tests construct engines directly (scratch DBs, per-test |
Enabling in compose proves nothing (hourly tick) and would race shared-devstack tests destructively. |
MR-A — transport sweeps
Branch feature/1129-retention-sweeps, Closes #965 + Relates to #1129. The detailed mechanics
(settings shape, lock protocol, batch loop, loop integration, #965’s own test plan) live in
the #965 plan; this section carries only the
program-level deltas.
-
A1 — this commit: D-A recorded on both issues (notes 3603012745 / 3603012780),
Plan::RETENTIONlabels, #965 ↔ #1129 ↔ #1073 relations, #965 onto epic &71; both plan files + nav. -
A2 — #965 Steps 2–4 as specified there, plus: checked
u32→i32conversions formake_interval/LIMIT binds; window upper bound (≤ 3650) inload(); a definedPruneReport { outbox_deleted, inbox_deleted, capped }; BOTH explicit settings test initializers updated (crates/craig-common/src/settings.rs+crates/craig-api/src/bootstrap.rs). -
A3 —
prune_table(conn, &RetentionPolicy)exposed from craig-mq. Exchange gains a minimal lib target exposingretention_policies(binary-only today); policystatus IN ('sent','failed') AND completed_at < cutoff. Financial’s policy (processed_at IS NOT NULL AND processed_at < cutoff; dead-letter rows untouched by construction) lives in its exposed lib modules. Both run tiny hourly sweeps with their own advisory keys. Admin-replay clamp per D4. -
A4 — invariants
outbox_retention_overrun(published-only, > 32 d),inbox_retention_overrun(> 33 d),send_jobs_retention_overrun,reconcile_settled_retention_overrun; grace constants
grace_literals_match_constantsmapping rows; red-harness on scratch DBs (the scratch schemas deliberately OMIT the outbound FKs — both point out of the swept tables and cannot affect the DELETE path; recorded here as the as-built deviation from the earlier FK-valid-parents wording). -
A5 — ADR-058 (both classes; the archive protocol marked "Accepted — MR-B implements"); ADR-022 §Retention correction; outbox/inbox module docs; configuration-reference; data-model rows across all 8 service pages; CHANGELOG. Battery → merge → close #965 → its plan → Archive.
MR-A ships ZERO migrations (#965’s index deferral stands: post-retention the tables are bounded and the batched hourly scan is acceptable; a follow-up files if prune latency shows).
MR-B — archive engine
Branch feature/1129-archive-engine, Closes #1129.
-
B1 —
crates/craig-retention(workspace member):lease.rs(pinned detached-connectionAdvisoryLease+ drop-guard, keyretention-archive);spec.rs(ArchiveTableSpec; select returns(id, ts, to_jsonb(t))so first/last timestamps are typed, not JSON re-parsed);manifest.rs(ArchiveManifest: formatndjson/v1, archive_id, service, table, record_count,data_key
manifest_key+data_sha256, store endpoint + bucket, first/last record id + timestamp, hot_window_days, archived_at, fullColumnSpecschema {name, data_type, is_nullable, char_max_len, numeric_precision, default} + fingerprint over the full tuples);engine.rs(run_pass(budget),PassReport { per_table, more }, errorsAlreadyRunning | Store | Db | Bookkeep(BoxError) | Config; batch = SELECT → NDJSON with byte-cap/quarantine → sha256 →put_createdata then manifest → ONE tx: id-keyed DELETE withrows_affectedmismatch rollback + the service’s bookkeep closure (BoxFuture<Result<(), BoxError>>) → commit); the scheduled worker (biased-select, jittered, budget 120, per-table isolation,archive_last_success_timestampgauge);RetentionArchiveSettings { enabled: false, hot_window_days: 90 (1..=3650), interval_seconds: 3600, batch_size: 1000 (1..=10_000), max_batch_bytes: 32 MiB (validated ≤ the Store cap at boot) }. craig-store gainsput_create+delete_if_exists. Unit + proptest suite.As-built deltas (2026-07-26):
PassReport.moreis deliberately FALSE for error-stopped tables (re-running into the same failure would loop an operator’s/runclient;TableReport.errorcarries that signal) — a refinement of D12’s wording;max_batch_bytesgains a 4096-byte floor inload(); the D9 Local-backend refusal lives inboot_probe(NOT engine construction) so the engine stays hermetically testable against a tempdir store;spawn_archive_workertakesenabledexplicitly and refuses an unconsented spawn (D11 defense-in-depth); craig-store additionally gainsStore::is_local_backend+StoreError::AlreadyExistsand re-exportsStoreBackend; deps addbytes/serde/opentelemetry(otel twin) beyond D7’s list; the workspaceserde_jsongainsfloat_roundtrip(archived NDJSON must parse back to the exact f64s written — a round-trip proptest caught the default parser’s ULP drift; regression seed committed). -
B2 — craig-security: migration (
SET statement_timeout = 0):archive_recordsDROP NOT NULL onretention_until, ADD data_key (no object-key column exists today)/manifest_key/data_sha256/store_bucket/ first_record_id/last_record_id/first_record_at/last_record_at/hot_window_days/legal_hold/purged_by, REPAIR stalepurge_eligible, +dead_letter_audit (dlq_received_at)index. Modelretention_until → Option<NaiveDate>; craig-webArchiveViewOption + template placeholder; utoipa. Engine wiring (specs audit_log + dead_letter_audit; bookkeep = ledger insert
publish_archive_completedextended payload); D10 boot probe; detection-window boot WARN guard;api/archive.rsrework per D11/D12/D14/D15; existing archive tests + the direct-router fixture rewritten; parser arms + validate-then-write inbound branch per D16.As-built deltas (2026-07-26): the ledger ALSO carries
store_endpoint(D9 names bucket+endpoint for the ledger; the unit list above omitted it);/purgeis consent-gated too (D11 — an unconsented deployment cannot verify its store); the D14 predicate is re-checked per candidate IMMEDIATELY before the object deletes (the store deletes are destructive statements too), and a stamp refused after deletes escalates toerror!naming bucket versioning as recovery; purgemore= selection-full heuristic (an empty slice is the drain proof); D15 rides per-invocation engines (manual =claims.sub, scheduled =system:retention); asecurity.archive_purgedevent carries the purge actor; refused D16 envelopes WARN-and-skip (never an error — a forged event must not poison-loop the queue; the generic audit row still records it);handle_inbound_eventispubas the D16 test seam; the seeder’s placements archive row dropspurge_eligible=true(the S4 stale state) with the SECURITY byte-identity hash re-blessed.J-review remediations (2026-07-26, in-commit): purge is scoped
source_service = 'craig-security'at all THREE statements — the craig-rules rows inarchive_recordsare fleet BOOKKEEPING copies whose objects live under rules' store identity and whose lifecycle rules' ownarchive_ledgerdrives (a fleet row’s survival is pinned in the purge matrix); the D16 divergence comparison is a pure fn (unit-killable) and includesmanifest_key; therules.evaluations_archivedcontract REQUIRES non-emptystore_endpoint/store_bucket(D9 ledger identity — B3’s emitter carries them; security’s own event extended to match);archived_by_namestays NULL at ledger inserts (it is the display-name column). Accepted untested residues (reviewed, recorded): the migration’s one-shot REPAIR UPDATE (a no-op in every fixture DB that runs it), the stamp-refused-after-object-deletes branch (raceless injection impossible; 6 reviewed lines escalating toerror!), and `boot_archiver’s probe→warn→spawn sequence beyond B1’s spawn-refusal tests. -
B3 — craig-rules:
archive_ledgermigration (PK archive_id, the manifest ledger facts);archive_worker.rs(spec rule_evaluations; bookkeep = local ledger insert + stagerules.evaluations_archived); D10 boot probe; compose garage credentials + healthy dependency (archiver OFF in compose per D20); theevaluation_idpointer doc updated.As-built deltas (2026-07-26): the module is
retention_archive.rsbehind a NEW minimal lib target (the craig-exchange precedent — tests drive the production spec/bookkeep, never a copy); rules has NO manual/runsurface (operators drive security’s; rules archives on its own schedule undersystem:retention); the store is constructed lazily inmainONLY when the archiver is enabled (nothing else in rules touches one); the staged event carries the D9 store identity per the B2-tightened contract (its required-field list is pinned from the emit side by the scratch suite, matching B2’s validator-side forgery matrix); the D17 id-range index is(first_record_id, last_record_id).J-review remediation (2026-07-26, in-commit; touches the B1 crate): the manifest’s id range is now MIN/MAX, not positional — batches page by the AGE column, and under write concurrency id order can diverge from age order within a batch, so a positional range could INVERT and silently match nothing in a D17 lookup (the review’s counterexample came from the suite’s own seeding). D17 lookups scan CANDIDATE ranges: normally exactly one; ranges can overlap at batch boundaries, never invert. The B3 atomicity test gained fault injection (outbox renamed away → staging fails inside the prune tx → rollback proves no-ledger-row-without-its-event structurally, not by counting).
-
B4 — CLI
cmd/archive.rsrework (destructive semantics, confirmation, new shapes, disabled-403) + cli.adoc; archive invariants (97 d) + red-harness; the non-vacuous AC3 gate test.As-built deltas (2026-07-26): the CLI confirmation is a type-
yesstdin gate skippable with--yes(the tests pass--yesand assert the D11 refusal surfaces as an error naming the knob);purgegains--limit(1..=100). The three 97-day watchdogs share ONE constant (AUDIT_HOT_WINDOW_OVERRUN_GRACE) and are red-harnessed against the LIVE spec files inside the existing B2/B3 suites rather than new files. AC3’s "gate test" collapses honestly into standing machinery: the artifact assertions (objects + manifests verify + ledger rows + hot rows gone) already run in the B2/B3 suites that driverun_passdirectly, and the pre-push battery’s devstack phase runscargo xtask invariants --gatewith the new (report-only) specs in the catalog — no redundant subprocess test. Seed honesty fix (reshaped by the B4 J-review’s concrete-date arithmetic): seededaudit_logtimestamps move from the 2024 range to within 35 days of the as-of month’s 1st. Frozen seed data ages against wall-clock, so NO deterministic seed stays green under a 97-day watchdog forever — the 35-day lookback gives a fresh devstack a ≥62-day green shelf life from the seed month’s 1st, after which red IS the staleness signal (reseed); the spec’s remediation text says exactly that. SECURITY byte-identity re-blessed (twice more today), other five hashes verified unchanged each time. Accepted residue: the CLI’s interactive type-yespath is untested (hardcoded stdin; fails CLOSED on EOF/garbage — an injectable reader is a nicety, not a gate). -
B5 — deployment-guide
== Retention & archiverunbook (knobs, parser-first enable order, D9 storage requirements, archive-pull +xtask archive-fetch, purge posture, local-ledger-only orphan GC); ADR-058 as-built amendment; ADR-003; api-docs/implementation-guide; shared-crates (craig-retention + Store); data-model-security/-rules; CHANGELOG; ⁂ #1073 note; close #1129; epic &71 tick; this plan → Archive.
Test matrix
All tests @axis-tagged; devstack tests #[ignore = "requires devstack"]; scratch DBs + scratch
store prefixes per D20.
| # | Test | Kind | axis |
|---|---|---|---|
1 |
Outbox prune: aged published deleted; aged PENDING kept (D2); aged claimed-but-unpublished kept; fresh kept |
scratch-DB |
happy |
2 |
Inbox 31-d window distinct from outbox 30-d (32-d row deleted, 30-d row kept) |
scratch-DB |
happy |
3 |
Batching: > PRUNE_BATCH rows cleared across batches, exact counts; cycle cap stops + warns |
scratch-DB |
fault |
4 |
Two concurrent pruners: one yields (advisory lock); lock released on early return (drop-guard) |
scratch-DB |
conc |
5 |
Window 0 skips that table only; |
unit |
sad |
6 |
Replay clamp: in-window replays work; older range → typed 400 |
devstack |
sad |
7 |
Send-jobs/reconcile sweeps: terminal-aged deleted; pending/in-flight/dead-letter rows survive |
scratch-DB |
sad |
8 |
NDJSON + manifest round-trip; sha256 vector; fingerprint flips on ANY column-attribute change |
unit + proptest |
happy |
9 |
Byte-cap truncation → |
unit |
evil |
10 |
Archive round-trip: aged rows → |
devstack |
happy |
11 |
Crash-resume: puts ok, prune tx aborted → second pass re-archives (new id) + prunes |
devstack |
fault |
12 |
Concurrent |
devstack |
conc |
13 |
Rules event → security ledger row; identical replay → one row; CONFLICTING replay → row unchanged + divergence WARN; forged source/table/prefix → refused |
devstack |
evil |
14 |
Purge: NULL retention → 0 purged (incl. the repaired stale-eligible seed row); dated+held → refused; dated+unheld → manifest-then-data deleted + |
devstack |
sad, happy |
15 |
Boot: enabled + Local/probe-fail → refuses naming knob + env var; disabled → no worker, no probe |
devstack |
sad |
16 |
Parser arms resolve both events (never the unknown fallback); cadence consts pinned |
unit |
happy |
17 |
Red-harness: each of the 7 watchdogs fires on seeded aged rows; sweep/pass clears them |
scratch-DB |
sad |
18 |
AC3: harness-driven pass, asserted artifacts, then |
gate step |
— |
Risks
| # | Risk | Mitigation |
|---|---|---|
1 |
First backlog drain on grown tables |
PRUNE_BATCH + cycle cap + hourly cadence + yield/cancel between batches; the no-index deferral is #965’s recorded call — follow-up files if latency shows |
2 |
Replica stampede |
Pinned-connection advisory lock per service DB (D5); the helper sweeps use their own keys |
3 |
Cross-service replay-past-dedup |
D3 asymmetric windows + load() validation + D4 mechanical replay clamp |
4 |
Archive PII in a shared dev bucket |
Dedicated prefix + D9 documented production requirements; devstack caveat recorded; archiver OFF in compose |
5 |
Poison row wedges archival |
D13 quarantine + per-table isolation + metric; the hot-window watchdog names the leftover |
6 |
Ledger/object divergence |
Local ledger in the prune tx (D8); manifest-first purge deletes; divergence WARN on conflicting event replays (D16) |
7 |
Migration timeout on grown tables |
|
Review-Disposition Index
Stop-ships S1–S10, high-severity H1–H15, completeness C1–C8 from the 2026-07-26 external review.
| Finding | Disposition |
|---|---|
S1 second contradictory plan (#965) |
MR-A implements #965 ( |
S2 equal windows unsafe |
D3 30/31 split + load() validation; D4 mechanical replay clamp |
S3 deleting never-published events |
D2 published-only (also excludes active claims by construction) |
S4 purge ignores the posture at the destructive step |
D14 predicate at every statement + repair migration + legal_hold + seed-row pin (test 14) |
S5 /run bypasses consent |
D11 typed 403 when disabled |
S6 orphan GC vs unguaranteed rules ledger |
D8 local |
S7 Local storage insufficient |
D9 Local forbidden for the archiver; bucket/endpoint recorded |
S8 store not immutable |
D9 |
S9 event = unchecked deletion capability |
D16 validate-before-write + divergence detection; purge keys only from the LOCAL ledger |
S10 addressability broken |
D17 id ranges + |
H1 BookkeepFn error type |
|
H2 workspace/test-literal edits unnamed |
Named in A2/B1/B2 (both settings initializers, the authz_fallback router fixture, workspace members) |
H3 purge Store wiring / NotFound contract |
Engine purge method + typed |
H4 unbounded replica-hostile drain |
D5 lock + D6 caps/yield/cancel |
H5 interval-0 tight loop |
Window-0 skips per table; archive interval-0 = worker not spawned (tests 5/15) |
H6 scheduled worker undesigned |
B1 scheduler spec (jitter, budget 120, isolation, gauge) |
H7 unbounded purge |
D12 bounded + lease + manifest-first + idempotent re-run |
H8 oversize row wedges |
D13 quarantine |
H9 SQL types / validation |
Checked i32 conversions + bounds + |
H10 index rollout blocks writes |
MR-A ships no index (#965 deferral); MR-B’s two migrations carry timeout-0 + the CONCURRENTLY note |
H11 dead_letter_audit scan index |
B2 migration adds |
H12 boot doesn’t prove storage |
D10 probe + compose healthy dependency |
H13 detection windows vs hot window |
B2 boot WARN guard |
H14 checksum/key/marker semantics |
data_key + manifest_key + data_sha256; manifest = object-completeness marker only; DB commit = prune truth (stated in ADR-058) |
H15 schema contract + timestamp source |
Select returns (id, ts, jsonb); full ColumnSpec attributes; error enum extended |
C1 watchdogs ≠ monitors |
D19 split: invariants = CI drift tools; metrics/gauges = production observability |
C2 vacuous AC3 |
D20 harness-driven pass + asserted artifacts before the gate |
C3 devstack races |
D20 scratch DBs + scratch prefixes + compose OFF |
C4 missing negative paths |
Tests 1, 3, 4, 5, 9, 11, 12, 13, 14, 15 |
C5 red-harness placement |
Scratch-DB seams; exchange minimal lib target (A3) |
C6 CLI + docs consumers |
B4 CLI rework + api-docs/implementation-guide + the composition data-model page |
C7 MR-A cites unlanded ADR |
ADR-058 lands in MR-A (A5) |
C8 sweeper contract + pins |
PruneReport/PassReport defined; string pins + scratch-DB behavior tests |