feat(contracts/org): versioned canonical encoder and digest - #246
Conversation
The first code of the org substrate, and deliberately the first: a chain's digests outlive every refactor that follows them, so the bytes that produce a hash must be specified before any record type exists. Not reflection over structs. All four bakeoff kernels this package draws from canonicalize with json.Marshal, which ties the byte sequence to Go declaration order, HTML-escapes & < >, and carries no scheme version - digests stable Go-to-Go and reproducible nowhere else, invalidated silently by a field reorder. Here a type states its shape by building a Value, so the encoding is a specification rather than an artifact of this implementation. Encoding: no insignificant whitespace, map fields in sorted-name order, shortest-decimal integers, one escape rule (JSON's two required escapes plus C0 controls), everything else literal UTF-8. No floats - their decimal form is the richest source of cross-language disagreement and no org record needs one. Ambiguous input is refused rather than resolved: duplicate field names, empty names, invalid UTF-8. The scheme version is hashed in, not recorded beside the digest, so a reader that ignores it gets an ordinary mismatch instead of silently accepting foreign bytes. Golden digests are reproducible with printf | shasum -a 256, verified against this implementation - the portability claim as an executable test rather than an assertion. 97.7% coverage, race clean, imports nothing in the module. Design: docs/features/org/spec.md 4.10. Closes task `p1-t0-canonical-encoder` Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
|
@claude review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @itsHabib's task in 2m 25s —— View job Code Review — PR #246
This is a well-designed, well-motivated piece of foundational infrastructure. The encoding decisions are sound, the test strategy is exactly right (hand-written expected bytes rather than round-trips), and the reasoning is documented at the right level of detail. The notes below are mostly minor polish. Correctness
Scheme separator (digest.go:37–39) — Using Minor Issues1. Garbled comment on // hexDigits is the lowercase alphabet for \u escapes. Case is part of the
// encoding: and are different bytes.Something was elided after 2. Duplicate comment block above // Golden digests. These pin the byte-to-digest mapping including the scheme
// version, which is hashed in rather than merely recorded beside the digest.
// A change to the encoder, the scheme string, or the hash construction breaks
// these — which is the intent: they are the chain's compatibility contract.
// Golden digests, pinning the byte-to-digest mapping including the scheme
// version that is hashed in rather than recorded beside it. A change to the
// encoder, the scheme string, or the hash construction breaks these — the
// intent, since they are the chain's compatibility contract.The comment block says the same thing twice, slightly rephrased — looks like an edit artifact where a revised version was appended without removing the original. Fix this → 3. Redundant intermediate variable in func Encode(v Value) ([]byte, error) {
var buf []byte
buf, err := appendValue(buf, v)
if err != nil {
return nil, err
}
return buf, nil
}This allocates func Encode(v Value) ([]byte, error) {
return appendValue(nil, v)
}is idiomatic, eliminates the intermediate variable, and behaves identically since 4. func List(items ...Value) Value {
return Value{kind: KindList, list: items}
}The variadic Design Observations (no action required)
Summary: Correctness is solid. The three fixable items are a garbled comment, a duplicate comment block (edit artifact), and a one-line simplification to |
|
Reviewer: claude |
Four findings from the panel on #246: - hexDigits' comment lost its examples to an elision and read as a fragment. It now names the two byte sequences the case rule is about. - A revised comment above TestGoldenDigests was appended without removing the original, so the block said the same thing twice. - Encode allocated a nil slice only to pass it along; appendValue(nil, v) is the same behavior in one line. - List stored its variadic slice directly. A variadic call cannot alias, but List(slice...) can, and a caller mutating that slice afterwards would change what a later Encode produced — a digest that depends on when it was taken. It clones now, matching sortFields' existing posture. Goldens unchanged, which is the point: none of this alters a byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds the continuity contract on top of the canon/v1 encoder: the
fixed-shape record spine, per-kind admission law, and the pure fold that
makes "holding the tip is being the role" a property of code rather than a
runtime convention.
Written against vision.md §3.9 — the state machine, not the earlier spec
draft. Chartered -> Held(inc) -> Active(inc, w) -> Retired, with each law
stated as something the system cannot represent:
L1 no incarnation without owned work — a charter's scope must name at
least one work reference
L2 hold many, act on exactly one — which is what lets an effect stamp
DERIVE its work_ref from state instead of taking it from the caller
L3 stopping produces a terminal record — a takeover mid-claim leaves a
dangling claim the successor must discharge before it may claim
L4 an effect needs Active plus an open-intent bound of one
Three decisions worth review:
- Structural parameters live on the SPINE, not in the body. The fold has to
survive body erasure, and a claim whose work URI sat in an erasable blob
would make ownership depend on a blob somebody is entitled to delete.
- Incarnation ids are the digest of the record that minted them, so charter,
attach and takeover carry none (a record cannot contain its own digest).
Digests rather than branchroom's parent+n epochs: a guessable id is one a
re-reading writer copies by accident.
- Identity is checked BEFORE chain position. The stale writer that matters
re-read the tip and presents a perfectly correct prev; a position-first
reducer calls that chain healthy and misdiagnoses the one failure the law
exists to catch. TestCrashTakeoverAndTheStaleWriter pins the ordering.
Also here: kind classes on the wire (unknown structural refuses, unknown
advisory is skipped — an old reader that skips a takeover concludes it still
holds the role); refusals as values with a frozen 37-identifier vocabulary;
annul restricted to the tip, because verifying an arbitrary digest would
make the fold grow with the chain.
Three reconciliations of vision.md are recorded in record.go's file comment
and belong in the doc before P1's gate: claim/yield/complete added as
structural kinds, abandon settled as a claim terminal, handoff dropped in
favour of release-then-attach.
94.6% coverage, race clean, lint clean, leaf-clean. Property tests cover
prefix-plus-remainder equivalence and total-refusal over arbitrary chains.
Stacked on #246 (canon/v1 encoder).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude review |
|
Claude finished @itsHabib's task in 3m 49s —— View job Code Review — PR #246
Reviewed at HEAD Issue 1 —
|
| Finding | Status |
|---|---|
Encode simplification to return appendValue(nil, v) |
✅ applied |
List aliasing → slices.Clone |
✅ applied |
Duplicate comment above TestGoldenDigests |
✅ removed |
Garbled hexDigits comment |
Design notes (no action required)
- The byte-by-byte
appendStringloop is correct: multi-byte UTF-8 continuation bytes (0x80–0xBF) and leading bytes (0xC2–0xFF) are all ≥ 0x20, not"or\, so they pass through intact.utf8.ValidStringat the top ensures no ill-formed sequences reach the loop. TestDistinctShapesDigestDistinctlyis excellent: the collision check across ten structurally different values (list-of-two vs concatenated string, int-1 vs string-"1", etc.) catches the framing accidents that golden-digest tests alone would miss.TestEncodingDiffersFromEncodingJSONis load-bearing defensive infrastructure. If this package gets "simplified" back tojson.Marshal, the test breaks loudly — exactly right for foundational hash substrate.- The scheme-inside-hash construction in
DigestBytesis correct. HashingScheme + \x00 + canonicalmeans a scheme mismatch presents as a digest mismatch, which every consumer already handles, rather than requiring a parallel field check.
Summary: Two issues remain — the hexDigits comment still has invisible control characters where its examples should be, and Map has the same aliasing exposure List was just fixed for. Both are one-liners. Ready to merge after those.
|
Reviewer: claude |
…iasing Two findings, both one-liners. The hexDigits comment was reported fixed in round 1 and was not. The round-1 edit was a string replace whose pattern did not match, so it silently changed nothing — the line still held two literal 0x1F bytes where its examples belonged, which is why it kept rendering as a fragment. Written out properly now, and a sweep confirms no control byte survives anywhere in the package. Map stored its variadic slice directly, the same exposure List was fixed for in round 1. Map(slice...) handed the caller's backing array a live reference inside the Value, so mutating it between two Encode calls produced two digests from one Value — the thing the List fix argued was unacceptable. sortFields already cloned defensively before sorting, so nothing downstream changes; this closes the constructor. Goldens unchanged. Round 2 of the two-round cap; anything further goes to the judge as residual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds the continuity contract on top of the canon/v1 encoder: the
fixed-shape record spine, per-kind admission law, and the pure fold that
makes "holding the tip is being the role" a property of code rather than a
runtime convention.
Written against vision.md §3.9 — the state machine, not the earlier spec
draft. Chartered -> Held(inc) -> Active(inc, w) -> Retired, with each law
stated as something the system cannot represent:
L1 no incarnation without owned work — a charter's scope must name at
least one work reference
L2 hold many, act on exactly one — which is what lets an effect stamp
DERIVE its work_ref from state instead of taking it from the caller
L3 stopping produces a terminal record — a takeover mid-claim leaves a
dangling claim the successor must discharge before it may claim
L4 an effect needs Active plus an open-intent bound of one
Three decisions worth review:
- Structural parameters live on the SPINE, not in the body. The fold has to
survive body erasure, and a claim whose work URI sat in an erasable blob
would make ownership depend on a blob somebody is entitled to delete.
- Incarnation ids are the digest of the record that minted them, so charter,
attach and takeover carry none (a record cannot contain its own digest).
Digests rather than branchroom's parent+n epochs: a guessable id is one a
re-reading writer copies by accident.
- Identity is checked BEFORE chain position. The stale writer that matters
re-read the tip and presents a perfectly correct prev; a position-first
reducer calls that chain healthy and misdiagnoses the one failure the law
exists to catch. TestCrashTakeoverAndTheStaleWriter pins the ordering.
Also here: kind classes on the wire (unknown structural refuses, unknown
advisory is skipped — an old reader that skips a takeover concludes it still
holds the role); refusals as values with a frozen 37-identifier vocabulary;
annul restricted to the tip, because verifying an arbitrary digest would
make the fold grow with the chain.
Three reconciliations of vision.md are recorded in record.go's file comment
and belong in the doc before P1's gate: claim/yield/complete added as
structural kinds, abandon settled as a claim terminal, handoff dropped in
favour of release-then-attach.
94.6% coverage, race clean, lint clean, leaf-clean. Property tests cover
prefix-plus-remainder equivalence and total-refusal over arbitrary chains.
Stacked on #246 (canon/v1 encoder).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude review |
|
Claude finished @itsHabib's task in 1m 42s —— View job Code Review — PR #246 (Round 3)
Reviewed at HEAD Round-2 fixes confirmed
A sweep confirms no C0 control bytes survive anywhere in the package. The comment is now instructive and readable; Cumulative findings across all rounds
Design observations (no action required)
Ready to merge. All verified findings from rounds 1 and 2 are addressed; the code is correct, the tests are authoritative, and the encoding decisions are sound. |
|
Reviewer: claude |
Builds the continuity contract on top of the canon/v1 encoder: the
fixed-shape record spine, per-kind admission law, and the pure fold that
makes "holding the tip is being the role" a property of code rather than a
runtime convention.
Written against vision.md §3.9 — the state machine, not the earlier spec
draft. Chartered -> Held(inc) -> Active(inc, w) -> Retired, with each law
stated as something the system cannot represent:
L1 no incarnation without owned work — a charter's scope must name at
least one work reference
L2 hold many, act on exactly one — which is what lets an effect stamp
DERIVE its work_ref from state instead of taking it from the caller
L3 stopping produces a terminal record — a takeover mid-claim leaves a
dangling claim the successor must discharge before it may claim
L4 an effect needs Active plus an open-intent bound of one
Three decisions worth review:
- Structural parameters live on the SPINE, not in the body. The fold has to
survive body erasure, and a claim whose work URI sat in an erasable blob
would make ownership depend on a blob somebody is entitled to delete.
- Incarnation ids are the digest of the record that minted them, so charter,
attach and takeover carry none (a record cannot contain its own digest).
Digests rather than branchroom's parent+n epochs: a guessable id is one a
re-reading writer copies by accident.
- Identity is checked BEFORE chain position. The stale writer that matters
re-read the tip and presents a perfectly correct prev; a position-first
reducer calls that chain healthy and misdiagnoses the one failure the law
exists to catch. TestCrashTakeoverAndTheStaleWriter pins the ordering.
Also here: kind classes on the wire (unknown structural refuses, unknown
advisory is skipped — an old reader that skips a takeover concludes it still
holds the role); refusals as values with a frozen 37-identifier vocabulary;
annul restricted to the tip, because verifying an arbitrary digest would
make the fold grow with the chain.
Three reconciliations of vision.md are recorded in record.go's file comment
and belong in the doc before P1's gate: claim/yield/complete added as
structural kinds, abandon settled as a claim terminal, handoff dropped in
favour of release-then-attach.
94.6% coverage, race clean, lint clean, leaf-clean. Property tests cover
prefix-plus-remainder equivalence and total-refusal over arbitrary chains.
Stacked on #246 (canon/v1 encoder).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ld (#248) * feat(contracts/org): record spine, contract law, and the ownership fold Builds the continuity contract on top of the canon/v1 encoder: the fixed-shape record spine, per-kind admission law, and the pure fold that makes "holding the tip is being the role" a property of code rather than a runtime convention. Written against vision.md §3.9 — the state machine, not the earlier spec draft. Chartered -> Held(inc) -> Active(inc, w) -> Retired, with each law stated as something the system cannot represent: L1 no incarnation without owned work — a charter's scope must name at least one work reference L2 hold many, act on exactly one — which is what lets an effect stamp DERIVE its work_ref from state instead of taking it from the caller L3 stopping produces a terminal record — a takeover mid-claim leaves a dangling claim the successor must discharge before it may claim L4 an effect needs Active plus an open-intent bound of one Three decisions worth review: - Structural parameters live on the SPINE, not in the body. The fold has to survive body erasure, and a claim whose work URI sat in an erasable blob would make ownership depend on a blob somebody is entitled to delete. - Incarnation ids are the digest of the record that minted them, so charter, attach and takeover carry none (a record cannot contain its own digest). Digests rather than branchroom's parent+n epochs: a guessable id is one a re-reading writer copies by accident. - Identity is checked BEFORE chain position. The stale writer that matters re-read the tip and presents a perfectly correct prev; a position-first reducer calls that chain healthy and misdiagnoses the one failure the law exists to catch. TestCrashTakeoverAndTheStaleWriter pins the ordering. Also here: kind classes on the wire (unknown structural refuses, unknown advisory is skipped — an old reader that skips a takeover concludes it still holds the role); refusals as values with a frozen 37-identifier vocabulary; annul restricted to the tip, because verifying an arbitrary digest would make the fold grow with the chain. Three reconciliations of vision.md are recorded in record.go's file comment and belong in the doc before P1's gate: claim/yield/complete added as structural kinds, abandon settled as a claim terminal, handoff dropped in favour of release-then-attach. 94.6% coverage, race clean, lint clean, leaf-clean. Property tests cover prefix-plus-remainder equivalence and total-refusal over arbitrary chains. Stacked on #246 (canon/v1 encoder). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(contracts/org): close the gaps mutation testing found `gremlins unleash --timeout-coefficient 30 ./contracts/org/` on the previous commit: 130 killed, 6 lived, 2 not covered — 95.6% efficacy against 94.6% line coverage. Every surviving line was "covered": executed by a test that never asserted on what it did. That gap is the argument for running mutants instead of trusting a coverage number. Two survivors were real: - The min_reader monotonicity check had no EQUALITY case. Only a decrease was ever tested, so mutating `<` to `<=` — which refuses a recharter that leaves min_reader alone, the common case — survived untouched. - Nothing asserted on NextDue at all, so inverting the guard that sets it survived. Derived liveness is computed against that field; a deadline that silently stops advancing is a role that reads as alive forever. Two were untested branches worth covering: Refusal.Error's seq-0 form, and short()'s exact boundary. Now 135 killed, 2 lived, 1 not covered — 98.5%. The three survivors are equivalent mutants and are recorded in record_test.go with the argument for each, so a future survivor reads as a missing test rather than as one more thing that was always like that. The most interesting one is structural: a mutation of prose inside a refusal's detail string cannot be killed, because every assertion here matches on Reason and never on the sentence. That is what makes the vocabulary safe to reword. Coverage 94.6% -> 96.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(contracts/org): teardown may not strand an obligation An exhaustive walk of the reachable state space found two ways to lose an obligation permanently, and both survived 96% line coverage, a 98.5% mutation score, and the rapid property tests. charter attach assign claim takeover unassign retire charter attach assign claim intent-ref abandon merge Both reach PhaseRetired with something still open — a dangling claim in the first, an effect intent with no outcome in the second. Nothing may extend a retired chain, so in both cases the obligation is gone for good. That is the silent disappearance L3 says must not be representable, and for the intent it is worse: T2's whole claim is that a replacement can always determine committed-versus-absent, and a retired role deletes the place that determination would be written. Neither sequence is individually suspicious. checkTeardown asked only whether the HELD SET was empty, and unassign empties it while leaving the obligation behind — so each record is locally correct and the three together are not. That is the failure mode a hand-written table cannot find: you write cases for combinations you thought of. checkTeardown now refuses on any outstanding obligation — dangling claim, open intent, or unanswered escalation. The escalation arm is the same argument one step further out: a human answers the ROLE, which is what makes their latency free, and an answer cannot append to a terminal chain. New refusal `open_escalation`; the frozen vocabulary goes 37 -> 38. The walk itself ships as statespace_test.go: every reachable state, every transition out of it, asserting totality (every refusal is a named reason), eight state invariants, and a pinned reachable-state count. The count is the interesting artifact — it went 97 -> 91 -> 86 as each fix made a family of illegal states unreachable, which is the fix being visible as a shrinking frontier rather than as a passing test. One correction to the walk itself: its first version checked dischargeability one record ahead and reported a wedge at {chartered, dangling}. That state is correct — revoke kills the credential, the obligation survives its holder, and the successor picks it up after an attach. The machine was right and the property was wrong; it is a reachability search now. Coverage 96.3% -> 98.1%. Mutation 135/2/1 -> 140/2/1 (98.6% efficacy). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(contracts/org): review round 2 — assert the vocabulary, compare Terms Four accepted findings and one rejected. - The frozen-vocabulary test computed a digest and only LOGGED it, pinning the count alone. A rename that preserves count breaks every consumer doing errors.Is(err, &Refusal{Reason: ReasonXxx}) and passed CI silently — the exact thing the frozen vocabulary exists to prevent. The digest is the assertion now; the count stays as a second signal. - sameState skipped Terms, so the prefix-plus-remainder property could not have caught a fold that dropped or mangled an inherited charter. It compares Terms now, and the property chain carries a recharter so the comparison has something to disagree about. - checkBody used an exact emptiness check in one guard and a whitespace-aware one in the other. Both behaved correctly; the asymmetry read like a fix applied in one place and forgotten in the other. - RoleState.Dangling now documents that it may outlive its Held entry. Unassign blocks only the ACTIVE claim, so a successor can drop the work and still owe the close — correct, because the obligation is to record how the claim ended, and that survives the work leaving the plate. REJECTED: that `json:"subject,omitzero"` is a json/v2 tag stdlib ignores, leaving `"subject":{}` on every record. omitzero landed in stdlib encoding/json in Go 1.24 and go.mod requires 1.26, so the tag is honored and there is no divergence between a canonical round-trip and a JSON marshal. Verified against the toolchain rather than argued, and pinned as TestWireFormatElidesEmptyOptionals — which also fails loudly if go.mod is ever moved below 1.24, since that would silently change the wire format of every record. Mutation score holds at 98.6% (140 killed, 2 lived, 1 not covered). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…cisions to fold Independent reconciliation of the whole ownership-continuity lineage at 2026-08-26 heads: what #245 designed, what #246/#248/#262/#263 shipped through the governed path, what #265/#266 measure, what hooks#42/#43 and drive#47 add, what cc-skills#29 proves in fixture, and what remains hypothesis. Every major claim classified on the honest rung ladder; duplications and missing joins named; D1-D10 written to be accepted, amended, or struck in place. Indexed from vision.md's document table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First code of the org substrate. Deliberately first: a chain's digests outlive every refactor that follows them, so the bytes that produce a hash must be specified before any record type exists. Unfixable after the first chain is written.
Closes task
p1-t0-canonical-encoder. Design:docs/features/org/spec.md§4.10 (PR #245, not yet locked — this piece is settled independently of the open questions there).Why not
json.MarshalAll four bakeoff kernels this package draws from — branchroom, mandate, obligation, proofline — canonicalize with
json.Marshal. That ties the byte sequence to Go declaration order, HTML-escapes&,<,>, and carries no scheme version. The digests are stable Go-to-Go, reproducible nowhere else, and a field reorder during a refactor silently invalidates every historical digest.Here a type states its canonical shape by building a
Value, so the encoding is a specification a second implementation can follow rather than an artifact of this one.The encoding
No insignificant whitespace. Map fields in sorted-name order — build order cannot reach the bytes. Shortest-decimal integers. Exactly one escape rule: JSON's two required escapes plus C0 controls; everything else literal UTF-8, including
&,<,>, DEL, and U+2028/9.No floats. Their decimal representation is the single richest source of cross-language digest disagreement, and nothing in an org record needs one — sequences are integers, deadlines are RFC 3339 strings, sizes are integers.
Ambiguous input is refused rather than resolved: duplicate field names, empty names, invalid UTF-8. "Last wins" would be a rule a second implementation has to reproduce exactly; refusal needs no agreement.
The scheme version is hashed in, not recorded beside the digest — a reader that ignores a beside-the-digest field would silently accept bytes produced under different rules; hashing it in makes that an ordinary mismatch.
Tests
The authoritative tests are hand-written expected bytes, not round-trips — a round-trip passes for any self-consistent encoder, including a wrong one.
Golden digests are reproducible with standard tools, which is the portability claim as an executable check rather than an assertion:
Verified to agree with this implementation.
TestEncodingDiffersFromEncodingJSONpins the divergence so nobody "simplifies" this back into the thing it exists to replace.97.7% coverage,
-raceclean,go list -depsconfirms it imports nothing in the module.🤖 Generated with Claude Code