Skip to content

fix(spec): economics refuses, never throws, on a number the canonical form cannot write (D5, #359) - #569

Merged
LamaSu merged 4 commits into
masterfrom
fix/economics-d5-canonical-numbers
Oct 4, 2026
Merged

LamaSu merged 4 commits into
masterfrom
fix/economics-d5-canonical-numbers

Conversation

@LamaSu

@LamaSu LamaSu commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Economics refuses a number the canonical form cannot write, and never throws on one. This answers the semantic conflict between #359 (evidence's D5) and the merged #360. It works in either order with #359 and #566, and #359 merges after it (board v3.461, row N15).

The problem

Under D5 (#359, evidence profile v1 §1), canonicalize refuses two kinds of number: one that is not finite, and one whose magnitude is 2^53 or more. Hashing a body that holds one throws. Three economics paths hashed their input before checking it.

#359's CI. #359's red build-and-test (run 37157738930, job 111304576433) fails only economics-review.test.ts, in three "clean-room round 3b" tests. #566 fixes R8 only. Locally, master cd9d8770 + #359 725635b3 + #566 f1a0d6ff still fails those three.

Product. These were traced on master + #359 + #566 and compared with master.

compileEconomics threw NonCanonicalValueError out of the compiler. Each schedule in options.schedules is hashed inside the options check (compile.ts:89), before rule 5 runs. Each input below was a refusal or compiled before D5:

Input Before D5 Under D5
thresholdCents 2^53 SCHEMA_INVALID throws
scale 1e16 compiles throws
scale 2^53 compiles throws
decayPerSecond 1e21 RATE_UNVERIFIED throws
scale Infinity SCHEMA_INVALID throws

clausesFromCompositionManifest threw because it hashes the manifest before any check:

  • groupBps 2^53 was GROUP_WEIGHTS_INVALID before D5;
  • an unknown field holding 1e21 was accepted before D5.

splitsFromTrainingManifest threw because the schema leaves dataPointCount unbounded: 2^53 was accepted before D5. These adapters are called from contracts' licensing-engine.ts and payouts.ts.

Not affected. The agreement, license and kit hashes are safe: every economics field is required and safe-bounded, with no optional members. bind.ts parses server facts before it canonicalizes them. The gateway's publish route runs rule 5 and then a guarded hash, so it answers 400. The dashboard's publish page hashes inside try blocks.

The fix

Every change holds with or without D5 in canonicalize.

  • Rule 5 (assertScheduleIsWellFormed): scale and decayPerSecond must be finite and have magnitude at most 2^53−1, which is D5's number domain. A larger real is an integer outside the safe range, so it has no canonical form. The registry's publish route uses the same check.
  • Compiler: the schedule hash in the options check is guarded. A body with no canonical form is a SCHEMA_INVALID options refusal.
  • Adapters: numberWithoutCanonicalForm (economics/input.ts) refuses such a manifest before it is hashed (MANIFEST_INVALID), and the hash itself is guarded.
    • The helper reads numbers only and invokes no accessor.
    • A cycle, a Proxy trap, or a structure past the input bounds is left to the guarded hash.
  • Docs: ECONOMIC_AGREEMENTS.md, rule 5 and §8.

Tests

  • Round 3b's sealed() uses a stand-in hash when the body has none, because the compiler must refuse whatever hash it carries.
  • The toString test keeps only the reals that both forms write the same way.
  • Two new round-3b tests:
    • A real of magnitude 2^53 or more is refused by rule 5 and by the compiler, and 2^53−1 still compiles.
    • Every schedule number without a canonical form is an options refusal, never a throw.
  • A new file, economics-d5.test.ts, mocks each hash to throw the way D5's does, so the guards are exercised in either order. It also covers the adapters' number check and the helper.
Tree Spec result
Fix alone 2168/2168, tsc clean
Fix + #359 + #566 2355/2355, tsc clean; merging fix → #566 → #359 gives an identical tree
master, without the fix the new tests fail 4 of 73
master + #359 + #566, without the fix the new tests fail 7 of 73
  • Gateway: contributors and money-path, 51/51.
  • Contracts: licensing-engine, immutability, integration and abi, 64/64.
  • Mutations: 6 of 6 caught. The mutants were the compile guard dropped, the rule-5 bound dropped, the rule-5 bound made too strict at 2^53−1, the adapters' check dropped, the adapters' guard dropped, and the helper's bound made too strict.

🤖 Generated with Claude Code

LamaSu and others added 4 commits October 3, 2026 17:58
… form cannot write (D5)

Under the evidence profile's D5 (#359), canonicalize refuses a number that is not finite or whose magnitude is 2^53 or more, so hashing a body that holds one throws. Three economics paths hashed such input before checking it, and threw out where they used to refuse:
- compileEconomics hashed each options schedule inside the options check, before rule 5. With thresholdCents 2^53, scale 1e16 or 2^53, decayPerSecond 1e21, or scale Infinity it threw NonCanonicalValueError.
- clausesFromCompositionManifest hashed the manifest before any check. With groupBps 2^53 or an unknown field of 1e21 it threw.
- splitsFromTrainingManifest: the schema leaves dataPointCount unbounded, so 2^53 threw in the hash.

The fix holds with or without D5, so it merges in either order with #359:
- rule 5: scale and decayPerSecond must also have magnitude at most 2^53-1, D5's number domain. A larger real is an integer outside the safe range, which has no canonical form.
- compile: the schedule hash is guarded. A body with no canonical form is a SCHEMA_INVALID options refusal.
- adapters: numberWithoutCanonicalForm refuses such a manifest before it is hashed (MANIFEST_INVALID), and the hash itself is guarded.
- tests:
  - round 3b's sealed() uses a stand-in hash when the body has none;
  - the toString test keeps only reals that both forms write;
  - new tests make the hash throw as D5's does, so the guards are exercised in either order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ID for numbers with no canonical form (D5)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s, hashes and pays that copy (astra EC5)

Each finding below was reproduced at 2832a1c first, with the new tests in "astra EC5" (economics-d5.test.ts) and a trace.

- H1. The composition adapter hashed manifest.entries and then read it again to pay. An entries getter that returned Alice's entries on the first read and Bob's on the second gave ok: true with a clause paying bob under Alice's hash. The training adapter had the same flaw: a datasets getter paid d2 under ds:1's hash.
- M1. Two throw paths sat outside the guard:
  - a manifestHash of null threw a TypeError at .toLowerCase();
  - a throwing datasets getter escaped TrainingManifestSchema.safeParse.
- M2. The number check stopped silently at depth 64, so behavior differed with and without D5. 65 nested objects ending in 2^53 converted before D5 and were refused under it.

The fix:
- snapshotJson takes { canonical: true }. It copies only what the canonical form writes: an undefined object member is dropped, as canonicalize drops it, and a number that is not finite or above 2^53-1 is refused. The compiler's default is unchanged. numberWithoutCanonicalForm is removed.
- All three adapters copy their whole input once. An accessor (never called), a cycle, nesting past 64, a throwing proxy trap or such a number is INPUT_INVALID (GRAPH_INVALID for a graph), the same with or without D5.
- Composition checks the copy against CompositionManifestSchema plus the input fields, so manifestHash null is MANIFEST_INVALID. It hashes the copy as written and pays only the parsed copy.
- Training checks each lineage level's shape before use, then schema-checks, hashes and pays the copy.
- The graph adapter also reads usedComponents once, through Set.prototype.values, and refuses anything that is not a Set (INPUT_INVALID). Each adapter refuses an idPrefix that is not a string.
- The L7 test now expects MANIFEST_INVALID for the "__proto__" address, because the schema refuses it before any lookup. A real address missing from the table is still UNKNOWN_CONTRIBUTOR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…en they refuse, and that they do not throw (astra EC5)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LamaSu
LamaSu merged commit bdd6a3f into master Oct 4, 2026
9 checks passed
LamaSu added a commit that referenced this pull request Oct 4, 2026
…ter went green)

No conflict: the tree equals git merge-tree(108c778, 13f3dc8). EC3c confirmed the barrel union at 13f3dc8; nothing in #394 changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 4, 2026
…ll CI run (steward #6340)

A plain merge: its tree equals git merge-tree of 725635b and master. No economics file is edited;
economics' own #569 fixed D5 there (#6244).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
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