Files
alkblobs/tasks/s5-token-field-bounds.md
T
glm-5.3-flash 437ab7f746 ops(security): S-5 — the renewal form's token field bounded, the token-layer retention posture recorded (review-001, wave 10)
- have's `token` field gains `maxLength` + pattern
  (`wire::token_wire_schema`); the pattern is case-insensitive
  (`^[0-9a-fA-F]{64}$`) to agree exactly with `from_hex`'s
  case-insensitive ingress contract — the canonical mint form stays
  lowercase, the put response's pattern unchanged
- `PinToken::from_hex` length-guards before decoding — a wrong-length
  input answers `None` with no proportional allocation
- the malformed-payload sweep's have arm gains the over-length /
  non-hex token payloads (`INVALID_INPUT` at the gate); the
  renewal-form test's malformed arm restructured (wire rejects, the
  handler's present:false mapping keeps direct calls; uppercased wire
  form renews — the gate/ingress agreement asserted)
- embedder-facing records: the registry's process-lifetime retention
  posture (a put loop without confirms grows without bound) with the
  admission cap named as the future hardening (decomposer's deferred
  judgment stands — a refused mint answers a committed put, a mint-
  contract decision), and the custodial-never-confirm warning
  (GC can never reap an unconfirmed custodial pin)
- gates: cargo test (default), --all-features server-less + live pg
  harness, clippy both variants -D warnings, fmt --check, doc
  (one pre-existing local warning, untouched)

Verification:
- cargo test: 149+54+4 ok
- cargo test --all-features (live harness): 326+125+4 ok
- cargo clippy --all-targets -- -D warnings: clean (+ --all-features)
- cargo fmt --check: clean
2026-10-15 21:45:34 +00:00

9.0 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
s5-token-field-bounds S-5 — the have renewal form's token field bounded before the hex decode; the registry/custodial retention recorded embedder-facing completed
s1-s3-ops-authority-model
narrow low component implementation
wave-10
review-001
security
ops
tokens

Description

