Skip to content

fix(transactions): bound the batch patch's import-id identity at the spec's 36 - #61

Merged
brunovenceslau merged 1 commit into
mainfrom
fix/enforce-import-id-bound
Aug 11, 2026
Merged

fix(transactions): bound the batch patch's import-id identity at the spec's 36#61
brunovenceslau merged 1 commit into
mainfrom
fix/enforce-import-id-bound

Conversation

@brunovenceslau

Copy link
Copy Markdown
Owner

Summary

Closes the last entry in wireBoundsUnenforced: the import-id identity of a PatchByImportID patch is now pre-flighted against the spec's 36-character bound in UpdateBatch. Previously an over-long key cost a round trip and came back a server 400; now it fails client-side with the *ArgumentError every other transcribed bound answers with, naming the field and the patch index ((patch N)).

PR #55 recorded this exact waiver and named enforcing it "a behavior change belonging in its own commit" — this is that commit.

What changed

File Change
transactions.go TransactionPatch gains its own validate, shadowing the promoted TransactionUpdate.validate: checkRuneMax on the import-id identity (runes, not bytes — the unit JSON Schema's maxLength counts), then the embedded update's bounds unchanged.
contract_wire_test.go The {SaveTransactionWithIdOrImportId, import_id} row moves from wireBoundsUnenforced (now empty — the table stays, the mapped-or-waived mechanism is the point) into wireBounds. The table doc now states its honest limit: a row claims enforcement, its teeth are the behavior test.
transactions_test.go Two tests pin the boundary from both sides: 37 multibyte runes rejected before any I/O with Field == "import_id" and (patch 1); a 100-char PatchByID id plus exactly 36 runes of import_id pass pre-flight and are asserted to arrive in the request body intact (the fake captures the body — "unbounded" means sent, not merely attempted).
CHANGELOG.md User-visible entry under [Unreleased]Changed.

Deliberate non-changes

  • The id identity stays unbounded, and a test pins that too — the spec declares no maxLength on transaction ids, and inventing one would reject payloads the server accepts, the exact over-reach the validate doctrine (spec-stated invariants, and only those) exists to prevent.
  • Update, Create, CreateBatch, and PatchByID behavior is untouched — verified call-site by call-site in review; MarshalJSON is untouched, so wire bytes for accepted inputs are unchanged.

Adversarial review (2 rounds, 3 personas each)

Round Key finding Fix
1 Duplicate ### Changed heading under [Unreleased]; "request went out" test would miss silent id truncation; waiver-table move with a bogus reason survives mutation (table ≠ gate) Headings folded; body-capture assertions; the table's limit documented where the next row will be added
2 APPROVE / GO / GO; body capture race-clean (-race -count=20), truncation and dropped-field mutants die by name, production code byte-identical across rounds

Mutation campaign: 6/7 killed in round 1 (validate removal, byte-count swap, both off-by-ones, delegation drop, index annotation) + both new classes in round 2 (silent truncation, dropped import_id). The sole survivor is the documented table-move mutant, guarded independently by the behavior test.

Testing

  • make local-ci exit 0; coverage 97.1%; new TransactionPatch.validate at 100%, no stranded statements.
  • Behavior change is user-visible → CHANGELOG entry included.

…spec's 36

The last entry in wireBoundsUnenforced was a decision, not a gap: the
spec bounds SaveTransactionWithIdOrImportId.import_id at 36 characters,
but PatchByImportID's key reached the wire unchecked because
TransactionPatch only had the validate it inherited from the embedded
TransactionUpdate, which has no import-id field. An over-long key cost a
round trip and came back a server 400 instead of the *ArgumentError
every other transcribed bound answers with. PR #55 recorded the waiver
and named enforcing it a behavior change belonging in its own commit —
this is that commit.

TransactionPatch now carries its own validate, shadowing the promoted
one: the import-id identity through checkRuneMax — runes, not bytes, the
unit JSON Schema's maxLength counts — then the embedded update's bounds
unchanged. UpdateBatch's per-element loop already annotates the failing
index, so the new rejection reads "(patch N)" like every other.

The id identity stays deliberately unbounded, and a test pins that too:
the spec declares no maxLength on transaction ids, and inventing one
would reject payloads the server accepts — the same over-reach this
package's validate doctrine (spec-stated invariants, and only those)
exists to prevent. The boundary is pinned from both sides: 36 runes of
multibyte import_id pass pre-flight AND arrive in the request body
intact — the fake captures the body, because "unbounded" means sent,
not merely attempted — while 37 fail with the field and patch index
named before any I/O.

The wireBoundsUnenforced table stays, empty: the mechanism is the point
— the completeness assertion still forces every spec bound to be mapped
or waived, so the next declared-but-unenforced bound must be written
down, not forgotten. Its import_id row moves to wireBounds, making
ImportIDMax enforced on every path that carries the field. The review's
mutation pass proved the honest limit of that table: a row moved back to
the waiver side with a bogus reason passes this file, because the table
pins the spec side only — each row's teeth are its behavior test, and
the table's comment now says so where the next row will be added.

CHANGELOG records the user-visible half under the existing Unreleased
Changed section: a batch that previously failed server-side with a 400
now fails client-side with *ArgumentError before any I/O. No wire
payload changes for inputs the server accepts.
@brunovenceslau
brunovenceslau merged commit c9c7330 into main Aug 11, 2026
5 checks passed
@brunovenceslau
brunovenceslau deleted the fix/enforce-import-id-bound branch August 11, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant