Skip to content

Claim Gate v0.1: ClaimBundle + ClaimReceipt — portable claim verification (signatures, expiry, supersession, verify()) - #34

Merged
jamesfgibbons merged 7 commits into
mainfrom
claim-gate/v0_1-bundle-receipt
Aug 28, 2026
Merged

jamesfgibbons merged 7 commits into
mainfrom
claim-gate/v0_1-bundle-receipt

Conversation

@jamesfgibbons

@jamesfgibbons jamesfgibbons commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

Portability round 4: YES/YES — independent implementation of verifier AND issuer confirmed; 23/23 golden parity; independent issuer reproduced every receipt's hashed core byte-for-byte. No further review rounds will run. Last residual closed in ccdbfd9: an as_of carrying more than six fractional digits is REFUSED as an operational error (CLI exit 2), never truncated and never rounded — truncation would map …00.1234567Z to …00.123456Z and …00.0000006Z to …00Z, rounding would map the latter to …00.000001Z, and each choice mints a different verified_at → receipt_id → receipt_hash. Pinned by test_round4_as_of_finer_than_microsecond_is_refused_not_truncated and test_round4_pinned_as_of_precision_rule_is_deterministic. Final count: 125 tests, all passing; 23/23 goldens byte-identical.

Residual / deferred at ratification time

  • Unicode normalization is unspecified (already named debt in the spec): ensure_ascii=False hashes the exact code points given, so an NFC-normalizing producer and a non-normalizing one hash the same logical content to different digests. Affects producers on both sides; no verifier-direction ambiguity.
  • Optional bindings issuer policy is unspecified: nothing says when an issuer SHOULD attach decision_hash / signal_id, so two issuers with different binding policy mint different receipts from the same bundle, keys, and as_of. Issuer-direction only — a verifier handed either receipt behaves identically.

Draft — merges only on a named founder YES after Codex review + the five-minute-read portability test.

Portability test round 2: PASS (8/8 gaps closed by prose, 9/9 parity, independent-implementation verdict YES). Round-2 residuals closed in 571fcfe: normative receipt-verification check order (first-failure single code, same conformance language as the bundle order) and the law-source clause inverted — prose + schemas + golden fixtures are the law for external implementers; the test file is internal enforcement only.

Portability round 3 — issuer side (verifier side: YES on first run)

Round 3 returned verifier-side YES (golden parity first run, all 8 prior gaps still closed, both round-2 residuals closed) and issuer-side NO: one blocking defect, four minor conflicts, plus a coverage note. All closed in 689615f.

Item Resolution
GAP-9 (BLOCKING) — computed-timestamp spelling unspecified Normative rule written (verbatim below). The code already normalized zero-fraction spellings as of 9c19e68, so no behavior change was needed for the demonstrated case; what was missing was the WRITTEN rule an independent issuer implements against. Pinned by test_gap9_equivalent_as_of_spellings_produce_byte_identical_receipts over five equivalent as_of spellings.
MINOR-1 — "full fractional precision" not implementable Chose the schema-bound option: fractional seconds limited to six digits in both schemas AND the verifier grammar; a seventh digit is now REFUSED (bundle_malformed), never truncated. Comparison resolution and arithmetic resolution are now the same microsecond, so schema, prose, and code agree and no host language's clock precision can change a verdict.
MINOR-2 — float conflict with CANONICAL_JSON.md Scoped explicitly, both statements in force: claim bundles/receipts REFUSE a JSON float at the door; the older conformance artifacts (CheckCatalogV1, EvidenceBundleV1, ConformanceReceiptV1) still COLLAPSE integral floats (1.0 → 1) and may legitimately carry a ratio. Neither artifact class reaches the other's branch.
MINOR-3 — keys document "any other member refused" vs shipped comment Prose aligned to the schema: document level is schema_version, issuer, keys, plus an OPTIONAL free-text comment that carries no semantics and is never consulted; key ENTRIES carry exactly key_id, alg, public_key. Test proves a keys file with and without the comment yields byte-identical receipts.
MINOR-4 — duplicate key_id exit code unlabeled Labeled in prose: operational refusal, CLI exit 2, no receipt emitted, never a typed verdict about the bundle (the keys file is verifier configuration, not the artifact under test).
COVERAGE — goldens pinned only 6 of 10 bundle codes Added issuer_mismatch, signature_invalid, bundle_malformed bundle/receipt goldens (signed attacker-style with raw Ed25519, since sign_bundle refuses to emit them) and a result-level receipt_hash_mismatch receipt golden. New test test_goldens_pin_every_bundle_reason_code asserts CLAIM_REASON_CODES is fully covered by shipped bytes.

The normative timestamp rule, verbatim from docs/CLAIM_GATE.md:

  1. Convert the instant to UTC. The zone is always the literal Z; a numeric offset MUST NOT appear in a computed timestamp.
  2. Write YYYY-MM-DDThh:mm:ss, then the fractional part by this rule: omit the fraction entirely when the microsecond field is zero; otherwise write exactly six digits. No other digit count is emitted — never .0, never .000, never .500.
  3. Normalize BEFORE the value enters the receipt: an as_of of 2026-08-15T12:30:00.000Z, 2026-08-15T12:30:00Z, or 2026-08-15T08:30:00-04:00 all name one instant and MUST produce verified_at 2026-08-15T12:30:00Z, the same receipt_id, and the same receipt_hash.

Tests 118 → 123, all passing (64 claim-gate). All 23 golden files regenerate byte-identical; no pre-existing golden changed; package-data schema copies stay byte-identical to schemas/.

Five-minute-read portability test: PASSED (yes-with-asterisk), asterisk now closed

A clean-room agent given ONLY docs/CLAIM_GATE.md + docs/CANONICAL_JSON.md + the fixtures achieved 8/8 golden parity — byte-exact hashes, verdicts, reason codes, valid_until. It recorded 8 spec gaps (4 divergence-capable while still passing goldens); all are now closed as normative spec text (commit e2810f2):

Gap Resolution
1. receipt_id underivable from spec Exact derivation written: cr_ + sha256(canonical JSON of {"bundle_hash","verified_at","evaluator_version"})[:16]
2. Verification clock as_of is an explicit INPUT, never implicit wall clock; verified_at IS the reported clock
3. Unmeasured value encoding Normative: value is an object whose "verdict" is UNMEASURED/NOT_APPLICABLE; all else measured
4. Refusal order Normative sequence 1-8 (schema → hash → key → signature → expiry-law → expiry → measurement); FIRST failing code emitted; different order = non-conformant
5. valid_until earlier than earliest claim Both directions of inequality are expiry_mismatch; no second code
6. Expiry boundary Inclusive: expired when as_of >= valid_until; code aligned (> → >= for bundles AND receipts); new boundary golden pair expired_boundary.* + named inclusivity test
7. Refused-receipt valid_until Prose rule: same min(bundle valid_until, verified_at + horizon) clamped to verified_at; unparseable bundle expiry → verified_at + horizon
8. bundle_mismatch trigger Defined precisely: receipt's quoted hash ≠ hash RECOMPUTED over the presented bundle's core bytes; stated bundle_hash field ignored (code changed from stated-field comparison to recompute)

Code changes required by the written rules: GAP-6 (two comparisons > → >=) and GAP-8 (recompute instead of stated-field read). Everything else already matched. After changes: 83/83 tests OK (24 claim-gate tests), all 14 pre-existing goldens byte-identical on regeneration, 2 new boundary golden files.

What existed vs what was invented

Existed in this repo and is REUSED, not reinvented:

  • Canonicalization + digest: docs/CANONICAL_JSON.md and evaluator.canonical_json/digest — claims.py imports the same code path; no copy-paste drift.
  • Verdict lattice PASS|FAIL|UNMEASURED|NOT_APPLICABLE and the existing reason_code vocabulary (extended, documented below).
  • Golden byte-stability harness pattern (tests/golden-receipts/ + conformance fixtures) — copied for bundles/receipts as tests/golden-claims/.
  • Immutable-core / volatile-record split (precedent: serpradio decision_envelope_v1): the Bundle is the frozen signed core, volatile facts FORBIDDEN inside; the Receipt quotes bundle_hash.
  • certified: false-style honesty: a receipt verifies INTEGRITY + policy conformance, never truth.

ABSENT from the entire workspace and INVENTED here: issuer identity, Ed25519 signatures, receipt expiry, supersession, and an exported verify(). Nothing in any repo previously let a third party verify a quoted claim offline.

Technical-constitution test table (all run in CI via the claims extra)

Clause Test Result
a. Determinism (same input → same canonical bytes + hash, twice) test_a_determinism_same_input_identical_canonical_bytes_and_hash_twice PASS
b. One changed byte → verification FAILS test_b_one_changed_byte_fails_verification PASS
c. Expired bundle → REFUSED, typed reason test_c_expired_bundle_refused_with_typed_reason PASS
d. Unknown signing key → FAIL CLOSED test_d_unknown_signing_key_fails_closed PASS
e. Superseded receipt can't masquerade as current; historical still verifies test_e_superseded_receipt_cannot_masquerade_as_current + test_e_supersession_never_mutates_the_hashed_core PASS
f. All-UNMEASURED evidence → never a green receipt test_f_all_unmeasured_evidence_is_never_a_green_receipt PASS
g. Independent reproduction via separate minimal code path test_g_independent_reproduction_from_schema_docs_and_fixture_bytes PASS
h. Key rotation done correctly → old bundle verifies by key_id test_h_key_rotation_done_correctly_keeps_old_bundles_verifying PASS
i. Golden byte-stable pairs (8 pairs: verified/tampered/expired/expired-boundary/unmeasured/unknown-key/expiry-mismatch/superseded) test_i_golden_bundles_and_receipts_are_byte_stable PASS
j. Earliest-expiry-governs enforced, not prose test_j_earliest_expiry_governs_is_enforced_not_prose PASS

Suite totals: 24 new tests in tests/test_claim_gate.py; full repo suite 83/83 OK with the extra installed, 63 OK + 20 honest skips on a lean install (skips are labeled, never silent passes). validate_contracts and validate_web_conformance both pass. Goldens regenerate byte-identically (16/16 files) via scripts/generate_claim_goldens.py. The constitution is fixture-proven (functionality is new — there was no prior behavior to be red against).

House-style rulings applied (deviations from the advisor sketch)

  • generated_at (never issued_at), valid_until (never expires_at), RFC 3339 UTC Z.
  • Outer hashes MANDATORY sha256:<64 hex>; quoting a legacy serpradio decision_hash accepts ^(sha256:)?[0-9a-f]{64}$ in bindings only.
  • schema_version = const type name (ClaimBundleV0_1, ClaimReceiptV0_1); receipt_id = ^cr_[a-z0-9]{6,}$ (deterministically derived — no randomness in identity); claim ids use the existing claim: prefix style.
  • Documented deviation: receipt_hash omits superseded/superseded_by as well as itself. A pure self-omitted hash would make marking a receipt superseded destroy its own historical verifiability, contradicting the never-overwrite law. Tested explicitly.
  • New reason codes (documented in docs/CLAIM_GATE.md): bundle_verified, bundle_malformed, hash_mismatch, signature_invalid, key_unknown, bundle_expired, expiry_mismatch, claims_unmeasured.

Dependency ruling

cryptography lives in the optional extra constitutional-cms[claims] — the base install stays PyYAML + jsonschema. Missing extra raises a clear error naming the install command; CI installs the extra so the constitution runs there. Fixture keys are TEST ONLY, clearly labeled, and protect nothing; no private key material is ever printed by any tool (keys load from file paths / CONSTITUTIONAL_CMS_CLAIM_KEY path env).

Deferred until earned

  • Serving the HTTP surface (GET keys.json / GET /claims/{id} / GET /receipts/{id} / POST /verify) — spec'd as NORMATIVE FUTURE in docs/CLAIM_GATE.md, explicitly not served by anything in this PR.
  • .well-known/constitutional-cms/keys.json serving (format is spec-only; the CLI reads the same shape from a local --keys file, fully offline).
  • TypeScript/JavaScript verifier (parity discipline already exists in tests/golden-receipts/).
  • Key revocation semantics beyond key removal; per-claim evidence narrowing; non-Ed25519 algorithms; network key discovery in the CLI.

DRAFT status recorded as v0.6.0-dev (pre-ratification) in CHANGELOG. No tag will be cut from this branch (signal-contract RELEASING.md discipline).

🤖 Generated with Claude Code


Adversarial review round 1 — five BLOCKING defects found and fixed (9c19e68)

Three independent reviewers ran live exploits against this branch. Every finding below was corroborated by at least two of them independently. Each fix carries a test that fails against the pre-fix code — verified by stashing the fix and re-running (29 of the new tests fail on the pre-fix tree).

Test counts: 83 → 118, all passing. Goldens regenerate byte-identically; package-data copies stay byte-identical to schemas/.

Fixed — blocking

# Defect Exploit demonstrated Fix Test
B1 Earliest-expiry-governs compared STRINGS. min() and != ran over RFC 3339 text at claims.py:224 and :445. The schema permits fractional seconds and . (0x2E) sorts before Z (0x5A), so lexical order ≠ chronological order. (a) claims ["…12:00:00Z", "…12:00:00.999999Z"] with bundle valid_until = the .999999 one → PASS; the bundle outlived its earliest claim. (b) bundle …12:00:00Z vs sole claim …12:00:00.000Z — same instant — → expiry_mismatch; a law-correct bundle refused. New parse_instant / instant_key (exact, Decimal fraction, offset-aware). All min() and equality on instants in both build_bundle and verify_bundle. New normative spec section "Timestamps are INSTANTS, never strings". test_bundle_later_than_its_earliest_claim_is_refused, test_same_instant_in_a_different_lexical_form_verifies, test_instant_key_orders_chronologically_where_strings_do_not, test_build_bundle_valid_until_is_the_earliest_instant
B2 Issuer never bound to the signing key. Key looked up by key_id alone; bundle["issuer"] checked against nothing. The spec's "the issuer's published key" had no enforcement behind the possessive. Signed with example.com's own key a bundle claiming issuer: victim-bank.example → PASS against example.com's own keys.json. Keys document MUST declare issuer (new ClaimKeysV0_1 schema, validated). New typed code issuer_mismatch at normative order step 4, right after key lookup, before signature. Added to the receipt schema enum and CLAIM_REASON_CODES. New spec section "Issuer binding — a key proves possession, never identity". test_cross_issuer_forgery_is_refused, test_matching_issuer_still_verifies, test_keys_document_without_an_issuer_is_refused_fail_closed, test_cli_cross_issuer_forgery_is_refused_with_a_typed_reason
B3 Duplicate key_id: first match wins. Verdict decided by document ORDER. Impostor entry sharing test-2026-a placed first → every legitimate bundle signature_invalid; placed last → passes. Duplicate key_id refuses the whole document, fail closed. Classified and documented as operational (ClaimGateError → CLI exit 2), because the keys file is verifier configuration, not the artifact under test. Keys doc also validated against a real schema (schema_version, issuer, keys[]) instead of only "keys[] is non-empty". test_duplicate_key_id_is_refused_in_either_order (both orderings), test_malformed_keys_entry_is_an_operational_refusal_not_a_crash, test_empty_keys_array_is_refused
B4 Duplicate/contradictory claim_id verified green. Consumers key by claim_id and silently get last-wins. Two claims sharing one claim_id, one {"verdict":"PASS"} and one {"verdict":"FAIL"} → PASS. Refused as bundle_malformed in code (JSON Schema cannot express it), folded into normative step 1 alongside timestamp parse-validation and the portability rejects. test_duplicate_claim_id_is_bundle_malformed
B5 Uncaught exceptions escaped as exit 1 — the documented code for a TYPED REFUSAL — with tracebacks. {"keys": ["oops"]} → AttributeError; bare JSON string as --bundle → AttributeError; --horizon-seconds 999999999999 → OverflowError; uppercase-hex bundle_hash → ValueError from receipt schema validation; {"verdict": ["UNMEASURED"]} → TypeError (unhashable); 2026-13-45T99:99:99Z and 2026-06-30T23:59:60Z → bare assert failure. verify_bundle/verify_receipt are now TOTAL over artifacts — every malformed input is a typed refusal, never an exception. Bare asserts replaced. Unmeasured check is safe against non-string verdicts (spec: they count as MEASURED). Quoted bundle_hash validated against the full ^sha256:[0-9a-f]{64}$ before use or receipt-quoting. Real RFC 3339 parse-validation in code (format: date-time is not enforced — no rfc3339-validator in the dependency set). horizon_seconds bounded to [0, 315360000] (10 years). CLI: exit 1 only for typed refusals; anything unexpected exits 2 with one line, never a traceback. test_uppercase_hex_bundle_hash_is_bundle_malformed, test_unhashable_claim_value_counts_as_measured, test_impossible_and_leap_second_timestamps_are_bundle_malformed, test_horizon_seconds_is_bounded, test_verify_bundle_is_total_over_arbitrary_json, test_verify_receipt_is_total_over_arbitrary_json, test_verify_receipt_refuses_a_non_object_bundle_without_crashing, test_unusable_as_of_is_operational_not_a_verdict, test_cli_never_exits_1_for_an_unexpected_condition

Documented as a v0.1 limit (spec text — receipts stay non-cryptographic in v0.1)