Review-001 S-5 — unbounded capability resources on an authenticated surface. Three parts:

  1. The token field's schema bound + the decode order. The renewal form's token field is a bare {"type": "string"} (src/ops/specs.rs:131) — no maxLength, no pattern — and [PinToken::from_hex] (src/ops/tokens.rs:56-59) hex-decodes proportionally before any length check: an arbitrarily large string is decoded (allocating proportionally) before the try_into rejects. Fix both: the schema gains maxLength/pattern (the canonical wire form is lowercase 64-char hex — ^[0-9a-f]{64}$, the same pattern the put response's token field already carries, src/ops/put.rs:89; the decode is case-insensitive per from_hex's contract, so decide whether the schema pattern is the canonical-lowercase form or the ingress stays case-insensitive with the schema bound only on length — design point, record in Notes; the schema gate and the ingress must not disagree silently), and from_hex checks the length before decoding (a cheap len() == 64 guard ahead of hex::decode — the proportional allocation never fires).
  2. The embedder-facing retention records. Two named behaviors are currently silent:
    • The token registry's process-lifetime retention (src/ops/tokens.rs — the registry doc; entries retire at confirm or process end): one put per loop without confirm is unbounded memory growth. Record it embedder-facing (the module doc's token-registry section) as the documented posture.
    • The custodial-never-confirm warning (a custodial pin held forever by an embedder that never confirms — GC can never reap): record the embedder-facing warning on the custody/ hand-over doc section.
  3. The admission cap — judged at decomposition (the plan's clause), recorded here for the task to land. Judgment (decomposer, 2026-10-16): deferred-and-documented, not implemented. The registry is process-lifetime by decided design; growth requires an authenticated write-granted principal looping puts without confirm — an embedder misuse the custodial-never-confirm warning addresses; a cap is an eviction/refusal policy decision on the mint contract (which entry dies, what a refused mint answers) — a behavior change beyond the security round's fix-shaped scope. The task lands the retention record naming the cap as the known future hardening (the review's "name it, then judge" satisfied: named here, judged deferred). If the implementation session finds the cap trivially expressible (e.g. a mint-refusal at a config bound with no eviction question), it may land it instead — the judgment flip recorded in Notes either way.

Tests: the schema bound rejects an over-length/non-hex token at the registry (INVALID_INPUT — extend the malformed-payload sweep's have-renewal arm); from_hex's length-first order unit-tested (a huge string answers None without a proportional decode — assert the rejection, the order itself is inspection-verified).

Acceptance Criteria

  • The renewal form's token field carries maxLength + pattern (the schema/ingress case posture decided and recorded)
  • PinToken::from_hex length-checks before decoding
  • The token registry's process-lifetime retention and the custodial-never-confirm warning recorded embedder-facing, the admission cap named as the future hardening (or landed, with the judgment flip recorded)
  • Tests: the over-bound token rejected at the schema; from_hex rejects wrong-length input without proportional decode
  • Gates green: cargo test (default), cargo test --all-features server-less, cargo test --all-features + live harness, clippy --all-targets -- -D warnings across the feature variants, cargo fmt --check

References

  • docs/reviews/001-storage-surface-general-review.md (S-5 — pre-split coordinates)
  • docs/plans/implementation.md (§Wave 10 — the "admission cap judged at decomposition" clause; §Review gates — the security gate)
  • src/ops/specs.rs (the have spec's token field)
  • src/ops/tokens.rs (PinToken::from_hex; the registry + custody doc sections)
  • src/ops/put.rs:89 (the put response's token pattern — the consistency contrast)
  • src/ops/tests.rs (the malformed-payload sweep — the schema-bound test's home)

Notes

The case posture (part 1's design point): the ingress stays case-insensitive and the schema pattern matches it exactly. The renewal form's token field schema is now {"type": "string", "maxLength": 64, "pattern": "^[0-9a-fA-F]{64}$"} — the new wire::token_wire_schema helper, beside the digest field shapes. It is case-insensitive because PinToken::from_hex's decode contract is case-insensitive (a public API the embedders call directly, its unit-backed contract unchanged), so the gate and the ingress agree on every input: nothing the gate passes decodes differently below it, and no divergence exists to record. The canonical mint form stays lowercase (to_hex); the put response's token pattern keeps the canonical-lowercase ^[0-9a-f]{64}$ (put.rs) — responses are minted, not presented, so that strictness was never the ingress's shape. The renewal wire test asserts the agreement end to end (an uppercased wire form renews).

from_hex is length-first (len() != 64 → None ahead of hex::decode) — the proportional allocation never fires; the doc records the order and its relationship to the wire bound.

The doc records (part 2): the module doc's token-registry section gains the embedder-facing retention posture (process-lifetime memory; a put loop without confirms grows without bound, one held guard each non-fleet) naming the admission cap as the known future hardening — the cap is deferred, not landed (below). The custody split section gains the custodial-never-confirm warning (GC can never reap a custodial pin whose embedder never confirms; the confirm verb is the hand-over contract's closing act). TokenRegistry's own doc carries the pointer.

The admission cap (part 3): the decomposer's judgment stands — deferred-and-documented, not flipped. The escape-hatch shape (a mint-refusal at a config bound) is not trivially expressible here: the put handler mints after the commit, so a refused mint answers a committed stream — what the putter is told (an internal on a landed blob? a new typed error?) and what happens to the hand-over is exactly the mint-contract policy decision the judgment named, not a mechanical refusal. No eviction question avoided either: refusing leaves the landed entry to GC with no pin handle. Deferred, recorded in the retention posture.

Test shape: the malformed-payload sweep's have arm gained the over-length ("a".repeat(65)) and non-hex ("G".repeat(64)) token payloads → INVALID_INPUT at the gate; the renewal-form test's malformed arm restructured — the wire now answers INVALID_INPUT (the old bare-string schema let it through to present: false), and the handler-level direct invocation keeps the malformed-token → present: false mapping (the store-free arm survives for calls riding past the wire gate, doc-aligned in specs.rs); from_hex's huge-string rejection unit-tested in tokens.rs (~1 MiB of as, plus the 63/65/0-length near-misses).

One incidental: the ops feature is not in the default feature set, so gates 1 and the ops-bearing runs are distinct runs (default = 326-free; --all-features = 326+125+4 lib/integration tests).

Summary

All three parts landed. PinToken::from_hex length-guards before decoding; the have renewal form's token field carries maxLength: 64 + the case-insensitive hex pattern (^[0-9a-fA-F]{64}$), chosen to agree exactly with from_hex's case-insensitive ingress contract (the agreement asserted wire-level via an uppercased renewal); the malformed-payload sweep's have arm and the renewal-form test cover the gate's INVALID_INPUT and the handler's surviving present: false mapping; the registry retention posture, the custodial-never-confirm warning, and the admission cap named-as-future-hardening are recorded embedder-facing in the module doc (token-registry + custody sections) and TokenRegistry's doc; the decomposer's deferred cap judgment holds (the refused-mint-answers-a-committed-put question is not trivially expressible). Gates: cargo test (default, 207), cargo test --all-features server-less and against the live pg harness (326/125/4), clippy --all-targets -D warnings on both feature variants, cargo fmt --check, cargo doc --no-deps (one pre-existing warning in local, untouched here).