docs(specs): retire S04-C and its implementation plan

🗑️ What this does

Retires S04-C (column-level encryption wiring) and its implementing plan 2026-08-02-s04c-encryption-wiring, and repairs every reference they leave behind. First application of the retention rule merged in c2bf2b29, so the resolutions below are the precedent for later retirements.

✅ Eligibility

Finding Re-verified
One implementing plan ✅ 2026-08-02-s04c-encryption-wiring.md alone declares S04-C on its - Spec: bullet. 2026-08-03-s04b-rotation-tooling.md declares S04-B and 2026-09-03-s04a-per-format-credential-columns.md declares S04-A (with S13 named for a preserved policy), so neither is an implementing plan
That plan is completed ✅ three steps, all merged — !1295 (merged) (2026-08-05), !1297 (merged) (2026-08-06), !1298 (merged) (2026-08-06)
No blocking open issue ✅ the issue search on S04-C returns 13 hits, 12 closed; the only open one is work item 1338 itself
No ADR cites it ✅ grep -rn 'S04-C' docs/adr/ is empty, so no handbook amendment is owed and no lychee-excluded link is left dangling
Destination coverage ⚠️ diverges from the pre-flight reading — see below

🔍 Divergences from the pre-flight findings

Two, both reported rather than worked around:

  1. ### Deployment posture (spec lines 125–135) is already carried, contrary to the pre-flight reading that paired it with the counter as uncarried. Its FIPS half sits in docs/dev/fips.md ## CI gate scope (the test:crypto-fips gate scoped to ./internal/crypto/..., and why) and ## Startup assertion (whose closing paragraph already states that the assertion, log and gauge belong to service-wide compliance work rather than this wiring). Its consumer-table half sits in docs/dev/column-encryption.md ## Consumer-table contract, which carries all five items the spec listed. Nothing from that section needed authoring; only ### Fallback-unwrap counter did.
  2. The reference census is 10 files / 31 occurrences over 24 lines, not 12 files / 25 occurrences. Six sites in 2026-09-03-s04a-per-format-credential-columns.md were not in the pre-flight list: lines 19, 127, 137, 1825, 3394 and 3407 at the old numbering. Five of those six are line-pinned citations of the same class as the two that were listed. Nothing merged to main between the pre-flight basis and this branch that added a site, so the difference is in the counting, not in the tree.

📚 Spec section by section

Read against the destination rather than checked off a list.

Spec section Where it lands
Title, **Status:** Approved Dropped: point-in-time. The index row carries the status, now Retired
## Summary Dropped: point-in-time framing ("this spec makes deployable…"). Its FIPS scoping is in docs/dev/fips.md ## CI gate scope
## References Dropped: point-in-time. Every durable target (ADR 023/024/007, S04-A, S03-B, storage-composition.md, fips.md, gitlab-com-infrastructure.md) is reachable from the destination or the live specs
## Dependencies Dropped: a delivery-state table. Its integration points are the ## Quick reference rows in docs/dev/column-encryption.md
## Data Model Nothing to carry: "No database tables"
### Boot sequence docs/dev/column-encryption.md ## What the composition root wires (the four ordered steps, boot-failure rule, skip-when-absent)
### The wireEncryption tier ## What the composition root wires (including the one Info record per boot naming the tier's outcome) and ### The enablement signal
### Fallback-unwrap counter Authored in this MR: ### The fallback-unwrap signal
### Deployment posture Already carried — docs/dev/fips.md ## CI gate scope and ## Startup assertion, plus ## Consumer-table contract, the opening's migration exclusion, and ### Rows the move leaves behind in column-encryption.md. The documentation-pairing assignments in it are point-in-time MR obligations, dropped
## API Contracts Nothing to carry: no endpoints, no new Go API
## Configuration Already carried by docs/dev/configuration-reference.md ## encryption: the block's presence, the local arm requirement, the 10m default and the 1m–1h bounds, and the shred-cooldown derivation
## Acceptance Criteria Dropped: point-in-time. Ten criteria that describe the delivery's test matrix, which merged with !1295 (merged)/!1297 (merged)/!1298 (merged)
## Error Cases Boot taxonomy. The reader-facing half ("any failure on the enabled path fails boot; no degraded mode") is in ## What the composition root wires; the per-sentinel mapping is a code contract of internal/crypto pinned by wire_encryption_test.go, dropped as point-in-time
## Security Considerations Zeroization and the fail-closed posture are in ## What the composition root wires; the label-coercion bound is in the new ### The fallback-unwrap signal; key-material confinement is S04-A's, which stays; the review gates on work item 8 are delivery state, dropped
## Out of Scope Dropped: point-in-time. The live disclaimers it mirrored survive in S04-B's own Out of Scope, repaired below
## Follow-ups, ## Open Questions Both None
## Resolutions 2 The hook contract, authored into ### The fallback-unwrap signal — resolution 2 existed only to say the contract had no other home
## Resolutions 3, 5 Already carried: the absent-block behavior in ## What the composition root wires, the enablement signal in ### The enablement signal
## Resolutions 1, 4 Dropped: 1 is the FIPS scoping now in docs/dev/fips.md, 4 is the work item 8 gate timing, which is delivery state

📝 What column-encryption.md gained, and who owns which half

One new section, ### The fallback-unwrap signal, holding what the spec alone stated: the hook fires on a GetKey fetch of an active namespace key wrapped under a non-active root key; the manager calls it synchronously on the cold-miss path alone, holding no lock, passing the record and active URIs and no namespace id; an implementation must be fast, must not panic and must be concurrency-safe; and the coercion rule with its two reasons and the resulting configured-keys-plus-one bound.

docs/dev/observability.md line 117 already carried the counter's operational semantics — warm-cache behavior, concurrent cold misses, what non-zero proves and what zero does not, the absent() caveat, and that an out-of-set URI reads unknown. That stays where it is. The split is by reader: column-encryption.md tells whoever wires the hook what the contract obliges, observability.md tells whoever reads the samples what they mean. Each page states the split, so neither drifts into restating the other.

24 reference lines across 10 files, of which three are kept as written by rule (closed-beta.md:90 and :660, …s04a…:19). The rule row that governs each is named, since this is the precedent run.

Site Resolution Rule row
docs/specs/S13-virtual-remote-foundation.md:660 Inline spec link → column-encryption.md#the-enablement-signal, which is the contract the sentence reaches for A live document's reference to a retired spec
docs/specs/S32-container-virtual.md:46 Dependency-row first cell → [Column-level encryption wiring](../dev/column-encryption.md#what-the-composition-root-wires), id dropped. Verdict cell lost its Approved status word and now states the contract and the remaining gap alone A live document's reference to a retired spec; a dev-doc table column whose values are spec ids, by analogy
docs/specs/README.md:97 Retired row: unlinked id, Retired status, one-line scope, the MR, the dev doc The index row in docs/specs/README.md
docs/plans/README.md:95 Retired row: unlinked Plan cell, Description led by the retirement marker, and — per the spec README's rule, because the same MR deletes the spec — the Spec cell's S04-C link dropped to plain text The index row in docs/plans/README.md

Dead text CI cannot see

Site Resolution Rule row
docs/dev/observability.md:117 Owner cell S04-C → [column-encryption.md](column-encryption.md#the-fallback-unwrap-signal). The ## Metrics preamble gained one sentence saying the cell names a dev doc where the spec has retired, so the column explains itself, and that the linked page owns the contract while the row owns how to read the samples A dev-doc table column whose values are spec ids — the rule's own worked example
docs/specs/S04-b-encryption-key-rotation-tooling.md:21 References entry relabelled to the dev doc with the delivery epic named after it. The epic link resolves, but it is delivery tracking and does not carry the wiring contract, which is what a reader following this entry came for A live document's reference to a retired spec
docs/specs/S04-b-…:451 not S04-C's → nor the composition-root encryption wiring's; the surface, not the id A live spec never carries a bare id for a retired spec
docs/specs/S04-b-…:456 Out-of-scope entry → the dev-doc section, with the epic kept as the delivery pointer A live document's reference to a retired spec
docs/specs/S32-container-virtual.md:128 S04-C's production encryption wiring → The production encryption wiring, with the dev-doc link A live spec never carries a bare id for a retired spec
internal/metrics/cardinality.go:181 Id dropped; the comment now names the metric vector and the cardinality rule it justifies. Compressed from 8 lines to 2 because the gate charges every segment an edit touches (cap 2 here) — the block keeps the configured-keys-plus-one bound with its coercion, why the labels are not in closedSetValues, and the mirrored fallbackUnwrapURIBudget with its full path; a trailing comment on each budget entry says 5 is headroom that nothing refuses past Code sites are not in the table — ## Comments do not cite process state governs
docs/roadmap/closed-beta.md:90, :660 Bare id kept as written, and the S04-A plan links on :90 left alone (that plan is not being deleted) A dated narrative row
docs/roadmap/closed-beta.md:185 and its section Re-derived over the index, not decremented — see below A live tally that counts index rows
docs/plans/2026-08-03-s04b-rotation-tooling.md:7 is S04-C's → the dev-doc section A live plan never carries a bare id for a retired spec
docs/plans/…s04b…:26, :169 The S04-C encryption wiring → The encryption wiring, and the S04-C chain → the encryption tier's chain. Both sentences already name cmd/artifact-registry/wire_encryption.go or the tier, so the resolvable coordinate was there and the id was the only rot A live plan never carries a bare id for a retired spec
docs/plans/2026-09-03-s04a-per-format-credential-columns.md:19 Plan slug left as plain text, unlinked already A reference to a retired plan keeps the plan's slug
…s04a…:104, :112 The S04-C:199 citation becomes a commit-pinned permalink at the basis the bullet declares; S04-C's FIPS resolution names the spec by surface and links docs/dev/fips.md#startup-assertion, which carries the posture it settled A line-pinned citation into either file
…s04a…:127, :137, :1825, :3394, :3407 Each becomes a commit-pinned permalink at the basis its own bullet declares (ee9e5063, 45b52905, 7606fdb5, and 85530efe for the Step 5b entry at :1825). Every line number was verified to still resolve to the quoted text at that commit A line-pinned citation into either file, third repair: the retired text itself is what the reader needs

📊 The tally on closed-beta.md

Re-derived over the three index tables rather than decremented, at bb10ea3a (2026-09-23), the basis main's own W38 sync of this section uses. docs/specs/README.md is unchanged since c2bf2b29, so that commit reproduces the count.

  • Status table: Approved drops S04-C, 34 → 33; a new Retired | 1 | S04-C row records the retirement.
  • The service table stays at 41 rows (6 Implemented, 23 Approved, 1 In Progress, 1 Draft, 9 Planned, 1 Retired).
  • Approved or better 41 of 56 → 40 of 55, and Approved alone 34 of 56 → 33 of 55, with Retired counted on neither side per docs/specs/README.md. The 56th row is the retired one, so 55 are tracked scope. Excluding the descoped table, closed-beta scope is 40 of 50.
  • The basis line names bb10ea3a and the retirement this change records, and one paragraph says the drop is a retirement, not lost scope or a regression.

💭 Notes for review

  • Duo spec-status-changes will flag the Retired row for having no spec file to match its status. That finding is expected on every retirement, and docs/specs/README.md holds the answer in the paragraph directly under the status legend.
  • No pre-existing retired row names S04-C. Checked both indexes: this is the first retirement in the tree, so there is no earlier row to amend and no earlier MR to preserve in a marker.
  • The end-of-plan docs-author run is not wanted for this plan, and is therefore not landed ahead of the deletion. The plan wired the composition root and changed no customer-visible surface, so the run's outcome would be no docs needed, which leaves nothing in the tree for a later reader. Deleting the plan file ends the run's availability rather than postponing it, and that is the intended outcome here.
  • Nothing under docs/user/ is affected. A retirement changes no customer-visible surface: no endpoint, no GraphQL field, no auth flow, no client behavior, no role, setting or limit moves. The one user-facing page this change came near, configuration-reference.md, already carries the whole encryption block and needed no edit.
  • Commit prefix. docs(specs): rather than docs(plans):, although the MR touches both trees: the spec's completion is what authorizes the retirement and the plan goes with it. Worth a maintainer's view, since the convention assigns docs(plans): by directory and no rule covers an MR that retires both.
  • Size. 91 insertions, 563 deletions over 13 files. 514 of the deletions are the two retired files; the reviewable surface is the 20-line column-encryption.md addition, the 28-line closed-beta.md recomputation, and 92 lines of short repairs across 10 files.
  • E2e catalogs need no change: docs/testing/ references no S04-C scenario and this MR changes no behavior.
  • The retired-row templates are amended here. Both spelled the marker Retired in {retiring MR URL}, and a bare URL in a table cell is an MD034/no-bare-urls error under markdownlint-cli2; the fenced templates themselves are exempt, so nothing caught it until a real row was written. The three template lines in docs/specs/README.md and docs/plans/README.md now spell it Retired in [!{iid}]({retiring MR URL}), the form both rows here use and the plan Status table's MR column already prescribes.

Related to #1338

Edited by David Fernandez

Merge request reports

Loading
Loading