Files
alkblobs/docs/architecture/decisions/012-pre-decomposition-consistency-rulings.md
T
glm-5.3-flash 7b9d904b8a docs(architecture): ADR-012 — pre-decomposition consistency rulings; tree verified single-valued
Full-tree review before task decomposition found five composition
defects (mechanisms specced correctly in isolation, composition
unruled) and a set of caller-facing gaps. ADR-012 rules each:

- Sweeps are never errors: aborts are GcAbortCause report data in
  Ok(SweepReport) (ProtectFailed / SweeperLock / NoLivenessSources);
  GcAborted retired from the error enum; direct-delete refusal is
  the GcRefuse error covering the full protection set
- Pin token gains its wire shape: blobs/put response {token, digest};
  blobs/have's token-renewal form (one digest + token); token
  validity domain = the minting serving node, process-lifetime
  mapping
- Fleet mode is an explicit constructor declaration (fleet: true),
  never inferred from engine choice
- All fleet GC state hosts on the fleet's kv engine (postgres) — one
  arbitration domain; large=pg-lo fleet nodes required onto the same
  pg instance (composite predicate's new clause); large=local fleet
  puts pin-row-first, publish-second
- Engine-state seam reduced: sqlite pin/sweep-lock bodies dropped
  (dead machinery); non-SQL engines stage delete-window candidates
  in-process; one-window-host rule per instance
- Facade clarifications: fall-through for all key-addressed ops,
  kv-only put-time rejection, mem+local dual-tier valid, error-model
  member/return-shape ruling (trait/facade family split), has ->
  bool, PinState variants, fleet liveness-table registration form,
  window executor = the next sweep

Alignment edits across all specs and ADR-005/008/009/010/011
(bracketed corrections per the established pattern); OQ-11 (pg-only-kv
feature graph) added to the parked index for auditability.

Verification: two independent review passes; all findings resolved;
verdict READY for task decomposition.
2026-10-03 07:14:45 +00:00

352 lines
19 KiB
Markdown

# ADR-012: Pre-decomposition consistency rulings — sweep-abort shape, pin-token wire shape, fleet-mode activation, and the GC-state host
## Status
Accepted
## Context
The ADR-011 vocabulary round was followed by a full-tree consistency
review — the gate before task decomposition. The vocabulary itself
held everywhere; what the review surfaced is a set of **composition
defects**: mechanisms each specced correctly in isolation, whose
interaction was never ruled, plus two caller-facing shapes stated two
ways. All are pre-implementation (zero code exists), all deciding
facts are in hand, and none waits on a consumer:
1. **The sweep-abort return shape is stated two ways.**
store-api.md's §Lifecycle pins an aborted sweep as
`Ok(SweepReport { aborted: Some(GcAbortCause), .. })` ("the report
is data, not an error carrier"), while §Error model of the *same
file* defines `GcAborted` as the sweep-abort error carrying the
cause classes — and ADR-005, ADR-008, ADR-010, and
gc-and-namespaces.md all follow the error reading. An implementer
writing `sweep()` cannot pick a signature.
2. **The pin token has no wire shape.** The renewal contract (ADR-010
§4) has the putter renew by presenting the token "alongside the
digest" via `blobs/have` — but the specced `have` payload is
`{namespace, digests[]}` with no token field, `blobs/put`'s
token-carrying response is never specced, and an unknown-length
putter does not know its digest until the server computes it. The
token's *validity domain* is also unstated (renewal may arrive
after the minting handler's guard dropped, possibly to a different
fleet node).
3. **"Fleet state is active" has no activation predicate.** ADR-010
routes puts through `pin_commit` "wherever the engine is
seam-capable and fleet state is active", and ADR-008 keeps
single-node postures on the in-process protocol — but nothing
defines what activates fleet state. Under ADR-011's vocabulary
fleetness (two instances sharing one pool) is not observable by
any one constructor.
4. **The GC-state home under mixed fleet engines is unnamed.** In the
kv=postgres + large=local-on-shared-media fleet posture (valid per
the composite rule), pin-before-publish for a large entry is a file
rename, not an insert — the joint entry+pin tx is not achievable on
the large tier's medium — and nothing says which engine hosts
`gc_pins`/`gc_window`/the sweep lock, or whether two SQL engines
mean two pin tables and an arbitration union.
5. **The sqlite seam rows are self-contradictory.** ADR-010 §2 rules
sqlite's `ensure_gc_schema` creates the window table *only*, yet
assigns sqlite `pin_commit`/`pin_renew`/`pin_check` "bodies
exercised by unit tests only" — bodies that insert into a table
nothing creates — and gives sqlite a sweep-lock clause body whose
semantics are undefined (sqlite has no advisory locks) and which
ADR-008's "single-node postures unchanged" says never runs.
The review also surfaced smaller caller-facing gaps (fall-through for
non-get ops, the kv-only error point, a missing abort cause, the
fleet liveness-table registration's API home) that need a ruled home
before signatures are written; §6 records them.
## Decision
### 1. Sweeps are never errors — every abort is data in the report
`sweep()` returns `Result<SweepReport, Error>` where the error family
exists for *operational* failure (I/O, engine failure), never for a
deliberate no-op. All abort causes are report data:
- **`GcAborted` is retired from the error enum.** The only abort
vocabulary is `GcAbortCause`, a data enum carried in
`SweepReport.aborted: Option<GcAbortCause>`, with exactly three
variants: `ProtectFailed` (ADR-005's protection-source failure),
`SweeperLock` (ADR-008's fleet sweeper-lock contention),
`NoLivenessSources` (the safe default — a sweep invoked with zero
registered sources; previously this cause existed in prose,
store-api.md's sweep bullet, but had no variant to carry it).
- Direct `delete()` on a protected key keeps a typed refusal error,
named **`GcRefuse`** — a distinct name so no reader composes the
retired `GcAborted` semantics onto it. (This renames the refusal
path; nothing else about it changes — ADR-005's arbitration still
decides at delete time.)
- `GcAbortCause` is matched non-exhaustively by callers; new causes
are new ADRs.
Rationale: fleet lock contention is a *routine, expected* outcome of
scheduled sweeps — the embedder's next run takes the lock — and a
typed error would push the common case onto the exceptional path.
The report is the data embedders schedule on; "sweep counts are
exact" (the test gate) is asserted against report fields.
This ruling picks store-api.md §Lifecycle's shape over the error
shape used by ADR-005/008/010 and the gc spec; those documents'
wording is aligned to it (mechanical edits; no semantic change — all
four said "nothing deleted" identically, they only disagreed on the
carrier).
### 2. The pin token's wire shape and validity domain
**`blobs/put` response: `{token, digest}`.** Both fields always
present: `digest` is the stored entry's canonical digest (hex) — the
unknown-length putter cannot know it beforehand and needs it for
every later op; `token` is the opaque pin-token handle (ADR-010 §4's
minting unchanged). Known-length putters receive the same shape.
**`blobs/have` gains the token as the renewal form.** Payload
`{namespace, digests[], token?}`:
- without `token` — unchanged: pure existence probe over `digests[]`
(ADR-008's `has` discipline preserved; response `present[]`
corresponds to `digests[]` by index);
- with `token` — the **renewal probe**: exactly one digest in
`digests[]`, and the handler (a) verifies the token maps to a pin
row for that digest, (b) calls the crate-internal `pin_renew(key,
owner)` via the ops module's store-core access (ADR-010 §4's
mechanism, unchanged), (c) responds with `present[]` as usual — the
putter's registration-landed signal. One token binds one pin row,
so renewal is one digest per probe; a putter renewing several pins
issues one probe per token. If the row has expired (putter paused
past TTL), the handler finds no live row: `present: false` and no
renewal occurs — the putter's backstop is re-put (ADR-005's
delete-then-recover), making ADR-010 §4's expiry race end-to-end
explicit on the wire.
**Token validity domain: the minting node.** The token↔(key, owner)
mapping is ops-layer, in-memory state in the process that minted it
(process lifetime, not handler lifetime — the handler's guard drops
at response time per ADR-010 §4, but the mapping outlives the
handler); renewal probes must reach the minting serving node, and
fleet peer nodes cannot serve a token they did not mint (`present:
false`-equivalent refusal, not an error). A putter whose minting node
died before renewal is exactly ADR-005's in-flight-put loss case with
the same backstop: re-put. This is a documented duty of the
re-routing/replication embedder (route renewal to the serving node),
not new machinery.
**Non-fleet serving nodes (any engine):** the token is the ops
layer's in-memory handle to the in-process `Pin` guard — ADR-005
posture, nothing expires. This sentence generalizes ADR-010 §4's
`local`-only wording to every non-fleet mode (kv=sqlite, kv=postgres
single-instance, large=pg-lo non-fleet).
### 3. Fleet mode is a constructor declaration
**A store instance is constructed with fleet mode explicitly on or
off; it is never inferred.** Under `fleet: true`: pins are expiry
rows, puts route through `pin_commit`, sweep acquires the SQL sweep
lock, and the composite fleet-validity predicate (with its per-node
declarations — ADR-008 §3's media assertion, kv-only re-routing
declaration) is enforced at construction. Under `fleet: false`: the
ADR-005 in-process protocol verbatim, regardless of engine.
ADR-011's *deployment* vocabulary is unchanged (fleet = two or more
instances sharing one pool); the constructor flag is the per-instance
admission of that posture. Two `fleet: true` instances over one
postgres pool are a fleet; an undeclared instance sharing the pool is
**misconfigured** — the observable failure is ADR-008 §2's exact
data-loss scenario, and it is the same class of deployment-verified
invariant as the shared-media declaration: the constructor cannot
prove cross-node/cross-instance truth, the deployment asserts it, and
the detection symptom (an unrenewed, un-pinned entry swept while in
flight) is documented.
`fleet: true` is a constructor error for every non-fleet-valid
configuration: any kv engine other than postgres, `mem` anywhere,
`local` large tiers without the shared-media declaration, `pg-lo` on
a different pg instance than the kv engine (§4).
### 4. GC state has exactly one home: the fleet's kv engine
**All fleet GC state — `gc_pins`, `gc_window`, the sweep lock — lives
on the kv-tier engine.** Fleet mode requires kv=postgres, so
postgres hosts the tables and the advisory lock for the whole store
instance, large tier included. The per-engine alternative (each SQL
engine hosting pin rows for its own tier, arbitration unioning two
tables) is rejected: two pin tables mean two arbitration domains and
two locks — the partitioning failure ADR-008 §3 exists to prevent,
re-expressed inside one store instance. One host is the whole point
of the seam.
Consequences per fleet large-tier engine:
- **`large = pg-lo` (fleet): the same pg instance as the kv engine is
required** — this is ADR-009's consolidation posture ("pool content
lives in the same pg instance the kv tier rides") now made a
checkable clause of the composite fleet-validity predicate, because
it is what makes the joint entry+pin tx expressible: the store core
holds one transaction over the shared instance; the pg-lo engine
contributes LO writes + companion-row insert; the pin-row insert is
the store's (§2 of ADR-010, ownership unchanged). Cross-instance
kv=postgres + large=pg-lo is valid *only* with `fleet: false`
(in-process protocol).
- **`large = local` on declared shared media (fleet): pin-first,
publish-second.** The joint tx is not achievable across media, so
the ordering is pinned: the pin row commits on the kv engine
*before* the stage file's commit-rename publishes the entry.
- The invariant survives: an entry becomes visible only after its
pin row is durable — sweep arbitration (which queries pin rows)
can never observe a visible-unpinned entry.
- The crash window moves to the harmless side: crash between
pin-commit and rename leaves an orphan pin row, reaped by the
existing expiry machinery (ADR-008's TTL + sweeper maintenance
step). No new recovery mechanism.
- The reverse ordering (rename-then-pin) is rejected: a crash
there reopens ADR-005's original race (visible, unpinned,
sweepable).
**Non-fleet postures are unchanged and single-hosted:** the GC
arbitration domain is the store instance (ADR-005/011); pins and
liveness arbitration are in-process; the durable window table (§5)
may live on a SQL engine but carries no fleet semantics.
### 5. The engine-state seam's final shape (sqlite rows repaired)
Per-engine clause applicability — the correction of ADR-010 §2's
contradictory rows:
| Engine | `ensure_gc_schema` | Window clauses | Pin clauses | Sweep-lock clause |
|---|---|---|---|---|
| postgres | `gc_pins` + `gc_window` | yes (fleet-active when declared) | yes (fleet-active when declared) | yes (pg advisory lock; fleet-active when declared) |
| sqlite | `gc_window` only | yes (durable single-node staging) | **no** (bodies dropped) | **no** (body dropped — single-flighter is the in-process lock) |
| pg-lo | `gc_window` only | conditional — see the one-host rule below | **no** (fleet pins live on the kv host, §4) | no |
| `local`, `mem` | — excluded from the seam by construction (ADR-010 unchanged); their delete-window staging is **in-process** (ADR-005 protocol inline) | | | |
**The one-window-host rule (per store instance):** the durable staged
window lives on **one** SQL-backed engine — the kv engine when it is
SQL-backed (sqlite or postgres), otherwise the large engine (pg-lo,
e.g. the kv=mem + large=pg-lo shape). A dual-SQL instance (kv=sqlite +
large=pg-lo, non-fleet) stages on the kv engine only; pg-lo's
window clauses are dormant there. Never two window tables in one
instance — the candidate set is one set, and a split staging domain
would be a miniature of the two-arbitration-domain failure §4
rejects.
- The sqlite pin-clause bodies are **dropped**: they were dead
machinery inserting into a table nothing created. A sqlite fleet is
not on offer (ADR-010 §3); if one ever is, it is a new ADR that
re-adds the clauses with their schema.
- The sqlite sweep-lock body is **dropped**: sqlite has no advisory
lock, and ADR-008's "single-node postures keep the arbitration lock
in-process" is the actual mechanism. The seam's sweep-lock clause
is the pg advisory lock and applies to the fleet host only.
- pg-lo remains a seam implementer for the window family (it hosts
the durable window on non-fleet topologies where the kv engine is
not SQL-backed, e.g. kv=mem + large=pg-lo test shapes) and
contributes staged-put mechanics into the store-held tx in the
consolidation posture; it hosts no fleet state.
- The durable staged delete window stays a **SQL-backed-engine
mechanism** (sqlite included) per ADR-008 §2 — what this ruling
adds is the non-SQL half: "`local` and `mem` stage candidates
in-process and execute the ADR-005 window protocol inline within
the same sweep." "On every engine" (ADR-008 §2, ADR-010 §2) is
corrected to "on every SQL-backed engine".
### 6. Facade clarifications (ruled, not deferred)
1. **Fall-through applies to every key-addressed op, not just
get/read_range.** `stat`, `has`, and direct `delete` all consult
the kv tier first, then the large tier (a `size` probe for
`stat`/`has`; arbitration-then-delete where the entry lives for
`delete`). POC #1 finding 6's fall-through is the general rule.
2. **kv-only mode's over-threshold rejection is a put-time error.**
Known-length puts (`len: Some`) exceeding the threshold reject
immediately; unknown-length puts reject at threshold overflow
(mid-buffer). The prior "constructor-time error" wording was
impossible — put lengths are not known at construction.
3. **`dual-tier` with `kv = mem` + `large = local` is a valid
construction** (the dispatch-coverage test shape; `mem` is merely
the kv engine choice in dual-tier mode). `mem-only` remains the
no-large-tier ephemeral mode. Both are non-fleet by definition.
4. **Direct `delete()` refuses any protected key** — pinned,
observed-live via a registered source, or named by the protect
callback — not just pinned keys. This is the ADR-005 invariant
("a visible pool entry is never deleted while liveness protects
it") applied to the direct path; ADR-005's "refuses pinned keys"
wording is aligned upward. An embedder's correction flow
deregisters/re-registers around the delete.
5. **Error-model member vs return shape:** `get`/`stat` return
`Option` (a miss is a value, not an error — `Missing` is not
theirs); `read_range` and `delete` return `Err(Missing)` on absent
keys (informative for the embedder flows that use them).
`Io(String)` covers backend media failure including cursor and
staged-put I/O errors; `Verification` covers hash-check failure on
put and get paths alike; `KeyInvalid` malformed keys. `Backend`
trait methods return this error family (crate-internal variants,
thiserror).
6. **Fleet liveness-table registration has an API home:** the fleet
form of `register_liveness_source` registers a *named table
reference* in the shared engine (the embedder's table per ADR-008
§2; the sweep's SQL arbitration consults it). The facade method is
the same verb with a fleet-shaped argument; the store core reads
the table and never owns it (ADR-008's schema-ownership rule).
7. **The staged window's executor is the next sweep** (ADR-008 §2's
"the sweeper, or the next sweep's owner"): a sweep takes overdue
`gc_window` rows (past `visible_after`), re-arbitrates each, and
commits the deletes before/alongside its own marking; non-SQL
engines execute inline in the same sweep (§5). Exact-count sweep
invariants are asserted relative to this schedule.
## Consequences
**Positive**
- Every signature the implementation needs is now single-valued:
`sweep()`'s return shape, put/have payload shapes, the fleet
activation predicate, the GC-state host, and the per-engine seam
table. Task decomposition has no contradictory text to resolve.
- The fleet mechanism gained the missing pieces (activation flag,
state host, large=local ordering) without new machinery — every
piece reuses an existing mechanism (constructor declarations,
expiry reaper, the store-held tx).
- The sqlite seam shrank (dead bodies dropped); the per-engine
applicability table is the contract-test matrix.
**Negative**
- `GcAborted`'s retirement is a renaming cost across four documents
(mechanical; no semantic change — and no code exists yet to break).
- The pin token gains a documented duty (route renewal to the minting
node) and the have/put payload shapes change from the earlier prose
(a token field and a digest return field) — wire-shape decisions
locked before any consumer, per the ADR-002 door discipline.
- Fleet mode is one more constructor parameter with per-node
declaration duties; the deployment-verified invariant class grows
by one row (undeclared-instance sharing).
**Neutral**
- No tier, engine, feature flag, or constructor *mode* changes; the
composite fleet-validity predicate gains one clause (pg-lo
same-instance) it had already assumed in prose (ADR-009).
- SweepReport, GcAbortCause, GcRefuse, and the fall-through/put-time
rules are the only *new* facade vocabulary; everything else in
store-api.md stands.
## References
- ADR-005 (the GC invariant and single-node protocol this ADR extends
and aligns), ADR-008 (fleet GC mechanism — §2's window wording and
§3's declarations feed §4/§5 here), ADR-009 (pg-lo admission —
§4's same-instance clause formalizes its consolidation posture),
ADR-010 (the I/O seams and GC-state home — §1/§2/§4 here repair its
sqlite rows and rule its activation predicate; §2 here completes
its §4 token contract with a wire shape), ADR-011 (the vocabulary
this ADR's constructor flag and host rules compose with)
- [store-api.md](../store-api.md) (sweep shape, error model,
invariants); [ops-surface.md](../ops-surface.md) (the op payloads
§2 pins); [backends-and-dispatch.md](../backends-and-dispatch.md)
(the composite fleet-validity rule §4 amends; constructor modes);
[gc-and-namespaces.md](../gc-and-namespaces.md) (the delete-window
execution model §6.7 names)
- `docs/research/poc-trait-dispatch-findings.md` finding 6
(fall-through, §6.1's basis); `poc-pglo-findings.md` (the
consolidation posture §4 formalizes)