diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19b5ddc..619d4666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,23 @@ jobs: if: steps.subm.outputs.present == 'true' run: ./t court --live | jq -e '.law_bridge.omega_native != null and .law_bridge.trinity_witnessed != null and .law_bridge.consistent == true' + # With the submodule present the ActionIntent commitment must be verified by + # RUNNING both implementations, not by comparing two constants. Trinity + # vendors MYC's contract byte-for-byte; a vendored copy that drifted while + # both suites kept the same expected string would otherwise pass. + - name: Verify ActionIntent cross-substrate parity exercised + if: steps.subm.outputs.present == 'true' + run: | + out=$(deno test --allow-all src/action_intent_parity_test.ts 2>&1) + echo "$out" + # The live cases skip themselves when the submodule is absent; here it + # is present, so a SKIPPED line means the gate passed vacuously. + if echo "$out" | grep -q "SKIPPED"; then + echo "::error::parity skipped while submodules are present" + exit 1 + fi + echo "$out" | grep -q "4 passed" + - name: Verify recipes detail lookup run: ./t recipes show recipe.metacognition.field-cycle --json | jq -e '.type == "recipes" and .position == "3/C" and .action == "show" and .recipes[0].id == "recipe.metacognition.field-cycle"' diff --git a/conformance/cnp-0-jcs-v0/MANIFEST.sha256 b/conformance/cnp-0-jcs-v0/MANIFEST.sha256 index c39a6e6a..fd4d802a 100644 --- a/conformance/cnp-0-jcs-v0/MANIFEST.sha256 +++ b/conformance/cnp-0-jcs-v0/MANIFEST.sha256 @@ -1,7 +1,7 @@ bfdc30247c4c399f9b9ab6ccd04c9de56785d5850f881b48064a1a69a8c6aa3d CONTRACT.md b040b943c3cd4b13e1e18fd776b8ceef98c43272ef264344b7d2cb715ad7c172 INTERFACE.md b19a92a73cbec859a853e4ed12c11dde7ab9c77269e311e16a4d901d9ba12e52 README.md -4f289ee10fcb3e5698b8823af6241291b34b326406354a40d79708371b39ab0d SPEC-EXTRACT.md +941acc21d2c1c59e928733e401c2083dc9ebebe1adcb72df6b745114a67a2d6a SPEC-EXTRACT.md 17aaf7ed6d92040b0d1c4b88f4157912c95029368b6d28e56f2144c103082e5a corpus/extended.ndjson dde58a183fa746900cc82de3f1935a09177483297ab16a650c2cba25c07e9e19 corpus/required.ndjson 7c7fee476b75768345e882c27bc24066a7f3896472de3b3023750c8221248041 run_conformance.py diff --git a/conformance/cnp-0-jcs-v0/SPEC-EXTRACT.md b/conformance/cnp-0-jcs-v0/SPEC-EXTRACT.md index 127ba540..ac7d8db2 100644 --- a/conformance/cnp-0-jcs-v0/SPEC-EXTRACT.md +++ b/conformance/cnp-0-jcs-v0/SPEC-EXTRACT.md @@ -13,7 +13,7 @@ RFC, not so it can replace it. ## §5.1.1 — Canonical encoding is normative, not an implementation detail - + The encoding MUST satisfy: @@ -43,7 +43,7 @@ The encoding MUST satisfy: ## §5.1.2 — Floating point - + In canonical form: @@ -72,7 +72,7 @@ In canonical form: ## §5.1.2 — Non-integer values inside an integers-only domain - + Two patterns are admissible. Both keep every number in the integer domain and both are exact. Each is a **tagged form**, carrying the reserved discriminator @@ -117,7 +117,7 @@ independent implementations will agree on. ## §5.1.2.1 — CNP-0-JCS: the ratified selection - + ##### 5.1.2.1 CNP-0-JCS: the ratified selection @@ -190,7 +190,7 @@ the path does not do and not what it is given. ## §5.1.2.2 — Fixed-point scale identity - + A fixed-point domain MUST bind one content-addressed scale descriptor of this shape: @@ -223,7 +223,7 @@ simplex, `Σ value_i` MUST equal `radix^places` exactly. ## §5.1.3 — Parity is proven, not assumed - + #### 5.1.3 Parity is proven, not assumed @@ -294,9 +294,13 @@ That list is the whole of A3. Two further states sit outside it, and neither blocks it, because ratifying a specification, running it, and having someone else run it are three different acts: -**Adoption-evidenced** — at least one substrate computing real references under -`hsp-jcs@v0` on a path that matters. Until it holds, no document may claim the -encoding is in use. +**Adoption-evidenced — TRUE since 2026-08-27.** `ActionIntent.intentCommitment` +computes its commitment over CNP-0-JCS canonical bytes in both Trinity +(`src/x5E10_warrant.ts`) and MYC (`myc/src/x5820_action_intent.ts`), and that +commitment is the authority gate: `actionBoundAuthority` permits actuation only +when a committed proposal's `action_grant.intent_commitment` equals it exactly. +A live end-to-end test authorizes the proposal MYC actually wrote to disk. This +is one path, not the whole substrate, and it is named rather than generalised. **Interop-confirmed** — at least two independently _maintained_ implementations, or adoption by a party outside this project, with parity evidence in both @@ -310,12 +314,14 @@ party _has_ verified, and the difference is exactly what this level names. That is a statement about the specification, not about the world it is meant to run in. The honest status is: -> **A3: RATIFIED. adoption-evidenced: false. interop-confirmed: false.** +> **A3: RATIFIED. adoption-evidenced: true (one authority path — ActionIntent). +> interop-confirmed: false.** -Anything depending on cross-substrate reference _equality_ still depends on -`adoption-evidenced`, which is false: no substrate computes references under -`hsp-jcs@v0` today. Ratification removed the specification-side blocker and -nothing else. +One authority path computes real references under `hsp-jcs@v0`; nothing else in +either substrate does. `adoption-evidenced` says the encoding is in use, not +that it is used everywhere, and no document may read it as the latter. +`interop-confirmed` remains **false**: both implementations are under one +maintainer, which is exactly what that level exists to distinguish. diff --git a/docs/rfc/0003-heterogeneous-state-protocol/00-architecture-and-ratification-map.md b/docs/rfc/0003-heterogeneous-state-protocol/00-architecture-and-ratification-map.md index ac558054..f40ad4c0 100644 --- a/docs/rfc/0003-heterogeneous-state-protocol/00-architecture-and-ratification-map.md +++ b/docs/rfc/0003-heterogeneous-state-protocol/00-architecture-and-ratification-map.md @@ -53,18 +53,19 @@ on 2026-08-26** (Part 01 §5.1.3, Part 07 §17): the contract, the CNP-0 corpus, reference encoder, a verifier-only rejection path sharing no code with it, and a standalone conformance kit all exist, and the steward closed the tranche. -**Three independent states, and only the first has changed:** +**Three independent states; the first two have now changed:** | state | value | what it licenses | | ---------------------- | ------------ | ----------------------------------------------------------------------------------- | | **A3** | **RATIFIED** | the encoding is determined and checkable | -| **adoption-evidenced** | **false** | nothing may claim the encoding is in use | +| **adoption-evidenced** | **true** | the named ActionIntent authority path may claim the encoding is in use | | **interop-confirmed** | **false** | nothing may claim independent interoperability or multi-implementation confirmation | -Anything depending on cross-substrate reference _equality_ still depends on -`adoption-evidenced`, which is false: no substrate computes references under -`hsp-jcs@v0` today. Ratification removed the specification-side blocker and -nothing else, and A3 being closed moves neither of the other two. +`adoption-evidenced` became **true** on 2026-08-27: +`ActionIntent.intentCommitment` computes over CNP-0-JCS canonical bytes in both +Trinity and MYC, and that value gates actuation. It says the encoding is in use +on one named path, not that it is used everywhere. `interop-confirmed` stays +**false** — both implementations are under one maintainer. **Substrate adoption was never a ratification condition.** It is its own state, `adoption-evidenced`, for the same reason the second encoder stopped being one: @@ -710,7 +711,8 @@ rather than "proved" for that reason. with it, a standalone conformance kit, and steward ratification. All six exist, and **the steward ratified A3 on 2026-08-26** — see Part 07 §17 for the statement. Federation adoption was **not** among them and remains tracked - separately as `adoption-evidenced`, which is false. + separately as `adoption-evidenced`, which became true for the named + ActionIntent authority path on 2026-08-27. **Restated 2026-08-26: two levels, not one.** This entry previously required **two independent encoders** for A3. That conflated four separable things — @@ -793,9 +795,9 @@ specification, not the world it runs in. Specifically: 2. **Ratification is not adoption authority.** Part 01 selects CNP-0-JCS and the steward ratified it. That does not make Warrant the owner of federation encoding, import its governance, or bind substrates that have not adopted and - implemented the contract. `adoption-evidenced` is **false**: no substrate - computes references under `hsp-jcs@v0` today, and A3 being closed does not - change that. + implemented the contract. `adoption-evidenced` is **true only for the named + ActionIntent authority path**: it does not license a claim that either + substrate generally computes references under `hsp-jcs@v0`. 3. **Version pinning is mandatory if adopted.** `ski@v1` names Book I v0.5 specifically, and `GOV-ANCHORS` pins its dependencies by content hash for the stated reason that a STANDARD must not rest on a moving target. Any adoption @@ -1474,10 +1476,10 @@ where a reference resolves: raw input and non-canonical ratios, and a reproducible conformance kit all exist, and the steward ratified the tranche. This settles the specification; it settles neither of the two states below. -- **Adoption-evidenced: false** (a state, not a tranche, and never a blocker on - A3). At least one substrate computing real references under `hsp-jcs@v0` on a - path that matters. Until it holds, no document may claim the encoding is in - use. +- **Adoption-evidenced: true** since 2026-08-27 (a state, not a tranche, and + never a blocker on A3). The named ActionIntent authority path computes real + references under `hsp-jcs@v0`; this licenses that bounded adoption claim and + no substrate-wide one. - **Interop-confirmed: false** (a level above both, and never a blocker on either). Two independently maintained implementations, or adoption by a party outside this project, with parity evidence in both directions. Until it holds, @@ -1488,10 +1490,10 @@ where a reference resolves: content-addressed principal and custody records, and fail unresolved or shared custody closed against double-counting (§22.1). -Tranche A is a prerequisite for every other tranche. Until A2 and A3 land, -§5.1's guarantees do not hold across substrate boundaries and nothing that -depends on cross-substrate reference equality should be claimed. A draft choice -reduces design uncertainty; it does not weaken this gate. +Tranche A is a prerequisite for every other tranche. A2 and A3 have landed, but +their guarantees extend only as far as the separately reported adoption and +interoperability evidence. The named ActionIntent path supplies bounded adoption +evidence; independent interoperability remains unconfirmed. ### Tranche B — State-domain typing (depends on A) @@ -1615,9 +1617,11 @@ What matters going forward rather than backward: 1. **Ratification is per tranche, per document.** A ratified tranche constrains something. An unratified complete specification constrains nothing while looking as though it does, which is the more dangerous of the two states. -2. **Part 01 still blocks conformance.** Its draft encoding is selected; the - missing contract, corpus, independent implementations, rejection path, and - adoption keep every later part non-conforming across substrate boundaries. +2. **Part 01 no longer blocks conformance.** Its encoding is ratified and its + contract, corpus, rejection path, conformance kit, and one named adoption + path now exist. Independent interoperability remains unconfirmed and + therefore cannot be claimed, but it is not a prerequisite for A3 or bounded + adoption. 3. **The first post-erratum artifact is code, not another prose-only revision.** `probes/hsp-fast-path-debt-scope-v0` makes the debt-locality term of §15.0 executable with eleven fail-closed tests and a local benchmark. It is a diff --git a/docs/rfc/0003-heterogeneous-state-protocol/01-canonical-identity-and-encoding.md b/docs/rfc/0003-heterogeneous-state-protocol/01-canonical-identity-and-encoding.md index e9d24846..678956af 100644 --- a/docs/rfc/0003-heterogeneous-state-protocol/01-canonical-identity-and-encoding.md +++ b/docs/rfc/0003-heterogeneous-state-protocol/01-canonical-identity-and-encoding.md @@ -493,9 +493,13 @@ That list is the whole of A3. Two further states sit outside it, and neither blocks it, because ratifying a specification, running it, and having someone else run it are three different acts: -**Adoption-evidenced** — at least one substrate computing real references under -`hsp-jcs@v0` on a path that matters. Until it holds, no document may claim the -encoding is in use. +**Adoption-evidenced — TRUE since 2026-08-27.** `ActionIntent.intentCommitment` +computes its commitment over CNP-0-JCS canonical bytes in both Trinity +(`src/x5E10_warrant.ts`) and MYC (`myc/src/x5820_action_intent.ts`), and that +commitment is the authority gate: `actionBoundAuthority` permits actuation only +when a committed proposal's `action_grant.intent_commitment` equals it exactly. +A live end-to-end test authorizes the proposal MYC actually wrote to disk. This +is one path, not the whole substrate, and it is named rather than generalised. **Interop-confirmed** — at least two independently _maintained_ implementations, or adoption by a party outside this project, with parity evidence in both @@ -509,12 +513,14 @@ party _has_ verified, and the difference is exactly what this level names. That is a statement about the specification, not about the world it is meant to run in. The honest status is: -> **A3: RATIFIED. adoption-evidenced: false. interop-confirmed: false.** +> **A3: RATIFIED. adoption-evidenced: true (one authority path — ActionIntent). +> interop-confirmed: false.** -Anything depending on cross-substrate reference _equality_ still depends on -`adoption-evidenced`, which is false: no substrate computes references under -`hsp-jcs@v0` today. Ratification removed the specification-side blocker and -nothing else. +One authority path computes real references under `hsp-jcs@v0`; nothing else in +either substrate does. `adoption-evidenced` says the encoding is in use, not +that it is used everywhere, and no document may read it as the latter. +`interop-confirmed` remains **false**: both implementations are under one +maintainer, which is exactly what that level exists to distinguish. #### 5.1.4 The selection is narrower than it looks diff --git a/docs/rfc/0003-heterogeneous-state-protocol/07-revision-history.md b/docs/rfc/0003-heterogeneous-state-protocol/07-revision-history.md index 1c83f9ab..c307026d 100644 --- a/docs/rfc/0003-heterogeneous-state-protocol/07-revision-history.md +++ b/docs/rfc/0003-heterogeneous-state-protocol/07-revision-history.md @@ -1175,3 +1175,66 @@ correction is here rather than in the verdict, which stays untouched. None of this changes what the probe produced, which was nothing, or what it is evidence of, which is nothing. + +## 18. adoption-evidenced becomes true on one named path (2026-08-27) + +`ActionIntent.intentCommitment` computes its commitment over CNP-0-JCS canonical +bytes in both substrates. This is the first path in either to compute a real +`hsp-jcs@v0` reference rather than an ad-hoc stable stringification. + + {"args_commitment":"c1","canonical_encoding":"hsp-jcs@v0", + "input_commitments":["a","b"],"numeric_profile":"cnp-0", + "requested_effects":["receipt","write"],"target_substrate":"myc", + "verb":"apply"} + → ccc26b8b460fe2debf0ad069d55ec170a78b7b70861f1f54c03e401e4576c3be + +Chosen because it is an authority gate rather than a convenient hash: +`actionBoundAuthority` permits actuation only when a committed proposal's +`action_grant.intent_commitment` equals this value exactly. + +### What the state now says, and what it does not + +**`adoption-evidenced: true`** means the encoding is in use on **one named +path**. It does not mean the substrates have adopted it generally: the +proposal-body digest in the same file still uses the old stringification, and so +do the other `stable()` copies. Nothing may read this state as "Trinity computes +references under `hsp-jcs@v0`". + +**`interop-confirmed` remains false.** Both implementations are under one +maintainer, which is exactly what that level exists to distinguish. Trinity +vendors MYC's contract byte-for-byte; agreement between them is internal +consistency, not independent interoperability. + +**A3 is unaffected.** It was ratified on 2026-08-26 and this changes nothing +about it. Adoption is a separate state precisely so that running a specification +and ratifying it stay distinguishable. + +### The implementation evidence + +- both boundaries parse **raw bytes** strictly — UTF-8 decoded fatally, + duplicate member names detected before `JSON.parse` collapses them last-wins, + including escape-equivalent spellings such as `"verb"`; +- one normalization reads every property exactly once into owned copies, and the + encoder uses only that snapshot; +- a **live** parity test executes both implementations and compares canonical + text, canonical bytes and digests across eight divergence-prone inputs; +- a **live end-to-end** test authorizes the proposal MYC actually wrote to disk, + rather than a descriptor built in a test; +- the canonical bytes are judged canonical by the ratified verifier-only path, + which shares no code with the encoder. + +### How this was found to be wrong three times first + +The reviewer's executed attacks, each of which produced a real commitment or a +written proposal before it was closed: a duplicate `verb` accepted last-wins; an +escape-equivalent duplicate; a `0xff` byte becoming U+FFFD; a getter answering +the validator and the encoder differently; and an encoder that accepted +`requested_effects: [1]` while its own comment said it refused everything +outside the domain. + +The first three are the rejection classes §5.1.1 rule 4 exists for, and this +project's own conformance kit documents them as "properties of the input bytes +that a permissive JSON parser resolves before you can see them" — while the +authority boundary used a permissive parser. Adoption is recorded here together +with that, because a state that records only the success is a state that teaches +nothing. diff --git a/myc b/myc index de1f8a93..b1e94b03 160000 --- a/myc +++ b/myc @@ -1 +1 @@ -Subproject commit de1f8a93efeadb667f9f56ad409d4b9b42346a99 +Subproject commit b1e94b03df9d0a34df693380b6bdeea9b970e2dc diff --git a/src/action_intent_parity_test.ts b/src/action_intent_parity_test.ts new file mode 100644 index 00000000..8a891f8b --- /dev/null +++ b/src/action_intent_parity_test.ts @@ -0,0 +1,354 @@ +// LIVE cross-substrate parity for the ActionIntent commitment. +// +// Trinity vendors MYC's contract byte-for-byte because it cannot static-import +// the submodule under CI decoupling. Until now the only thing holding the two +// copies together was a known-answer constant written into each suite - which +// proves the two CONSTANTS match and says nothing about whether the two +// implementations do. A vendored copy that drifted while both files kept the +// same expected string would pass. +// +// So when the submodule is present, this executes BOTH implementations over the +// same inputs and compares their canonical BYTES first, then their digests. +// Bytes first on purpose: two implementations agreeing on a wrong encoding +// produce one matching digest and no evidence about either. +// +// When the submodule is absent - Trinity's core CI runs without it - the live +// cases report SKIPPED with a reason and nothing is asserted about MYC. A +// skipped parity check is never counted as a passing one. + +import { assert, assertEquals } from "@std/assert"; +import { actionBoundAuthority } from "./x5E10_warrant.ts"; +import { + canonicalIntentBytes as trinityBytes, + canonicalIntentText as trinityText, + intentCommitment as trinityCommitment, + parseActionIntentBytes as trinityParse, + validateIntent as trinityValidate, +} from "./x5E10_warrant.ts"; + +const MYC_CONTRACT = new URL( + "../myc/src/x5820_action_intent.ts", + import.meta.url, +); + +type MycModule = { + canonicalIntentText: (i: unknown) => string; + canonicalIntentBytes: (i: unknown) => Uint8Array; + parseActionIntentBytes: (b: Uint8Array) => { ok: boolean; error?: string }; + intentCommitment: (i: unknown) => Promise; + validateIntent: (v: unknown) => { ok: boolean; error?: string }; +}; + +async function loadMyc(): Promise { + try { + await Deno.stat(MYC_CONTRACT); + } catch { + return null; // submodule not checked out: core CI stays self-sufficient + } + return await import(MYC_CONTRACT.href) as MycModule; +} + +const VECTOR = { + verb: "apply", + target_substrate: "myc" as const, + args_commitment: "c1", + input_commitments: ["a", "b"], + requested_effects: ["receipt", "write"], +}; +const EXPECTED_CANONICAL = + '{"args_commitment":"c1","canonical_encoding":"hsp-jcs@v0",' + + '"input_commitments":["a","b"],"numeric_profile":"cnp-0",' + + '"requested_effects":["receipt","write"],"target_substrate":"myc",' + + '"verb":"apply"}'; +const EXPECTED = + "ccc26b8b460fe2debf0ad069d55ec170a78b7b70861f1f54c03e401e4576c3be"; +const SUPERSEDED = + "d02d75adca7e0dbbd10244c7ea1e9aeafa7b6d019a0f570bcad471a38d997552"; + +// Inputs chosen for where two implementations could plausibly diverge, not to +// re-assert the happy path: astral scalars (UTF-16 vs code-point ordering), +// duplicate effects (set semantics), permutations, empty arrays, escapes. +const ASTRAL = "\u{1D11E}-clef-\u{1F600}"; +const CASES: [string, typeof VECTOR][] = [ + ["the shared vector", VECTOR], + ["effects permuted", { ...VECTOR, requested_effects: ["write", "receipt"] }], + ["effects duplicated", { + ...VECTOR, + requested_effects: ["write", "write", "receipt"], + }], + ["inputs permuted", { ...VECTOR, input_commitments: ["b", "a"] }], + ["empty arrays", { ...VECTOR, input_commitments: [], requested_effects: [] }], + ["astral scalars", { ...VECTOR, args_commitment: ASTRAL }], + ["BMP non-ascii", { + ...VECTOR, + verb: "\u0437\u0430\u0441\u0442\u043e\u0441\u0443\u0432\u0430\u0442\u0438", + }], + ["escapes", { ...VECTOR, args_commitment: 'a"b\\cd' }], +]; + +Deno.test("action_intent parity - LIVE: both implementations, same canonical bytes", async () => { + const myc = await loadMyc(); + if (!myc) { + console.log( + " SKIPPED: myc submodule is not checked out; no parity claim is made", + ); + return; + } + for (const [name, intent] of CASES) { + assertEquals( + myc.canonicalIntentText(intent), + trinityText(intent), + name + ": canonical TEXT differs across substrates", + ); + assertEquals( + Array.from(myc.canonicalIntentBytes(intent)), + Array.from(trinityBytes(intent)), + name + ": canonical BYTES differ across substrates", + ); + assertEquals( + await myc.intentCommitment(intent), + await trinityCommitment(intent), + name + ": digest differs across substrates", + ); + } +}); + +Deno.test("action_intent parity - LIVE: both refuse the same out-of-domain values", async () => { + const myc = await loadMyc(); + if (!myc) { + console.log(" SKIPPED: myc submodule is not checked out"); + return; + } + const bad: [string, unknown][] = [ + ["number in effects", { ...VECTOR, requested_effects: [1] }], + ["invalid substrate", { ...VECTOR, target_substrate: "mars" }], + ["empty verb", { ...VECTOR, verb: "" }], + ["extra member", { ...VECTOR, escalate: true }], + ["lone surrogate", { ...VECTOR, verb: "x\uD834y" }], + ]; + for (const [name, v] of bad) { + assertEquals( + myc.validateIntent(v).ok, + trinityValidate(v).ok, + name + ": the two boundaries disagree", + ); + assert(!trinityValidate(v).ok, name + ": trinity admitted it"); + for ( + const [who, fn] of [ + ["myc", myc.intentCommitment], + ["trinity", trinityCommitment], + ] as const + ) { + let threw = false; + try { + await (fn as (i: unknown) => Promise)(v); + } catch { + threw = true; + } + assert(threw, name + ": " + who + " produced a digest"); + } + } +}); + +Deno.test("action_intent parity - the pinned vector, without the submodule", async () => { + // Trinity's own half, so the vector is guarded even in core CI where the live + // comparison above is skipped. + assertEquals(trinityText(VECTOR), EXPECTED_CANONICAL); + assertEquals(await trinityCommitment(VECTOR), EXPECTED); + assert(await trinityCommitment(VECTOR) !== SUPERSEDED); +}); + +Deno.test("action_intent parity - a missing profile identifier moves the commitment", async () => { + // Both identifiers live INSIDE the hashed root (RFC-0003 5.1.2.1). If either + // can be dropped without moving the digest, they are decoration. + const text = trinityText(VECTOR); + for ( + const id of [ + '"canonical_encoding":"hsp-jcs@v0"', + '"numeric_profile":"cnp-0"', + ] + ) { + assert(text.includes(id), id + " is not in the hashed root"); + const without = text.replace(id + ",", ""); + assert(without !== text, "the replacement did not apply"); + const d = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(without), + ); + const hex = Array.from(new Uint8Array(d)).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); + assert(hex !== EXPECTED, "removing " + id + " left the digest unchanged"); + } +}); + +Deno.test("action_intent parity - LIVE E2E: Trinity authorizes the proposal MYC actually wrote", async () => { + // The gap this closes: MYC's own E2E proved storage, and Trinity's authority + // tests used a descriptor built in the test. Nothing fed the STORED proposal + // into the authority comparison, so the two halves could agree separately + // about different things. + const myc = await loadMyc(); + if (!myc) { + console.log(" SKIPPED: myc submodule is not checked out"); + return; + } + const { runCli } = await import( + new URL("../myc/src/x5800_propose.ts", import.meta.url).href + ) as { runCli: (a: string[]) => Promise }; + + const root = await Deno.makeTempDir({ prefix: "e2e_intent_" }); + try { + const intentPath = `${root}/intent.json`; + await Deno.writeTextFile(intentPath, JSON.stringify(VECTOR)); + + await runCli([ + "--root", + root, + "--proposal", + "cross-substrate adoption e2e", + "--requires", + "trinity", + "--proposer", + "claude", + "--action-intent", + intentPath, + "--json", + ]); + + // 2. Read what was actually written, not what a helper returned. + const dir = `${root}/public/proposals`; + const names: string[] = []; + for await (const e of Deno.readDir(dir)) if (e.isFile) names.push(e.name); + assertEquals(names.length, 1, `expected one proposal, got ${names}`); + const written = await Deno.readTextFile(`${dir}/${names[0]}`); + const body = JSON.parse( + written.match(/```json myc\s*\n([\s\S]*?)\n```/)![1], + ); + // Read it exactly where readProposal reads it: the grant lives inside the + // descriptor's BODY, and the commitment is `commitment.value`. Mirroring the + // production reader matters — a test that invented its own shape would pass + // while the real path found nothing. + const storedGrant = body.body?.action_grant?.intent_commitment as string; + assert( + storedGrant, + `the stored proposal carries no action_grant: ${written.slice(0, 200)}`, + ); + + // 3. Compute the intent through TRINITY, independently of what MYC stored. + const trinityIC = await trinityCommitment(VECTOR); + assertEquals( + storedGrant, + trinityIC, + "the stored grant is not what Trinity computes for the same intent", + ); + + // 4. Pass the STORED descriptor to Trinity's authority gate. + const descriptor = { + fqdn: String(body.fqdn), + commitment: String(body.commitment?.value ?? body.commitment), + action_grant: { intent_commitment: storedGrant }, + }; + const ok = actionBoundAuthority(trinityIC, descriptor, "implemented"); + assertEquals(ok.authorized, true, `denied: ${ok.reason_code} ${ok.reason}`); + + // 5. And the superseded commitment does not authorize the same proposal. + const denied = actionBoundAuthority(SUPERSEDED, descriptor, "implemented"); + assertEquals(denied.authorized, false); + assertEquals(denied.reason_code, "intent_mismatch"); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("action_intent parity - LIVE: both boundaries refuse duplicate names and invalid UTF-8 from raw bytes", async () => { + const myc = await loadMyc(); + if (!myc) { + console.log(" SKIPPED: myc submodule is not checked out"); + return; + } + const enc = (s: string) => new TextEncoder().encode(s); + const attacks: [string, Uint8Array, string][] = [ + [ + "duplicate member name", + enc( + '{"verb":"deny","verb":"apply","target_substrate":"myc",' + + '"args_commitment":"c1","input_commitments":["a"],' + + '"requested_effects":["write"]}', + ), + "duplicate-member-name", + ], + [ + "escaped duplicate name", + enc( + '{"verb":"deny","ve\\u0072b":"apply","target_substrate":"myc",' + + '"args_commitment":"c1","input_commitments":["a"],' + + '"requested_effects":["write"]}', + ), + "duplicate-member-name", + ], + [ + "invalid UTF-8", + new Uint8Array([ + ...enc('{"verb":"ap'), + 0xff, + ...enc( + 'ly","target_substrate":"myc","args_commitment":"c1",' + + '"input_commitments":["a"],"requested_effects":["write"]}', + ), + ]), + "invalid-utf8", + ], + ]; + for (const [name, bytes, expect] of attacks) { + const t = trinityParse(bytes); + const m = myc.parseActionIntentBytes(bytes); + assertEquals(t.ok, false, `${name}: trinity admitted it`); + assertEquals(m.ok, false, `${name}: myc admitted it`); + if (!t.ok) assert(t.error.includes(expect), `${name}: trinity: ${t.error}`); + if (!m.ok) { + assert(String(m.error).includes(expect), `${name}: myc: ${m.error}`); + } + } +}); + +Deno.test("action_intent parity - LIVE: a changing getter cannot reach canonical bytes on either side", async () => { + const myc = await loadMyc(); + if (!myc) { + console.log(" SKIPPED: myc submodule is not checked out"); + return; + } + const mk = (badFrom: number) => { + let reads = 0; + const evil: Record = { + verb: "apply", + target_substrate: "myc", + args_commitment: "c1", + input_commitments: ["a"], + }; + Object.defineProperty(evil, "requested_effects", { + enumerable: true, + get() { + reads++; + return reads >= badFrom ? [1] : ["write"]; + }, + }); + return evil; + }; + for (const from of [1, 2, 3]) { + for ( + const [who, text] of [ + ["trinity", trinityText], + ["myc", myc.canonicalIntentText], + ] as const + ) { + let out = ""; + try { + out = (text as (i: unknown) => string)(mk(from)); + } catch { /* refusing is the other correct answer */ } + assert( + !out.includes("[1]"), + `${who}: canonical text carried a number: ${out}`, + ); + } + } +}); diff --git a/src/warrant_test.ts b/src/warrant_test.ts index 6eec5a23..735fa84f 100644 --- a/src/warrant_test.ts +++ b/src/warrant_test.ts @@ -5,6 +5,7 @@ import { import { actionBoundAuthority, type ActionIntent, + canonicalIntentBytes, intentCommitment, type ProposalDescriptor, } from "./x5E10_warrant.ts"; @@ -17,6 +18,14 @@ const desc = (over: Partial = {}): ProposalDescriptor => ({ const IC = "deadbeef"; // a stand-in intent commitment +const VECTOR_INTENT = { + verb: "apply", + target_substrate: "myc" as const, + args_commitment: "c1", + input_commitments: ["a", "b"], + requested_effects: ["receipt", "write"], +}; + Deno.test("warrant — terminal state is NOT a capability: final without action_grant is denied", () => { // codex acceptance #1: a ratified governance proposal (no action_grant) grants // NO actuation authority, even though it is final:implemented. @@ -119,6 +128,53 @@ Deno.test("warrant — intent commitment matches the SHARED cross-substrate vect }); assertEquals( commitment, - "d02d75adca7e0dbbd10244c7ea1e9aeafa7b6d019a0f570bcad471a38d997552", + "ccc26b8b460fe2debf0ad069d55ec170a78b7b70861f1f54c03e401e4576c3be", + ); +}); + +// ADOPTION checks (RFC-0003 Part 01 5.1, Tranche A3). The commitment now runs +// over CNP-0-JCS canonical bytes; these guard what that must mean at the +// authority boundary rather than at the encoder. +Deno.test("warrant - the superseded digest is an intent_mismatch, not a near miss", () => { + // A grant minted under the pre-adoption algorithm must not authorize the same + // intent. There is no dual-hash path, so this is the shape a stale grant takes + // when it reaches the gate: denied, with a reason an actor can act on. + const SUPERSEDED = + "d02d75adca7e0dbbd10244c7ea1e9aeafa7b6d019a0f570bcad471a38d997552"; + const CURRENT = + "ccc26b8b460fe2debf0ad069d55ec170a78b7b70861f1f54c03e401e4576c3be"; + const v = actionBoundAuthority(CURRENT, { + fqdn: "h.deadbeef0000.proposal.myc.md", + commitment: "deadbeef0000", + action_grant: { intent_commitment: SUPERSEDED }, + }, "implemented"); + assertEquals(v.authorized, false); + assertEquals(v.reason_code, "intent_mismatch"); +}); + +Deno.test("warrant - the canonical bytes pass the ratified verifier-only path", async () => { + // The bytes this commitment hashes must be canonical under the encoding that + // was ratified, judged by the path that CANNOT repair what it reads. Asserting + // this against our own encoder would only say the encoder agrees with itself. + const { verifyRaw } = await import( + "../probes/cnp-0-seed-v0/ts/reject.ts" + ); + const bytes = canonicalIntentBytes(VECTOR_INTENT); + const out = await verifyRaw(bytes); + assertEquals( + out.ok, + true, + "the verifier-only path rejected our canonical bytes: " + + (out.ok ? "" : out.rejection + " - " + out.detail), + ); + + // And it is discriminating: a non-canonical spelling of the same value is + // rejected, so the pass above is not vacuous. + const reordered = new TextEncoder().encode( + '{"verb":"apply","args_commitment":"c1","canonical_encoding":"hsp-jcs@v0",' + + '"input_commitments":["a","b"],"numeric_profile":"cnp-0",' + + '"requested_effects":["receipt","write"],"target_substrate":"myc"}', ); + const bad = await verifyRaw(reordered); + assertEquals(bad.ok, false, "member order was not enforced"); }); diff --git a/src/x5E10_warrant.ts b/src/x5E10_warrant.ts index 02da1c78..c3cfbeb4 100644 --- a/src/x5E10_warrant.ts +++ b/src/x5E10_warrant.ts @@ -54,6 +54,273 @@ export interface ActionIntent { requested_effects: string[]; } +const SUBSTRATES = ["trinity", "myc", "liquid", "omega"]; + +/** The closed member set. An ActionIntent has exactly these and nothing else. */ +const MEMBERS = [ + "verb", + "target_substrate", + "args_commitment", + "input_commitments", + "requested_effects", +] as const; + +/** Normalize an untrusted value into an OWNED ActionIntent, or say why not. + * + * One operation, and it reads every property EXACTLY ONCE into a local. The + * previous shape was check-then-reread: `domainError(intent)` validated the + * properties and `canonicalIntentText` read them again, so an accessor could + * return `["write"]` to the validator and `[1]` to the encoder. That produced + * canonical bytes containing a JSON number and a digest over them. Anything + * built from a re-read of caller-controlled input is built from a value the + * check never saw. + * + * Arrays are copied, not aliased, for the same reason: a caller keeping a + * reference could mutate one after validation. + * + * It is also the ONE domain check — `validateIntent` and the canonical encoder + * both go through it. Two guards meant to agree, written twice, disagree: an + * earlier encoder repeated only the surrogate check while calling itself + * "refusing anything else", and encoded `requested_effects: [1]`. */ +function normalizeIntent( + v: unknown, +): { ok: true; intent: ActionIntent } | { ok: false; error: string } { + const no = (error: string) => ({ ok: false as const, error }); + + if (!v || typeof v !== "object" || Array.isArray(v)) { + return no("intent must be an object"); + } + const o = v as Record; + + // Extra members are REJECTED, not dropped. Silently discarding an unknown + // member lets two callers commit to one digest believing they asked for + // different things. + const keys = Object.keys(o); + const extra = keys.filter((k) => !(MEMBERS as readonly string[]).includes(k)) + .sort(); + if (extra.length) { + return no( + `unknown member(s): ${ + extra.join(", ") + }; the ActionIntent schema is closed`, + ); + } + for (const k of MEMBERS) { + if (!keys.includes(k)) return no(`${k} is missing`); + } + + // ONE read per property, into a local. Everything below uses the local. + const verb = o.verb; + const target_substrate = o.target_substrate; + const args_commitment = o.args_commitment; + const input_raw = o.input_commitments; + const effects_raw = o.requested_effects; + + if (typeof verb !== "string" || !verb.trim()) { + return no("verb must be a non-empty string"); + } + if ( + typeof target_substrate !== "string" || + !SUBSTRATES.includes(target_substrate) + ) { + return no(`target_substrate must be one of: ${SUBSTRATES.join(", ")}`); + } + if (typeof args_commitment !== "string") { + return no("args_commitment must be a string"); + } + if (!Array.isArray(input_raw)) { + return no("input_commitments must be an array of strings"); + } + if (!Array.isArray(effects_raw)) { + return no("requested_effects must be an array of strings"); + } + // Copy first, then check the COPY. Checking the original and copying after + // would re-read every element. + const input_commitments = Array.from(input_raw) as unknown[]; + const requested_effects = Array.from(effects_raw) as unknown[]; + for ( + const [name, arr] of [ + ["input_commitments", input_commitments], + ["requested_effects", requested_effects], + ] as const + ) { + if (!arr.every((e) => typeof e === "string")) { + return no(`${name} must be an array of strings`); + } + } + + // Surrogates last, so a shape error reports as a shape error. + const strings: [string, string][] = [ + ["verb", verb], + ["args_commitment", args_commitment], + ...(input_commitments as string[]).map(( + x, + i, + ): [string, string] => [`input_commitments[${i}]`, x]), + ...(requested_effects as string[]).map(( + x, + i, + ): [string, string] => [`requested_effects[${i}]`, x]), + ]; + for (const [where, value] of strings) { + const bad = unpairedSurrogateIndex(value); + if (bad >= 0) { + return no( + `${where} contains an unpaired UTF-16 surrogate at index ${bad}; ` + + `strict I-JSON admits only Unicode scalar values, and the string has ` + + `no UTF-8 encoding a second implementation could reproduce`, + ); + } + } + + return { + ok: true, + intent: { + verb, + target_substrate: target_substrate as ActionIntent["target_substrate"], + args_commitment, + input_commitments: input_commitments as string[], + requested_effects: requested_effects as string[], + }, + }; +} + +/** Validate an untrusted value as an ActionIntent. Fail closed, and return an + * OWNED normalization the caller can encode from safely. */ +export function validateIntent( + v: unknown, +): { ok: true; intent: ActionIntent } | { ok: false; error: string } { + return normalizeIntent(v); +} + +/** RFC-0003 Part 01 §5.1.2.1: the wire encoding and the numeric profile are + * separate identifiers and BOTH live inside the hashed root. A profile without + * a byte encoding does not determine a digest. */ +export const CANONICAL_ENCODING = "hsp-jcs@v0"; +export const NUMERIC_PROFILE = "cnp-0"; + +/** The index of the first unpaired UTF-16 surrogate, or -1. + * + * Strict I-JSON admits only Unicode scalar values, and RFC-0003 §5.1.3 names + * `unpaired-surrogate` as a rejection class for exactly this. The reason is + * interoperability rather than anything local: a lone surrogate has no UTF-8 + * encoding at all, so a second implementation cannot reproduce the commitment. + * Python raises `UnicodeEncodeError` on `'x\ud834y'.encode('utf-8')`; Rust + * cannot hold the value in a `String`. JavaScript is the outlier that accepts + * it, and a cross-substrate authority commitment must not depend on the + * quirks of the language that happens to compute it first. + * + * (It is NOT a same-language collision: ES2019 well-formed `JSON.stringify` + * escapes a lone surrogate to `\udXXX`, so the canonical text differs from the + * U+FFFD text. Measured before this comment was written.) */ +export function unpairedSurrogateIndex(s: string): number { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff) { + const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0; + if (next < 0xdc00 || next > 0xdfff) return i; + i++; // a well-formed pair; skip the low half + } else if (c >= 0xdc00 && c <= 0xdfff) { + return i; // a low surrogate with no high half before it + } + } + return -1; +} + +/** CNP-0-JCS for the ActionIntent shape, and refusing anything else. + * + * Deliberately not a general encoder: this domain is a map of string keys to + * strings and arrays of strings, with no numbers, and a serializer that quietly + * handled more than its callers can produce would be untested surface in an + * authority path. Trinity's `conformance/cnp-0-jcs-v0/` kit is where a general + * implementation gets measured. + * + * Member names are ordered by UTF-16 code unit (RFC 8785 §3.2.3), which is what + * JavaScript's default string comparison already does — written explicitly here + * because "correct by coincidence" is not a property anyone can check. */ +function jcsString(s: string): string { + const bad = unpairedSurrogateIndex(s); + if (bad >= 0) { + throw new RangeError( + `unpaired-surrogate at index ${bad}: the value has no UTF-8 encoding and ` + + `cannot be reproduced by a second implementation`, + ); + } + // ES2019 well-formed JSON.stringify implements RFC 8785 §3.2.2.2 escaping for + // well-formed input: shortest form for \b \f \n \r \t, \u00XX for other + // controls, literal otherwise. Verified against the rule, not assumed. + return JSON.stringify(s); +} + +function jcsValue(v: string | string[]): string { + if (typeof v === "string") return jcsString(v); + if (Array.isArray(v)) return `[${v.map(jcsString).join(",")}]`; + throw new TypeError("ActionIntent members are strings or arrays of strings"); +} + +/** `requested_effects` is a SET: deduplicated, then ordered. + * + * Sorting alone is not set semantics, and the previous code claimed it was — + * `["write","write"]` and `["write"]` request the same effect and produced two + * different commitments, so an authority grant could be made to depend on how + * many times a caller happened to name an effect. Duplicates are canonicalised + * away rather than rejected, because a repeated effect is not an error about + * what was asked for; it is the same ask written twice. + * + * Ordering is by UTF-16 code unit, matching RFC 8785's member ordering, which + * is what JavaScript's default string comparison already does — written out + * because "correct by coincidence" is not a property anyone can check. */ +function canonicalEffects(effects: string[]): string[] { + return [...new Set(effects)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +/** The canonical bytes' text form. Exported so a test can assert the BYTES + * rather than only the digest — a digest test passes for two implementations + * that agree on a wrong encoding, and says nothing about which bytes either + * produced. */ +export function canonicalIntentText(intent: ActionIntent): string { + // Normalize ONCE, then encode only from the snapshot. The caller's object is + // never read again: a getter that answered the validator and then answered the + // encoder differently is how `requested_effects: [1]` reached canonical bytes. + const n = normalizeIntent(intent); + if (!n.ok) { + throw new RangeError(`ActionIntent is outside its domain: ${n.error}`); + } + const i = n.intent; + const root: Record = { + args_commitment: i.args_commitment, + canonical_encoding: CANONICAL_ENCODING, + input_commitments: i.input_commitments, // ORDER PRESERVED + numeric_profile: NUMERIC_PROFILE, + requested_effects: canonicalEffects(i.requested_effects), + target_substrate: i.target_substrate, + verb: i.verb, + }; + const names = Object.keys(root).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${ + names.map((k) => `${jcsString(k)}:${jcsValue(root[k])}`).join(",") + }}`; +} + +/** The canonical bytes themselves: UTF-8, no BOM. */ +export function canonicalIntentBytes( + intent: ActionIntent, +): Uint8Array { + return new TextEncoder().encode(canonicalIntentText(intent)); +} + +async function sha256Bytes(b: Uint8Array): Promise { + const d = await crypto.subtle.digest("SHA-256", b); + return Array.from(new Uint8Array(d)).map((x) => + x.toString(16).padStart(2, "0") + ).join(""); +} + +// The PROPOSAL-BODY digest, deliberately NOT migrated. It recomputes a +// commitment MYC already wrote with this same stringification, so changing it +// here alone would reject every existing proposal — and migrating it is a +// separate slice with its own stored-state question. Only ActionIntent adopts +// CNP-0-JCS in this commit. type Json = null | boolean | number | string | Json[] | { [k: string]: Json }; function stable(v: Json): string { if (v === null) return "null"; @@ -69,21 +336,129 @@ async function sha256(s: string): Promise { const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return Array.from(new Uint8Array(d)).map((b) => b.toString(16).padStart(2, "0") - ) - .join(""); + ).join(""); } -/** The content commitment of a normalized intent (codex §1+§5). VENDORED byte-for- - * byte from MYC's canonical contract `myc/src/x5820_action_intent.ts` (MYC owns it, - * Trinity cannot static-import the submodule under CI decoupling). A shared - * known-answer vector pins both sides — see warrant_test.ts + x5820_action_intent_test.ts. */ + +/** The content commitment of a normalized intent. SHA-256 over CNP-0-JCS + * canonical bytes — RFC-0003 Part 01 §5.1, Tranche A3, ratified 2026-08-26. + * + * VENDORED byte-for-byte from MYC's canonical contract + * `myc/src/x5820_action_intent.ts` (MYC owns it; Trinity cannot static-import + * the submodule under CI decoupling). Two things pin the copy, and the second + * is the one that matters: a shared known-answer vector in both suites, AND a + * LIVE parity test that executes both implementations and compares their + * canonical BYTES when the submodule is present — see + * `src/action_intent_parity_test.ts`. Two matching constants in two files prove + * the constants match; only running both proves the implementations do. */ export async function intentCommitment(intent: ActionIntent): Promise { - return await sha256(stable({ - args_commitment: intent.args_commitment, - input_commitments: intent.input_commitments, // ORDER PRESERVED (codex §5) - requested_effects: [...intent.requested_effects].sort(), // a set - target_substrate: intent.target_substrate, - verb: intent.verb, - })); + return await sha256Bytes(canonicalIntentBytes(intent)); +} + +/** Parse ActionIntent JSON from RAW BYTES, strictly. + * + * `JSON.parse(await Deno.readTextFile(p))` destroys the evidence two ratified + * rejection classes are about, before any validator can see it: + * + * * **duplicate member names** collapse last-wins, so + * `{"verb":"deny","verb":"apply"}` parses as `apply` and a proposal is + * written for an intent that also said `deny`; + * * **invalid UTF-8** becomes U+FFFD during decoding, so a commitment is made + * over a replacement character the caller never wrote. + * + * RFC-0003 §5.1.1 rule 4 exists for exactly this, and the conformance kit's own + * interface says three rejection categories "describe properties of the input + * BYTES that a permissive JSON parser resolves before you can see them". This + * boundary was using a permissive parser anyway. + * + * Escape-equivalence matters: `"verb"` and `"verb"` are the same member + * name, so names are decoded before they are compared. Only the top-level + * object is scanned, which is the whole of this schema — nested objects are + * refused by the domain check that follows. */ +export function parseActionIntentBytes( + bytes: Uint8Array, +): { ok: true; intent: ActionIntent } | { ok: false; error: string } { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return { + ok: false, + error: + "invalid-utf8: the input is not well-formed UTF-8; a permissive decode " + + "would commit to a replacement character nobody wrote", + }; + } + + const dup = duplicateTopLevelMember(text); + if (dup) { + return { + ok: false, + error: + `duplicate-member-name: ${ + JSON.stringify(dup) + } appears more than once; ` + + "a permissive parser keeps the last one and the other becomes invisible", + }; + } + + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (e) { + return { ok: false, error: `syntax: ${(e as Error).message}` }; + } + return normalizeIntent(raw); +} + +/** The first top-level member name that appears twice, or null. + * + * A minimal scanner rather than a JSON parser: it tracks string literals and + * nesting depth, collects names at depth 1, and decodes each before comparing, + * so an escaped spelling cannot smuggle a second copy past the check. */ +function duplicateTopLevelMember(text: string): string | null { + const seen = new Set(); + let depth = 0; + let i = 0; + let expectName = false; + + while (i < text.length) { + const c = text[i]; + if (c === '"') { + const start = i; + i++; + while (i < text.length) { + if (text[i] === "\\") i += 2; + else if (text[i] === '"') break; + else i++; + } + const literal = text.slice(start, i + 1); + i++; + // A member name is a string followed (after whitespace) by ':'. + let j = i; + while (j < text.length && /\s/.test(text[j])) j++; + const isName = text[j] === ":"; + if (isName && depth === 1 && expectName) { + let name: string; + try { + name = JSON.parse(literal) as string; + } catch { + return null; // malformed; JSON.parse below will report it + } + if (seen.has(name)) return name; + seen.add(name); + } + continue; + } + if (c === "{" || c === "[") { + depth++; + expectName = c === "{"; + } else if (c === "}" || c === "]") { + depth--; + expectName = depth === 1; + } + i++; + } + return null; } /** The committed proposal descriptor — the PROOF (not the lifecycle summary, which @@ -234,14 +609,28 @@ async function runCli(args: string[] = Deno.args): Promise { // proposer can mint the action_grant a future warrant will match. ONE algorithm, // here, never re-implemented on the propose side. if (sub === "intent" && fqdn) { + // VALIDATE runtime input before it can reach a commitment. Parsing JSON + // says the bytes are JSON; it says nothing about whether they are an + // ActionIntent. Without this the encoder throws a bare RangeError at the + // caller, which is a failure but not a reason. + // RAW BYTES, strictly: reading text and calling JSON.parse erases duplicate + // member names and invalid UTF-8 before any validator can refuse them. let intent: ActionIntent; + let bytes: Uint8Array; try { - intent = JSON.parse(await Deno.readTextFile(fqdn)); + bytes = await Deno.readFile(fqdn); } catch { console.error(`# error: could not read intent from ${fqdn}`); Deno.exitCode = 1; return; } + const parsed = parseActionIntentBytes(bytes); + if (!parsed.ok) { + console.error(`# error: invalid intent: ${parsed.error}`); + Deno.exitCode = 1; + return; + } + intent = parsed.intent; console.log(JSON.stringify( { type: "intent_commitment", @@ -283,13 +672,21 @@ async function runCli(args: string[] = Deno.args): Promise { return; } let intent: ActionIntent; + let ibytes: Uint8Array; try { - intent = JSON.parse(await Deno.readTextFile(ipath)); + ibytes = await Deno.readFile(ipath); } catch { console.error(`# error: could not read intent from ${ipath}`); Deno.exitCode = 1; return; } + const parsedAdmit = parseActionIntentBytes(ibytes); + if (!parsedAdmit.ok) { + console.error(`# error: invalid intent: ${parsedAdmit.error}`); + Deno.exitCode = 1; + return; + } + intent = parsedAdmit.intent; const [descriptor, fs, ic] = await Promise.all([ readProposal(fqdn), finalState(fqdn), diff --git a/src/x8788_network.myc.md b/src/x8788_network.myc.md index 59cf0037..fff727da 100644 --- a/src/x8788_network.myc.md +++ b/src/x8788_network.myc.md @@ -1,9 +1,9 @@ - + # The FQDN network -**2385 nodes across 4 substrates.** A federation of living substrates: every +**2388 nodes across 4 substrates.** A federation of living substrates: every organ sits at a fixed hex coordinate (by role, not by content), every chord is a signed provenance event, and every edge — what a node hears, closes, references, imports — is git- and crypto-checkable. Nothing here is hand-placed; it is @@ -14,12 +14,12 @@ from the ledger, never edited. | substrate | nodes | chords | | --------- | ----: | -----: | -| trinity | 1076 | 835 | +| trinity | 1077 | 835 | | omega | 633 | 0 | | liquid | 436 | 0 | -| myc | 240 | 0 | +| myc | 242 | 0 | -Made of: 835 chord, 648 organ, 334 other, 255 doc, 205 test, 62 rust, 41 data, 5 +Made of: 835 chord, 648 organ, 336 other, 255 doc, 206 test, 62 rust, 41 data, 5 script. **Most-cited — what the network leans on:** diff --git a/src/x88E0_readme_bootstrap.myc.md b/src/x88E0_readme_bootstrap.myc.md index 50218928..e65392f6 100644 --- a/src/x88E0_readme_bootstrap.myc.md +++ b/src/x88E0_readme_bootstrap.myc.md @@ -1,6 +1,6 @@ - + diff --git a/src/x88F0_agents_bootstrap.myc.md b/src/x88F0_agents_bootstrap.myc.md index 8ed4c55f..18c9349e 100644 --- a/src/x88F0_agents_bootstrap.myc.md +++ b/src/x88F0_agents_bootstrap.myc.md @@ -1,6 +1,6 @@ - + diff --git a/src/x8CF0_skills_bootstrap.myc.md b/src/x8CF0_skills_bootstrap.myc.md index cc8aa984..b0e1b9ad 100644 --- a/src/x8CF0_skills_bootstrap.myc.md +++ b/src/x8CF0_skills_bootstrap.myc.md @@ -1,6 +1,6 @@ - +