Limit What the spec now says Test
Receipts are UNAUTHENTICATED. Anyone can mint a green-looking receipt; receipt_hash is self-consistency only. New section "What a v0.1 receipt does NOT prove": unauthenticated, not evidence of issuance, only the bundle+keys path is authoritative. CLI receipt-only mode no longer prints VERIFIED — it prints RECEIPT INTEGRITY OK (unauthenticated — supply --bundle and --keys to verify the claim). test_cli_receipt_only_mode_never_claims_VERIFIED, test_cli_bundle_mode_still_says_VERIFIED
Supersession is unauthenticated. superseded/superseded_by sit outside the hash by design (so history stays verifiable), which means a holder can strip the annotation and it verifies as current, hash intact. Stated plainly: supersession is an ISSUER-SIDE assertion, not a revocation mechanism; absence of the annotation is not evidence of currency; a consumer needing current state MUST consult the issuer's receipt endpoint. Authenticated supersession/revocation added to deferred-until-earned. (spec only — the mechanism itself is unchanged and already tested)
Key status was inert. keys.json shipped "status": "retired" and the code ignored it; a retired key signed fresh long-lived bundles fine. Chose removal (the task's preferred option). Removed from the fixture, the golden generator, and the spec example. A keys entry carrying status is refused rather than silently ignored, so the removal is enforceable and nothing advertises a control that does not exist. Key lifecycle semantics added to deferred-until-earned; revocation in v0.1 is removal from the document. Rotation still keeps old bundles verifying (the verified golden is signed by the older key). test_h2_keys_entries_advertise_no_inert_status_control, test_h_key_rotation_done_correctly_keeps_old_bundles_verifying
Canonicalization portability hazards. Integral floats collapse (1.0 → 1), huge integers become 300-digit literals, duplicate object members are last-wins. New spec section "Portability MUST-rejects" + code: duplicate JSON object members refused at parse time on the CLI file-read path (object_pairs_hook); JSON floats and integers beyond 2^53-1 refused as bundle_malformed/receipt_malformed. Unicode normalization explicitly named as NOT yet specified. test_duplicate_json_object_members_are_refused, test_non_interoperable_numbers_are_bundle_malformed, test_cli_never_exits_1_for_an_unexpected_condition[duplicate JSON object member]

Tests added for previously unguarded checks

A deletion experiment showed these checks had no failing test — you could delete them and the suite stayed green. Each now has one, re-verified by deleting the check and confirming the named test fails:

Check Test Deletion experiment
Bundle schema validation step test_bundle_schema_validation_step_is_guarded delete step 1 → returns hash_mismatch → FAILS
Receipt schema validation step test_receipt_schema_validation_step_is_guarded delete step 1 → returns receipt_hash_mismatch → FAILS
receipt_expired test_receipt_expired_is_refused_for_current_state delete → FAILS
Receipt expiry inclusivity test_receipt_expiry_boundary_is_inclusive delete → FAILS
verify_receipt default current-state test_verify_receipt_defaults_to_the_current_state_question flip default to current=False → FAILS
NOT_APPLICABLE in the unmeasured set test_not_applicable_is_in_the_unmeasured_set drop it from the set → FAILS
Quoted-hash guard test_uppercase_hex_bundle_hash_is_bundle_malformed relax the guard → refusal comes from the totality net, not the normative step → FAILS

Two further tests retire the "dead constants" debt by making CLAIM_REASON_CODES / RECEIPT_RESULT_CODES load-bearing: test_reason_code_constants_match_the_published_schema (every code the verifier can emit is in the receipt schema enum) and test_every_emitted_code_is_a_declared_code (sweep every golden; no undeclared code escapes).

sign_bundle now also refuses to emit a bundle no conformant verifier will accept (test_sign_bundle_refuses_to_emit_what_no_verifier_accepts). This is defence in depth, not a security control — the adversarial specimens in the test suite are signed with raw Ed25519, which is exactly how an attacker signs.

Named debt — non-blocking findings, carried forward

Recorded here rather than fixed; none changes a verdict in v0.1:

  1. Numeric canonicalization parity — beyond the MUST-rejects now in place, there is no cross-implementation parity test for numbers that ARE legal.
  2. Unicode normalization — ensure_ascii=False hashes exact code points; a producer that NFC-normalizes and one that does not will disagree. Named in the spec as unspecified; no rule, no test.
  3. Non-canonical base64 padding — the sig pattern (^[A-Za-z0-9+/]{86}==$) and the new public_key pattern (^[A-Za-z0-9+/]{43}=$) pin the canonical form, but there is no test that a non-canonically-padded or whitespace-bearing encoding is refused.
  4. reason_codes has no maxItems / uniqueItems — a receipt could carry ["bundle_verified"] * 10_000 and validate. v0.1 emits exactly one code, so nothing exercises it.
  5. Temporal coherence unchecked — nothing requires observed_at <= valid_until, or generated_at to relate sanely to either. A bundle generated after it expires validates.
  6. No size or depth limits — a deeply nested or enormous claim value is accepted; canonicalization will recurse over it.
  7. bindings / generation_digest have no golden coverage — both are optional and neither appears in any golden pair, so their canonical placement is untested.
  8. Comment step-number drift — the in-code step comments were renumbered to match the new 9-step normative order in this commit, but nothing enforces that they stay in sync with docs/CLAIM_GATE.md.

Not judged a defect

None. Every corroborated finding reproduced exactly as reported; the reproduction script output is in the commit's review trail. Two of the reported B5 symptoms needed a more specific trigger than stated but were real: a bare JSON string passed to --bundle in bundle mode already returned bundle_malformed correctly — the AttributeError fires in receipt mode (--receipt … --bundle <bare string>) and in claim-sign. Both are fixed.

@jamesfgibbons

Copy link
Copy Markdown
Owner Author

@codex review

Exact-head review requested per the approved Claim Gate plan (W-B: Codex review then founder YES). Context: the five-minute-read portability test has passed round 2 — a clean-room implementer using ONLY docs/CLAIM_GATE.md + fixtures achieved 9/9 golden parity and judged the spec sufficient for fully independent implementation (issuer-side determinism included). Review focus: schema soundness, signing/verification code paths, the normative check orders vs implementation, fixture coverage. Merge follows only on clean review + named founder YES; v0.1 tag is a separate founder act.

@jamesfgibbons jamesfgibbons left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field Marshal truth-review (COMMENT, not approve-to-merge). Head 571fcfe.

HOLDS. Independent run in /tmp: tests.test_claim_gate 24/24; full suite 83/83 OK with constitutional-cms[claims] on PATH.

Confirmed on the branch, not from the PR body:

  • prose + schemas + goldens are the external law
  • receipt_hash omits superseded / superseded_by
  • bundle_mismatch recomputes the presented bundle core and ignores the stated field
  • expiry is inclusive (as_of >= valid_until) with a boundary golden
  • as_of is an input; verified_at is that clock
  • all-UNMEASURED is never a green receipt
  • HTTP GET /claims / POST /verify remains spec-only
  • fixture PEMs are TEST ONLY and documented as protecting nothing

Chair still owns YES + tag. Codex review is still empty on this PR. Do not publish as 0.5.0 (pyproject.toml is still that number; CHANGELOG correctly says v0.6.0-dev).

Packet: /tmp/field-marshal-claim-gate-34-20260822.md.

@jamesfgibbons
jamesfgibbons marked this pull request as ready for review August 23, 2026 13:51
jamesfgibbons and others added 7 commits August 28, 2026 15:52
…tion (DRAFT)

Two new portable objects behind one primitive: a ClaimBundleV0_1 is the
frozen, hashed, Ed25519-signed core (volatile facts forbidden inside);
a ClaimReceiptV0_1 is the volatile verification record that quotes
bundle_hash. Receipts attest integrity + policy conformance, never truth.

- schemas/claim_bundle_v0_1.schema.json + claim_receipt_v0_1.schema.json
  (additionalProperties: false; sha256:-prefixed outer hashes; house
  timestamps generated_at/valid_until, RFC 3339 UTC Z)
- constitutional_cms/claims.py: build_bundle / sign_bundle / verify_bundle /
  verify_receipt (+ generate_keypair, supersede_receipt); canonicalization
  reused from evaluator (one code path); cryptography behind the new
  optional extra "claims" with a clear import-guard error
- CLI: claim-bundle / claim-sign / claim-verify, fully offline against a
  local keys file; exit 0 verified, 1 refused (typed), 2 operational
- docs/CLAIM_GATE.md: five-minute boundary spec — signing, expiry law
  (earliest-expiry-governs, verifier-enforced), never-overwrite
  supersession, keys.json convention (spec only), normative-future HTTP
  surface, strict-consumer contract
- Technical constitution: tests/test_claim_gate.py + byte-stable golden
  pairs in tests/golden-claims/ signed by TEST ONLY fixture keys,
  including an independent-reproduction path
- CI installs the claims extra so the constitution runs; lean installs
  skip crypto clauses honestly

DRAFT until founder ratification (v0.6.0-dev in CHANGELOG; no tag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…text

Clean-room implementer (CLAIM_GATE.md + CANONICAL_JSON.md + fixtures only)
achieved 8/8 golden parity but recorded 8 spec gaps, 4 divergence-capable.
All closed in docs/CLAIM_GATE.md; reference implementation aligned:

- GAP-1: receipt_id derivation written exactly (cr_ + sha256(canonical
  {"bundle_hash","verified_at","evaluator_version"})[:16])
- GAP-2: as_of is an explicit input; verified_at IS the reported clock
- GAP-3: normative unmeasured-value encoding (object with "verdict" in
  {UNMEASURED, NOT_APPLICABLE}; everything else is measured)
- GAP-4: normative refusal order 1-8; first failing check's code; a
  different order is non-conformant
- GAP-5: both directions of valid_until inequality are expiry_mismatch
- GAP-6: inclusive expiry boundary (expired when as_of >= valid_until);
  code changed > to >= for bundles and receipts; new boundary golden
  pair expired_boundary.* + named inclusivity test
- GAP-7: refused-receipt valid_until rule written as prose (min() with
  parseable bundle valid_until, clamped to verified_at)
- GAP-8: bundle_mismatch trigger = recomputed hash of the presented
  bundle's core bytes; stated bundle_hash field ignored (code changed
  from stated-field comparison to recompute; test extended)

83/83 tests OK; 14 pre-existing goldens byte-identical after regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ law-source inversion

- Normative receipt-verification order written (what the code does):
  receipt_malformed -> receipt_hash_mismatch -> bundle_mismatch (when a
  bundle is presented) -> receipt_superseded -> receipt_expired ->
  receipt_verified; first-failure single code; steps 4-5 skipped for the
  historical question. Same conformance language as the bundle order.
  Code already matched; no code change.
- Law source inverted for external implementers: the PROSE + schemas +
  golden fixtures are the law; tests/test_claim_gate.py is the reference
  implementation's internal enforcement, not a normative source.

24/24 claim-gate tests OK; full suite 83/83 OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round 1

Three independent reviewers ran live exploits against this branch. Every
finding below was corroborated by at least two of them, and every fix carries
a test that fails against the pre-fix code (verified by stashing the fix).

BLOCKING-1 — earliest-expiry-governs compared STRINGS, not instants.
min() and != ran over RFC 3339 text. The schema permits fractional seconds and
'.' (0x2E) sorts before 'Z' (0x5A), so lexical order is not chronological.
Two wrong verdicts, both demonstrated: a bundle whose valid_until was LATER
than its earliest claim VERIFIED, and a law-correct bundle spelling one instant
two ways was REFUSED. Now parsed to instants and compared as instants in
build_bundle and verify_bundle alike; the spec says so normatively.

BLOCKING-2 — the issuer was never bound to the signing key.
verify_bundle looked up the key by key_id alone and never checked
bundle["issuer"] against anything, so example.com's own key signed a bundle
claiming issuer victim-bank.example and it verified green against example.com's
keys.json. The keys document now declares its issuer, is validated against a
new ClaimKeysV0_1 schema, and a mismatch is refused with the new typed code
issuer_mismatch — normative order step 4, right after key lookup.

BLOCKING-3 — duplicate key_id let document ORDER pick the verdict.
First-match lookup meant an appended impostor entry sharing a legitimate key_id
flipped every legitimate bundle (placed first) or got the attacker's accepted
(placed last). A keys document containing a duplicate key_id is now refused
fail-closed as an operational error (exit 2) — documented as operational
because the keys file is verifier configuration, not the artifact under test.

BLOCKING-4 — duplicate/contradictory claim_id verified green.
One claim_id carrying both {"verdict":"PASS"} and {"verdict":"FAIL"} passed;
consumers key by claim_id and silently got last-wins. Now bundle_malformed,
enforced in code because JSON Schema cannot express it.

BLOCKING-5 — uncaught exceptions escaped as exit 1, the documented code for a
TYPED REFUSAL, with tracebacks. verify_bundle and verify_receipt are now TOTAL
over artifacts: a malformed keys entry (AttributeError), a bare JSON string
presented as a bundle (AttributeError), --horizon-seconds 999999999999
(OverflowError), an uppercase-hex bundle_hash (ValueError from receipt schema
validation), an unhashable claim value (TypeError), and impossible or
leap-second timestamps (bare assert) all become typed refusals. The bare assert
is gone; format: date-time is not enforced here so timestamps are really
parse-validated; horizon_seconds is bounded to 10 years; the CLI exits 2 with
one line for anything unexpected and never prints a traceback.

Honesty fixes (v0.1 design limits, now stated rather than implied):
- Receipts are UNAUTHENTICATED. Receipt-only verification no longer prints
  VERIFIED; it prints RECEIPT INTEGRITY OK (unauthenticated — supply --bundle
  and --keys to verify the claim). New spec section "What a v0.1 receipt does
  NOT prove".
- Supersession is an ISSUER-SIDE assertion, not revocation: the annotation sits
  outside the hash by design, so a holder can strip it and the receipt verifies
  as current. A consumer needing current state MUST consult the issuer's
  receipt endpoint. Authenticated supersession/revocation deferred until earned.
- Key `status` was inert — a "retired" key signed fresh long-lived bundles.
  Removed from the fixture, the generator, and the spec; a keys entry carrying
  it is refused rather than silently ignored. Revocation in v0.1 is removal.
- Portability MUST-rejects: duplicate JSON object members (undefined in RFC
  8259, resolved differently by different parsers) refused at parse time; JSON
  floats and integers beyond 2^53-1 refused, because canonicalization collapses
  1.0 to 1 and emits huge literals no double-parsing implementation reproduces.

Also added tests for seven checks a deletion experiment showed had NO failing
test: bundle schema step, receipt schema step, receipt_expired, receipt expiry
inclusivity, verify_receipt's default current-state question, NOT_APPLICABLE in
the unmeasured set, and the quoted-hash guard. Each was re-verified by deleting
the check and confirming the new test fails.

sign_bundle now refuses to emit a bundle no conformant verifier will accept —
defence in depth, not a security control: the adversarial specimens are signed
with raw Ed25519, which is how an attacker signs.

Tests 83 -> 118, all passing. Goldens regenerate byte-identical; package-data
copies stay byte-identical to schemas/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GAP-9 (blocking) — computed-timestamp spelling was unspecified. The code
already normalized zero-fraction spellings (the exploit predates 9c19e68),
but nothing wrote the rule down, so an independent issuer could spell one
instant two ways and mint two receipt_ids. Normative rule written: UTC Z
always; fraction OMITTED when the microsecond field is zero, otherwise
EXACTLY six digits; normalization happens before the value enters
receipt_id or receipt_hash. Pinned by a test over five equivalent as_of
spellings (plus the six-digit fractional case).

MINOR-1 — "compared at full fractional precision" was not implementable
against a reference that arithmetics at microseconds. Bounded fractional
seconds to six digits in both schemas and the verifier grammar; a seventh
digit is now REFUSED, never truncated, so comparison resolution and
arithmetic resolution are the same microsecond and schema, prose, and code
agree.

MINOR-2 — float conflict with CANONICAL_JSON.md scoped explicitly: claim
bundles/receipts REFUSE a float; the older conformance artifacts still
COLLAPSE integral floats. Both statements stand; neither artifact reaches
the other's branch.

MINOR-3 — keys-document prose aligned to the schema: an optional free-text
comment is permitted at document level and never consulted; key ENTRIES
carry exactly key_id, alg, public_key.

MINOR-4 — duplicate key_id refusal labeled: operational, CLI exit 2, no
receipt, never a typed verdict about the bundle.

COVERAGE — new goldens pin issuer_mismatch, signature_invalid, and
bundle_malformed (signed attacker-style with raw Ed25519, since
sign_bundle refuses to emit them), plus a result-level
receipt_hash_mismatch artifact. A new test asserts every code in
CLAIM_REASON_CODES appears in some golden.

Tests 118 -> 123, all passing. All 23 goldens regenerate byte-identical;
no pre-existing golden changed; package-data copies stay byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 4 returned YES/YES (independent implementation of both verifier and
issuer, 23/23 golden parity). One residual remained: the six-digit cap
sentence named artifact codes, and an as_of is not an artifact, so the rule
did not reach verifier input.

Measured behavior of the reference today (pinned, not changed): an as_of
with more than six fractional digits fails the RFC 3339 grammar, so it is
an OPERATIONAL refusal — ClaimGateError, CLI exit 2, no receipt. It is
neither truncated nor rounded. The reviewer's measured truncation was in
their own implementation; this code already refuses.

Refusal is the only self-consistent choice: truncation maps
...00.1234567Z to ...00.123456Z and ...00.0000006Z to ...00Z, rounding maps
the latter to ...00.000001Z, and each mints a different verified_at ->
receipt_id -> receipt_hash. A caller holding a finer clock decides its own
rounding before calling, and owns that decision visibly.

Tests 123 -> 125. Goldens unchanged (23/23 byte-identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecycle

Add normative section to CLAIM_GATE.md clarifying the boundary between
Claim Gate verification (integrity + policy conformance) and entity
lifecycle authority (subject existence).

Key points:
- verify() / verify_bundle / verify_receipt attest that a claim MAY be
  expressed, never whether the subject still exists
- A green bundle/receipt about a retired/unknown/seasonal_inactive
  subject is ontology drift, not a verified lie
- Lifecycle authority is a different court; companion law is
  contracts/entity_lifecycle.yaml and docs/CONSUMING_LAYER.md
- Claim Gate MUST NOT consult entity_lifecycle.yaml
- Entity lifecycle MUST NOT become a claim reason code

No behavior change, no schema change, no golden rewrite. This is a
spec join documenting what Claim Gate does NOT prove.

Co-authored-by: James F Gibbons <james@jamesfgibbons.com>
@cursor
cursor Bot force-pushed the claim-gate/v0_1-bundle-receipt branch from e932e1e to 63a3793 Compare August 28, 2026 15:54
@jamesfgibbons
jamesfgibbons merged commit 7090437 into main Aug 28, 2026
2 checks passed
@jamesfgibbons
jamesfgibbons deleted the claim-gate/v0_1-bundle-receipt branch August 28, 2026 18:15
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.

2 participants