- the put offer requires key hex (tag-prefixed, ingress via
digest_ingress) and len — the wire's unknown-length put and the
deferred post-EOF cross-check arm are dead; one claim-check moment,
pre-EOF, for every family
- the tee hasher opens unconditionally at stream start under the
offered key's algorithm; the store arm routes the facade's one
put_as seam (offered_key.algorithm(), Some(offered_len))
- the response echoes the key: {token, key} over the wire key hex
(ADR-012 §2's {token, digest} superseded); DIGEST_MISMATCH details
{key, derived}, MINT_REFUSED details {key} over the committed key
hex, VERIFICATION_REFUSED texts name the offered key's own
derivation
- tests: the unknown-length wire-form tests and response_hex_of die;
the kv-only threshold test collapses to the known-length form; the
mismatch test pins the always-running pre-EOF check; the family
pinning test's write side lands (a wire-offered git-object put
commits under its own oid)
- module docs aligned to the landed put contract (no contradiction
left in mod.rs/fetch.rs/tokens.rs)
Verification: cargo test (default 156/54/7/2), cargo test
--all-features server-less (341/125/7/3 skips) and against the live
pglo-poc harness (341/125/7/3), both clippy variants -D warnings,
cargo fmt --check, cargo doc --no-deps, the fuzz corpus-replay gate.
One loaded-parallelism wall-clock cadence flake passed on the full
re-run and in isolation (the recorded run-discipline posture).
12 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ops-put-handler-key-claims | The put handler under mandatory claims — key + len mandatory, the tee hasher parameterized, the response echoes the key | completed |
|
moderate | medium | component | implementation |
|
Description
ADR-020 §Decision 4's put handler (the third seam of §Decision 9's
order; depends on both preceding tasks — the offer/response shapes
ride the wire form, the store arm routes the facade's one seam). All
changes in src/ops/put.rs plus its tests:
- The offer's claim is mandatory: the put offer carries key hex
and
len, both required — no wire peer is structurally unable to know (a sync offerer holds the entry it offers; a relay's upstream identified it). The spec's input schema (src/ops/put.rs:74-83) gains the required fields; the digest field's identity follows the ADR's vocabulary — the offer speaks keys (field namingdigest→keyis the default reading; record the choice). The wire's unknown-length put dies with this:offered_lenis alwaysSome, and the recorded consequence stands (an unmeasured remote stream buffers at its own layer before offering, forever — §Decision 3's sentence, carried explicitly in the spec text by record-corrections). put_establish(src/ops/put.rs:149): the mandatory key's ingress (the wire task'sdigest_ingress— tag-prefixed key hex); the offer ack's reply field unchanged.- The tee hasher parameterizes and always opens at stream start
(src/ops/put.rs:332-338): the
BlobHasher::newcall takes the offered key's algorithm (Key::algorithm— the hasher already carries all four members) and the mandatorylen; the known-length-only gating (offered_len.is_some()) goes. The tee's one-read discipline is unchanged. - The store arm routes the one seam (src/ops/put.rs:320):
store.put_as(offered_key.algorithm(), Some(offered_len), reader)replacesstore.put(offered_len, reader). - The deferred post-check arm is deleted (finish_put's
offered-digest cross-check, src/ops/put.rs:481-499): claim checking
has exactly one moment — pre-EOF — for every family (identity-family
hashing never waits on
len; the check timing is a question theAlgorithmanswers, not the offer). The pre-EOF cross-check (src/ops/put.rs:377-389) stays and now always runs: the tee's derived key must equal the offered key, else the kill-before-commitDIGEST_MISMATCHsettle. The facade's own verification remains the backstop behind it.
- The response echoes the key:
{token, key}(the tag-prefixed key hex, 42/66 — the vocabulary the caller offered in) replaces the{token, digest}shape ADR-012 §2 pinned (superseded by ADR-020's banner). The response schema (src/ops/put.rs:84-94) and thefinish_putresult update; the key pattern is the wire form's. Renewal, confirm, andhavesemantics are unchanged under the field's identity change — the token maps to the entry as before (ADR-017's namespace parity untouched). - The error texts speak the new vocabulary:
DIGEST_MISMATCH's message ("the canonical derivation" → the offered key's own derivation),VERIFICATION_REFUSED's declared description andput_refused_error's text (the same shift),MINT_REFUSED's committed-anchor details (the committed key hex; the details field naming follows the offer's — record the choice). The spec'swith_descriptionand thePUT_ACK_FIELDdoc's{token, digest}mentions update. - The fanout seam's producer side is unchanged mechanically: the broadcast begins under the offered key (now always present) before the first wire read.
- The tests (src/ops/tests.rs): the put tests' offers carry the
mandatory key + len in the tag-prefixed form; the unknown-length
wire-form tests die (
put_unknown_length_round_trips_the_buffering_path:746,put_unknown_length_offer_digest_mismatch_rejects_at_completion:908 — the form they exercise no longer exists; the buffering-path coverage lives on at the facade,put_as(algorithm, None, ...));put_kv_only_threshold_rejects_both_offer_forms(:1351) collapses to the known-length form;the_put_handler_ingress_guards_and_the_torn_stream_settle(:4224) updates to the mandatory-claim ingress; the mismatch test (:855) becomes the always-running pre-EOF check's pin; the response-echo assertions check{token, key}.
Acceptance Criteria
- The put offer's schema requires key hex and
len(an offer missing either is the schema gate'sINVALID_INPUT); the key field gates on the tag-prefixed pattern and ingresses through the physical-key decoder - The tee hasher opens at stream start under the offered key's algorithm, for every registered algorithm — a wire-offered family put cross-checks under the family's own derivation
- The store arm routes
put_aswith the offered key's algorithm and the mandatory len;store.puthas zero callers in src/ops/ - The post-EOF cross-check arm is deleted; the pre-EOF check is the only claim-check moment (pinned by the mismatch test — a contradicting claim rejects before commit, zero residue)
- The response is
{token, key}with the tag-prefixed key hex; the renewal/confirm/have semantics test-pinned unchanged - The error texts speak the per-key derivation vocabulary; no wire text says "canonical derivation" as a family-specific claim
- Gates green:
cargo test(default),cargo test --all-featuresserver-less and against the live pg harness (the fleet put round-trip tests ride the new offer form —ALKBLOBS_PG_HOST=localhost,ALKBLOBS_PG_PORT=15432,ALKBLOBS_PG_USER=postgres,ALKBLOBS_PG_PASSWORD=poc,ALKBLOBS_PG_DB=blobs; server-less runs skip cleanly), both clippy variants,cargo fmt --check,cargo doc --no-deps
References
- docs/architecture/decisions/020-family-explicit-wire-payloads.md (§Decision 4 — this task's contract; §Decision 2/3 — the wire form and the mandatory-claim principle; §Decision 9 — the seam order)
- docs/reviews/003-family-wire-gap-review.md (F-1's put arm; D-4)
- docs/architecture/ops-surface.md (the put op's WHAT — record corrections restates it)
- src/ops/put.rs (the spec :74-136, put_establish :149, the store arm :320, the tee hasher :332, the pre-EOF check :377, finish_put :449, the post-check :481); src/ops/tests.rs (the put test tree); tasks/wire-key-hex-ingress.md; tasks/facade-put-as-seam.md
Notes
Field-naming choices (recorded, per the task's instructions): the
offer's claim field is key (the ADR's vocabulary — the wire speaks
keys; the offer speaks keys), the response's echo field is key in
the same tag-prefixed form, and the error-details field naming follows
the offer's: DIGEST_MISMATCH's details became {key, derived} (the
offered claim + the stream's derived key hex — derived keeps its
identity as the derivation's answer, not a claim) and
MINT_REFUSED's details became {key} over the committed key hex
(the tag-prefixed form — the putter's CAS re-put anchor). Both detail
halves ride the wire key hex form; put_message_details_schema
properties renamed to match.
The put_establish/put_handle ingress shape: the mandatory key's
ingress is digest_ingress (the wire task's tag-prefixed decoder).
Missing-claim post-schema → the unreachable-post-validation
INVALID_INPUT guard (the key's and the len's, each named); present-
but-undecodable → the ingress's INVALID_DIGEST passed through
verbatim (message "the digest is not tag-prefixed key hex…") —
coherent with the read ops' handler-direct posture (the schema gate
rejects non-hex on the wire; the ingress below it rejects-never-guesses
for handler-direct callers). The establisher refuses on the missing
key too (previously it tolerated if let Some).
The tee hasher and the cross-check: BlobHasher::new(offered_key. algorithm(), offered_len) unconditionally at stream start; the
known-length gating (offered_len.is_some()) and the offered_hex/
offered_digest options are gone — the pre-EOF cross-check runs
unconditionally against the mandatory claim, and finish_put lost its
post-check arm (and two parameters). The Algorithm import left
put.rs with the hardcode; the store arm routes
put_as(offered_key.algorithm(), Some(offered_len), reader) via a
cloned key into the spawned task (the key lives on for the
cross-check/response).
The verification-failure test's offer arithmetic (a subtlety worth
recording): with the cross-check always running, a claimed key now
must be the claimed-length derivation of the short content for the
facade's VERIFICATION_REFUSED (length verdict) to be the rejection —
a claim the tee's derivation contradicts would reject earlier as
DIGEST_MISMATCH. The test claims derives(short_content, 1024) and
the typed-rejection surface is unchanged.
The family pinning test transformed: a_facade_published_family _entry_is_wire_readable_end_to_end's rejecting-put arm became the
write-side pinning test — a wire-offered GitObjectSha256 put lands
under its own oid (the tee's identity-family cross-check against the
claimed key, put_as routing, the hand-over's custody, the echoed
key). POC-X's reproduction is this repo's own test now; the honest
DIGEST_MISMATCH intermediate state is gone.
Deleted/restructured tests: put_unknown_length_round_trips_the_ buffering_path and put_unknown_length_offer_digest_mismatch_rejects _at_completion died with the unknown-length wire form; the
kv-only-threshold test collapsed to the known-length form (renamed
put_kv_only_threshold_rejects_the_known_length_offer); response_hex_of
died (the response echoes the wire key hex — one form everywhere);
put_denies_the_ungranted_namespace* and the ACL-matrix put payloads
gained a key so the FORBIDDEN assertions isolate the ACL gate ahead of
a schema-valid offer; the malformed-put battery gained the two
mandatory-claim deaths (missing key, missing len); the establisher
sweep test covers the missing/undecodable/bare-hex key refuses plus
the gate-passing ack, and the establisher now exposes the ack via the
public reply_fields() accessor (the field is alkcall-private —
caught at the all-features gate).
Doc sweep in code (the module doc must not contradict landed
behavior — record-corrections' full docs sweep rides its own task):
src/ops/mod.rs's blobs/put section (offer/response shapes, the
pre-EOF single claim-check moment, the deleted unknown-length
paragraph), the fanout's producer-side paragraph (begin under the
offered key; a refused begin runs store-arm-only), the squatting
residuals' naming ("the unverified offered claim/key" — the residual
record itself untouched), the pin-token wire-form notes in tokens.rs
and mod.rs ({token, key}), and fetch.rs's residual header. The ops
record docs (ops-surface.md, store-api.md, ADR-012/014/017/019/020)
already carried the target vocabulary ahead of this wave.
Summary
The put handler is under mandatory claims: the {namespace, key, len}
offer (both claim fields required — the wire's unknown-length put is
dead), ingress through digest_ingress, the tee hasher opened
unconditionally at stream start under the offered key's algorithm
(every registered family cross-checks under its own derivation), the
store arm routed through the facade's one put_as seam with the
mandatory len, and exactly one claim-check moment — pre-EOF — with the
deferred post-check arm deleted. The response echoes the key
({token, key}, tag-prefixed), the error texts and details speak the
per-key derivation vocabulary ({key, derived} / {key} details over
the wire key hex), and the fleet/renewal/confirm/have semantics are
test-pinned unchanged. The family pinning test's write side landed: a
wire-offered git-object put commits under its own oid. Verification:
cargo test (default: 156 / 54 / 7 / 2), cargo test --all-features
server-less (341 / 125 / 7 / 3, clean skips) and against the live
pglo-poc harness (341 / 125 / 7 / 3 — the pg columns ran for real),
both clippy variants with -D warnings, cargo fmt --check,
cargo doc --no-deps, and the fuzz corpus-replay gate (7 passed) —
all green. One wall-clock cadence flake on the first loaded
parallelism run (the_paused_fleet_token_expires_reaps…) passed on
the full re-run and in isolation — the recorded run-discipline
posture, not a regression.