Code-side record corrections, last of the ADR-020 implementation
round's tasks: no current-surface document advertises a deleted shape
or the superseded wire posture.
- README.md: the opening hash description restated around put_as and
family-explicit Key::from_bytes construction (the wave-13 gate's
put_git_object / from_git_oid_hex / documented-scoping phrasing
dropped); the body's Store::put reads and the quick-start fence moved
to the one seam (matching tests/readme_examples.rs, already on the
seam); the docs bullet's ADR range 001-020.
- fuzz/README.md: the key_encoding target's surface shrunk to the
landed cluster (from_bytes/as_bytes, the Algorithm registry mapping,
the explicit Algorithm::derive) — the from_git_oid_hex / Key::derive
/ Digest::derive entries and the length-selects-family hex-posture
clause dropped; the invariant list aligned with fuzz/shared's own
doc header.
- tests/conformance/README.md: INV-1's pinning text names put_as.
- ops-surface.md / store-api.md / hashing-and-keys.md: already
described the landed shapes exactly (their WHAT text rode the
implementation commits); the "Reviewed pending the implementation
round's verification" banners flipped to landed (verified by the
wave-14 pinning test, the conformance family legs, and the fuzz
key_encoding invariants).
- CHANGELOG.md: the [0.2.0] entry — the wire break (tag-prefixed key
hex, bare hex dead, mandatory put claims, the {token, key} response)
and the facade break (put, put_git_object, from_git_oid_hex,
Digest::derive, Key::derive removed; put_as added), the ADR-020 ref,
and the refreshed Verified counts.
- Cargo.toml: version = "0.2.0" (no shim, no dual-form period);
fuzz/Cargo.lock follows the bump.
Verification: cargo test (default) 219 green (unit 156, conformance
54, git-interop 7, README examples 2) and cargo test --all-features
against the live pg harness 476 green, both clippy variants
(-D warnings), cargo fmt --check, cargo doc --no-deps,
cargo publish --dry-run --allow-dirty (0.2.0 packages), and the fuzz
corpora replay gate 7/7 — all green.
Task: tasks/record-corrections-adr-020.md (status: completed).
23 KiB
status, last_updated, notes
| status | last_updated | notes |
|---|---|---|
| reviewed | 2026-10-18 (ADR-020 — the wire speaks keys: digest fields are tag-prefixed key hex (42/66), bare hex dead, the op set closed; the put offer's claim key + len mandatory; the put response echoes the key. Review-003's F-1 coherence finding — the blob-family-scoping posture is superseded. Verified landed — wave 14, 2026-10-19 (the pinning test: a facade-published family entry is fetchable, stat-able, and have-servable over the ops surface; a wire-offered family put lands under its own oid)) | ADR-017 — the authority model named (CAS global pool; capability-to-ask; separation = separate stores), renewal namespace parity with the mint record, and the remote-putter read+write role requirement. ADR-014 — put's data plane (zero call-plane bytes), one-response synthesis (ack-in-result), the alkcall amendment-4 gate. ADR-020 — the digest wire form (below): the wire speaks keys |
Ops surface (alkcall-backed network operations)
What this is
The network-facing operation family over the store: have/need
announcements, verified fetch, and streaming put — riding the alkcall
substrate behind a feature-gated ops module in this crate
(feature ops, default-off; ADR-001). The base crate stays lean and
alkcall-free; enabling ops adds the alkcall dependency and registers
the op family.
Why alkcall (ADR-001, the short form)
The op family is exactly alkcall's mixed-shape sweet spot (validated machinery, not parallel invention):
- JSON control plane — have/need announcements,
stat-shaped probes, offer/carry lengths: alkcallOperationSpec+ JSON Schema validation (alkcall ADR-016) + typed error schemas +from_calldiscovery + External/Internal visibility. - Binary data plane — bulk bytes one way (fetch:
Sub, alkcall ADR-021) or the other (put:Pub/Sink, alkcall ADR-046), allocated via thechannel_openmarker (alkcall ADR-047); established pump patterns (pump_bidi, alkcall ADR-050). - ACL is solved there —
AccessControl+ ownership checks (alkcall ADR-011) under the alkcall ADR-017 privilege model map onto namespace gating directly; an in-band invented scheme would be a second, unreviewed authorization story in the family (AGENTS convention 6).
Producer/consumer vocabulary throughout; no transport enters either
layer — the caller (an embedder, alkgit's replicator, a future alkfs
sync) dials and hands an established Connection in (alkcall is a
pure protocol crate; ADR-012 there).
Op family (WHAT is exposed)
Namespaces on the wire. Every ops payload carries an explicit
namespace field — the consumer-scoped identifier the embedder's
registry recognizes (syntax: non-empty UTF-8 string, embedder-validated;
the minting of namespaces is the embedder's act — e.g. alkgit's
registry maps repo ids to namespaces, alkfs maps workspace roots). The
store core never sees this string (backends are namespace-blind,
ADR-005); it selects the liveness source and the ACL resource. The ops
module's job is to bind namespace → the embedder-registered liveness source for pin conversion, and to present the namespace as the
alkcall ACL resource.
JSON control ops (Visibility per ADR-001 §Decision):
blobs/stat{namespace, digest}→{len}— probe before offering; ACL-gated like fetch (read action on the namespace)blobs/have{namespace, digests[], token?}→{present[]}— the have half of have/need set diffing (hashes only — content never traverses this op); read-gated: an existence probe over arbitrary digests is a discovery surface, so it is gated exactly like fetch, not public.present[]corresponds todigests[]by index. The optionaltokenis the renewal form (ADR-012 §2): when present, exactly one digest accompanies it — the handler verifies the token maps to that digest's pin row and that the presented namespace matches the mint record (renewal namespace parity, ADR-017), renews it (crate-internalpin_renewvia the ops module's store-core access, ADR-010 §4), and answers withpresent[]as usual; an expired/foreign row answerspresent: false(the putter's backstop is re-put). Withouttoken,haveremains a pure existence probe (ADR-008's discipline).blobs/delete{namespace, digests[]}— operator machinery;Visibility::Internal, evaluated under the internal authority context per alkcall ADR-017 (internal calls switch authority context, never skip ACL): requires the embedder's operator/admin authority context, not a namespace grant. Thenamespacefield exists for audit/triage scoping (which slice of the pool the deletion targets), not for gating.
Binary channel ops (registered via the channel_open marker):
The digest wire form (ADR-020,
2026-10-18). Every digest-bearing field on this surface — fetch,
stat, have, delete, and put offers — carries the physical key encoding
verbatim: [registry byte] + digest bytes, hex-encoded (42 hex for the
21-byte keys 0x02/0x03, 66 hex for the 33-byte keys 0x01/0x04),
parsed by the same rejects-never-guesses decoder the persistence
boundary already trusts (Key::from_bytes-shaped: tag validated,
digest length checked per tag, never inferred). The schema gate is
^[0-9a-f]{42}$|^[0-9a-f]{66}$; the gate and the ingress change
together (the S-5 discipline). With the tag in the payload, length is a
check and never a guess; bare digest hex is not a wire form (no
implicit default-family reading — one rule, no special cases). The
form names the algorithm (the registry's identity), never a consumer's
vocabulary: the wire speaks keys, not git. The op set is closed —
blobs/fetch, blobs/stat, blobs/have, blobs/delete, blobs/put
and nothing else; family support is payload parameterization under
unchanged op names (AGENTS convention 12), and a future registry
member's entire ask of this surface is admission under its own ADR
(ADR-020's litmus: no wire changes, no facade changes, no new methods).
The delivery-form asymmetry (ADR-020 §Decision 3): the wire never
discovers addresses — the facade can; wire offers carry full claims.
The ops ingress error texts describe the family-explicit contract
(tag-and-length), not any family's bare hex lengths.
blobs/fetch(Sub— the server→client streaming op shape; open-op idchannels/blobs/sub, thealk/blobsdata-plane ALPN) —{namespace, digest, ranges?}in; verified bytes out: the consumer hash-checks the received whole against the carried digest (ADR-006's whole-blob verification — there are no per-chunk or per-range checks derivable under the canonical digest; ranged fetch responses carry the same ADR-006 slice-digest conventionread_rangereturns). The establishment phase (ADR-049) carries the ack in the open reply's fields:{source, len?, slice_digests?}—sourcenames the serving path (store/fanout); the whole form ackslenfrom the store path (the hash-check's expected whole) and nothing from a fanout tap (a live put's tail; the hash-check runs at stream end); the ranged form acks one slice digest per range, in request order, over exactly the served bytes (over-reads clamp; the digest covers the clamp). The fanout consult (POC #3 finding A3's consumer side): a live broadcast → a subscriber arm taps it; no broadcast, or one that ended (a late joiner) → the normal post-commitget— identical bytes under CAS; a mid-put tap has no prefix, so the consumer's hash-check on a partial feed fails and the re-fetch is the degrade. Fetches never produce read-side broadcasts (recorded against the A3 text — the put handler's stream reader is the canonical producer; concurrent fetchers each ride their own facade read). The miss (absent digest, no broadcast) is the typed missing-error schema: the declaredBLOB_MISSINGcontract, answered on the wire as the establishment refusal (channel:open_failed, reasonunknown_resource, ADR-049's "the requested resource does not exist"), the digest in the refusal's message. The need announcement is the fetch miss — the have/need diff is caller-side; no separate need op exists (the wire carries only concrete fetch requests). Slow-subscriber policy (drop-and-late-join) is ops-layer policy, not a store concern.blobs/put(Sink— the client→server streaming op shape) —{namespace, key, len}offer + byte stream in; both claim fields are mandatory (ADR-020 §Decision 3 — the wire never discovers addresses):keyis the tag-prefixed key hex naming the family and the address,lenthe claimed length; the server verifies the stream against the key's own derivation under its algorithm before commit. Data plane (ADR-014 §1): bulk bytes ride the adopted channel'sBiStreamexclusively under ADR-040's bounded-buffer backpressure — the call plane carries the offer and the response, and nothing else; nonemptycall.publishedon the marked op is rejected loudly, never consumed (no competing data path). The claim check has one moment — pre-EOF — for every family (ADR-020 §Decision 4): the tee hasher opens at stream start and parameterizes perAlgorithm(identity-family hashing never waits onlen; the check timing is a question theAlgorithmanswers, not the offer), and the store arm routes the facade's one derivation-explicit put seam (ADR-020 §Decision 5) — there is no deferred post-check arm and no unknown-length wire put (an unmeasured remote stream buffers at its own layer before offering). Response:{token, key}(ADR-020 §Decision 4, amending ADR-012 §2's field) — the pin token (§Pin hand-over below) and the echoed key (key hex) — the caller addresses what landed in the vocabulary it offered. Refusal families (the op's declared error disclosure):VERIFICATION_REFUSED(the stream failed the offered key's own derivation — nothing committed, zero residue),PUT_REFUSED(a put-time store refusal — nothing committed),DIGEST_MISMATCH(the offered claim contradicted by the derivation under the offered key's algorithm — nothing handed over), andMINT_REFUSED(ADR-019 — the one refusal over a committed put: the stream committed and verified, but its hand-over's mint was refused at the token registry's admission cap; no token was issued and no pin custody was taken, the committed digest rides the refusal's details, and the CAS-idempotent re-put is the recovery). The establishment ack and the handler's result merge into the op's single response, emitted after the put handler completes — the put's ack is informative-in-result, not a gating ack (a putter cannot wait for the ack before streaming: sink ops respond once, at stream end; ADR-014 §2 pins the timing and the fetch/put asymmetry). Need half of have/need: a fetch miss is the need announcement — the consumer computes its need set by diffingblobs/haveresults and fetches the absent digests; no separate need op exists (the diff is caller-side; the wire carries only concrete fetch requests). Dependency gate (ADR-014 §3): put was not registered until alkcall's amendment-4 surface landed (Pub-typed marked opens, wrapper-awaits-handler, publish-chunk rejection, relay lifting, and the putter-facing stream-then-open client capability) — satisfied by alkcall 0.9.0 (2026-10-14); put unblocks for the put task, dependency floor at 0.9.
Pin hand-over for remote puts (the cross-wire contract). The
server-side put handler owns the Pin guard; the offering side's
reference registration happens in the remote process, invisible to
the server. The hand-over contract:
- the put lands pinned (ADR-005) — the entry cannot be swept while the handler holds the pin;
- the
blobs/putresponse returns a pin token in the specced response shape{token, key}(ADR-020 §Decision 4, amending ADR-012 §2's field — opaque handle minted by the ops layer over the pin row the put committed; the token↔(key, owner) mapping is ops-layer state, not durable state — the durable state is the pin row itself, ADR-010 §4); the putter can later confirm its registration landed by observing the digest present viablobs/have; - the embedder's registered liveness source is the actual root of
record: the contract on the embedder (documented on the ops module)
is that it registers the namespace's roots such that a digest
referenced by a namespace's manifest is either (a) already
registered before the caller's next sweep could run on the serving
node, or (b) protected by holding the pin token until its
registration is confirmed (
blobs/havereturns present after registration). In short: the remote putter may not rely on an unconfirmed put surviving the next sweep — it holds its pin token until its registration shows up inblobs/have. APinconversion on the server side (token → registered liveness source) is the mechanism the embedder plugs its registry into.
Token renewal (ADR-010 §4, wired per ADR-012 §2 — the fleet-TTL
race, resolved): the token is the pin — there is no second pin
state. The server-side handler's in-process Pin guard drops when
the blobs/put response completes (if it never dropped, the TTL
machinery would be dead on the ops path — the pin row's expiry, not
the guard's presence, is the fleet liveness signal). Under fleet
engines the put's pin is an expiry-carrying row (ADR-008: TTL 60 s
default, renewed every TTL/3). The remote putter holding a token is
the row's owner and renews via the renewal form of blobs/have
(ADR-012 §2: one digest, token alongside — the handler verifies the
token maps to the digest's pin row and that the presented namespace
matches the mint record — the mint records the namespace from the
put offer, the same discipline confirm uses; ADR-017's parity
ruling — and calls the crate-internal
pin_renew via the ops module's store-core access — the ops module
is in-crate and crosses the seam by construction, the same way the
put handler holds the Pin guard; has itself remains
a pure existence probe per ADR-008 — renewal is a token-authenticated
act on the pin row, not a has semantic), or a re-put of the same
digest (CAS dedup renews the row; the re-put carries the namespace
itself, so parity holds there trivially). A putter may pause past the
TTL/3 mark (20 s at the default TTL) only if it renews before expiry;
a putter pausing past TTL lets the row expire — the renewal probe
answers present: false, the sweeper reaps the row (ADR-008), and
delete-then-recover (ADR-005) is the backstop: the
putter re-offers the digest; CAS makes the re-put idempotent. The
race resolves to a re-put, never to silent loss. Token validity
domain: the minting serving node (ADR-012 §2) — the token↔(key,
owner) mapping is ops-layer in-memory state in the node that minted
it (process-lifetime, outliving the handler); renewal probes must
reach that node, fleet peers refuse tokens they did not mint (present: false, not an error), and a minting node's death before renewal is
ADR-005's in-flight-put loss case with the same re-put backstop — a
documented embedder duty (route renewals to the serving node).
For non-fleet serving nodes (any engine — sqlite, single-instance
postgres, local, pg-lo; ADR-012 §2) the token is the in-process
guard's handle — nothing expires; the process-crash case is ADR-005's
in-flight-put loss with the same backstop.
The remote-putter role is read+write (ADR-017). The hand-over
contract below already requires the putter to probe blobs/have to
confirm its registration landed, and the renewal form rides
blobs/have — both fleet pin duties ride read on the putter's own
namespace. This pairing is a role-level duty, not a grant
implication: the ACL matrix's write ⊄ read capability algebra is
untouched, and an embedder granting put-without-read knowingly
configures a principal that cannot complete fleet pin duties
(minted pins expire through the TTL loop — the re-put backstop
still prevents loss, but the pin never holds past TTL).
The admission posture (ADR-019 — the mint contract's typed
answer to the unconfirming loop). The attack shape, on record
(POC-L, deliberately unpatched in the consumer POC): a hostile
client streaming conforming content and abandoning the confirm
step each iteration — each minted token a held pin plus a registry
entry, so on a non-fleet store the loop is unbounded registry
growth plus custody GC can never reap (the content-mismatch variant
rejects the put with no mint). The store's answer: the token
registry admits up to its cap
(OpsConfig::token_registry_max_entries, default 65_536;
usize::MAX uncapped) and refuses mints beyond it — never
evicting — with the put handler answering the already-committed put
through the declared MINT_REFUSED error (above). The registry's
cap bounds the registry's own resources (process-lifetime memory,
never-expiring custody), not the loop's reach: an authenticated
writer can still fill the pool with committed bytes between sweeps —
that resource is the ACL write grant's story plus the GC cycle, not
the registry's. The mint policy at the door is the embedder's
responsibility (which identities mint; how many unconfirmed puts one
principal may hold; whether a session confirms before its next
mint); for a deployment whose door cannot prove confirm — an
untrusted multi-tenant upload surface, the LFS shape — the fleet
form is the safe posture (the pin row's TTL bounds every held pin
with or without a confirm). Making the fleet TTL posture available
non-fleet was rejected (ADR-019: the TTL is the fleet core's pin-row
machinery, an expiry would break the confirm verb's contract, and
the honest alternatives already exist); re-opening it is a new ADR.
Placement of the op registration (which embedders wire where they
want them exposed) matches the alkgit ops pattern (its
git/repo/* call ops): the module exports spec/handler pairs; the
embedder assembles.
ACL mapping
Resources and actions, evaluated by alkcall's AccessControl machinery
at op entry — this module never re-implements an authorization check
(convention 6; ADR-001):
The authority model, named (ADR-017): the pool is a CAS global pool, and a namespace grant is capability-to-ask, not data partitioning. A read/put grant on a namespace confers the right to ask the store's ops on that namespace's behalf — it does not partition content. Content is addressed by digest; digest knowledge plus any namespace's read capability reaches the content, regardless of which namespace it was put under. A deployment whose threat model requires data isolation between namespaces runs separate store instances (separate pools — the ADR-003 topology ruling, named on this surface). This statement is the S-1/S-3 resolution's landed form; the model cannot be silent.
Resources and actions:
- Namespace = resource.
resource_type: "blob-namespace",resource_id_pathselecting the payload'snamespacefield; actions map onto read/put/manage:blobs/fetch,blobs/stat,blobs/have— the read action; unlisted namespaces (no ACL entries) deny by default (OQ-09's resolution; a grant per namespace is the deferred cost, deliberately paid over accidental exposure)blobs/put— the write actionblobs/delete— not namespace-gated at all; internal authority context only (alkcall ADR-017: internal switches context, never skips ACL)
Store-facing relationship
The ops module is a consumer of the store core (store-api.md), not a
side channel: fetch handlers call get/read_range/stat; put
handlers route the facade's one derivation-explicit put seam
(ADR-020 §Decision 5); both honor pins and delete windows. There is no
ops-internal path around the store facade (ADR-001 §Consequences —
this is what makes "re-homing the ops surface later loses nothing"
true). One fleet topology additionally rides this module: re-routing
(ADR-008) — a fleet whose nodes do not share large-tier media routes
large-blob access through a single storage node via these ops, with
client nodes' constructors in kv-only mode (ADR-011 §4) for pool
content. The module supports this shape without change; no
fleet-specific op exists.
Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| 001 | Substrate posture & placement | alkcall-backed, feature-gated ops module; conformance-not-deviation rationale |
| 006 | Verification | verified-fetch via carried digests; no chunk trees |
| 008 | Fleet re-routing topology | non-shared-media fleets route large-blob access through a storage node via these ops; no new ops |
| 010 | Pin-token renewal | the token is the pin; renewal rides have/re-put; TTL expiry falls to delete-then-recover |
| 012 | Wire-shape rulings | put response shape (the field now the echoed key per ADR-020 §Decision 4); have's token-renewal form; token validity domain = minting node; non-fleet token scope generalized |
| 014 | Marked-pub put contract | put's data plane = the adopted channel (zero call.published); one-response synthesis (ack-in-result, not ack-before-stream); the alkcall amendment-4 gate; no-stub posture |
| 017 | Authority model | CAS global pool — namespace grant is capability-to-ask, not data partitioning; renewal namespace parity; the remote-putter role is read+write |
| 020 | The wire speaks keys | digest fields are tag-prefixed key hex (schema 42/66; bare hex dead); the op set closed — payload parameterization under unchanged names; put offer key + len both mandatory; put response echoes the key; the facade folds to one derivation-explicit put seam |
| 018 | The derivation family | registry 0x03/0x04, preimage-identity hashing over the loose form (the ADR-002 annotated exception); the per-family stat semantic; the former blob-family wire scoping superseded by ADR-020 |
| 011 | Vocabulary + modes | instance/node/fleet split; kv-only mode named (the re-routing posture's client shape) |
Open Questions
None owned by this document. OQ-09 (default namespace visibility) resolved closed-by-default — recorded in open-questions.md with its reopen condition (a named requirement for open-read-by-default, never mere existence of a deployment).
References
docs/research/phase-0.mdOQ-BL-01 (the substrate settlement + its justification, promoted verbatim into ADR-001 §Context)- alkcall docs/architecture — ADR-011/012/016/017/021/046/047/050 (the machinery this module rides)
docs/research/poc-largeblob-findings.mdfinding A3 (fanout seam)- ADR-001; store-api.md; gc-and-namespaces.md (namespace-as-resource); ADR-012 §2 (the wire shapes this doc's op payloads carry); ADR-014 (the put's data plane, response synthesis, and the amendment-4 gate)