Files
alkblobs/tasks/ops-put-handler-key-claims.md
T
glm-5.3-flash 45a11cbcf0 feat(ops): the put handler under mandatory claims — key + len mandatory, the tee hasher parameterized, the response echoes the key (ADR-020 §Decision 4, wave 14)
- 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).
2026-10-18 23:30:19 +00:00

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
wire-key-hex-ingress
facade-put-as-seam
moderate medium component implementation
wave-14
review-003
adr-020
ops
put
wire-break

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 naming digest → key is the default reading; record the choice). The wire's unknown-length put dies with this: offered_len is always Some, 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's digest_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::new call takes the offered key's algorithm (Key::algorithm — the hasher already carries all four members) and the mandatory len; 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) replaces store.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 the Algorithm answers, 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-commit
    • DIGEST_MISMATCH settle. 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 the finish_put result update; the key pattern is the wire form's. Renewal, confirm, and have semantics 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 and put_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's with_description and the PUT_ACK_FIELD doc'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's INVALID_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_as with the offered key's algorithm and the mandatory len; store.put has 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-features server-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.