From 64a2bf7dc0fa6974ab8cc4ec6a630f801cb762aa Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 19 Jul 2026 21:11:56 +0200 Subject: [PATCH 01/11] feat: add canonical causal contracts --- src/AGENTS.md | 32 +- src/contracts/AGENTS.md | 40 +++ src/contracts/CLAUDE.md | 1 + src/contracts/bundle.test.ts | 272 ++++++++++++++ src/contracts/bundle.ts | 297 +++++++++++++++ src/contracts/canonicalJson.test.ts | 198 ++++++++++ src/contracts/canonicalJson.ts | 302 ++++++++++++++++ src/contracts/digestDomain.test.ts | 226 ++++++++++++ src/contracts/digestDomain.ts | 205 +++++++++++ src/contracts/envelope.test.ts | 329 +++++++++++++++++ src/contracts/envelope.ts | 213 +++++++++++ src/contracts/golden.test.ts | 243 +++++++++++++ src/contracts/goldens/causal-contract.v1.json | 338 ++++++++++++++++++ src/contracts/ids.test.ts | 51 +++ src/contracts/ids.ts | 53 +++ src/contracts/index.ts | 6 + src/contracts/streamFinal.test.ts | 92 +++++ src/contracts/streamFinal.ts | 63 ++++ src/envelope.test.ts | 9 + src/envelope.ts | 191 +--------- src/index.ts | 1 + 21 files changed, 2978 insertions(+), 184 deletions(-) create mode 100644 src/contracts/AGENTS.md create mode 120000 src/contracts/CLAUDE.md create mode 100644 src/contracts/bundle.test.ts create mode 100644 src/contracts/bundle.ts create mode 100644 src/contracts/canonicalJson.test.ts create mode 100644 src/contracts/canonicalJson.ts create mode 100644 src/contracts/digestDomain.test.ts create mode 100644 src/contracts/digestDomain.ts create mode 100644 src/contracts/envelope.test.ts create mode 100644 src/contracts/envelope.ts create mode 100644 src/contracts/golden.test.ts create mode 100644 src/contracts/goldens/causal-contract.v1.json create mode 100644 src/contracts/ids.test.ts create mode 100644 src/contracts/ids.ts create mode 100644 src/contracts/index.ts create mode 100644 src/contracts/streamFinal.test.ts create mode 100644 src/contracts/streamFinal.ts diff --git a/src/AGENTS.md b/src/AGENTS.md index 84c9d4d..2110517 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -2,14 +2,30 @@ ```text src/ -├── index.ts # Barrel for package exports -├── envelope.ts # CausalEvent type + zod validator + parseCausalJsonl + canonical hashing -├── reconcile.ts # Pure reconciler + backward cause-chain tracer -├── seq.ts # Per-(run_id, system:stream_id) contiguity checker -├── envelope.test.ts -├── reconcile.test.ts -├── reconcile.perf.test.ts -└── seq.test.ts +├── index.ts # Barrel export for package public API +├── envelope.ts # Compatibility facade re-exporting contract exports +├── reconcile.ts # Pure reconciler + backward cause-chain tracing +├── seq.ts # Per-(run_id, system:stream_id) contiguity checker +├── AGENTS.md # This guide +└── contracts/ # Contract-level canonical envelope/finality/digest/bundle parsers + ├── AGENTS.md # Contracts guide + ├── CLAUDE.md # Compatibility guide symlink to AGENTS.md + ├── canonicalJson.ts # Canonical JSON parser/stringifier/helpers + ├── canonicalJson.test.ts + ├── ids.ts # Recognized systems and causal-id helpers + ├── ids.test.ts + ├── envelope.ts # noopolis.causal-event.v1 validation and JSONL parser + ├── envelope.test.ts + ├── streamFinal.ts # noopolis.causal-stream-final.v1 validation + ├── streamFinal.test.ts + ├── digestDomain.ts # noopolis.causal-digest-domain.v1 declaration/hash helpers + ├── digestDomain.test.ts + ├── bundle.ts # Bundle preflight over mixed contract records + ├── bundle.test.ts + ├── index.ts # Contracts barrel export + ├── golden.test.ts # Golden corpus coverage/invariant tests + └── goldens/ + └── causal-contract.v1.json # Sole approved corpus manifest ``` Keep this package read/verify-only. Emit-side logic and fixture/conformance diff --git a/src/contracts/AGENTS.md b/src/contracts/AGENTS.md new file mode 100644 index 0000000..649ec5c --- /dev/null +++ b/src/contracts/AGENTS.md @@ -0,0 +1,40 @@ +# Contracts Guide + +```text +src/contracts/ +├── AGENTS.md # This guide +├── CLAUDE.md # Compatibility guide symlink to AGENTS.md +├── canonicalJson.ts +├── canonicalJson.test.ts +├── ids.ts +├── ids.test.ts +├── envelope.ts +├── envelope.test.ts +├── streamFinal.ts +├── streamFinal.test.ts +├── digestDomain.ts +├── digestDomain.test.ts +├── bundle.ts +├── bundle.test.ts +├── golden.test.ts +├── index.ts +└── goldens/ + └── causal-contract.v1.json +``` + +This folder owns the cross-project contract surfaces: +- `noopolis.causal-event.v1` +- `noopolis.causal-stream-final.v1` +- `noopolis.causal-digest-domain.v1` +- shared bundle preflight +- canonical JSON utilities + +Rules for all files in this folder: +- Keep every source and test file under 400 lines. +- Use named exports only. +- Keep behavior deterministic and transport-neutral. +- Preserve strict schema and parsing rules from previous implementation steps. +- Keep public names non-conflicting at the folder barrel level. + +`src/contracts/CLAUDE.md` is a required exact symlink to this file and must +remain the canonical local compatibility pointer. diff --git a/src/contracts/CLAUDE.md b/src/contracts/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/contracts/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/contracts/bundle.test.ts b/src/contracts/bundle.test.ts new file mode 100644 index 0000000..2f9166b --- /dev/null +++ b/src/contracts/bundle.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; + +import { CAUSAL_EVENT_VERSION } from "./envelope.js"; +import { CAUSAL_STREAM_FINAL_VERSION } from "./streamFinal.js"; +import { parseCausalBundle, parseCausalBundleBytes } from "./bundle.js"; + +import type { CausalEvent } from "./envelope.js"; + +const eventA: CausalEvent = { + cause_event_ids: [], + emitter: { + seq: 1, + stream_id: "network:room-1", + system: "moltnet" + }, + event_id: "moltnet:e1", + payload: {}, + principal_id: "agent:agent-1", + recorded_at: "2026-07-09T00:00:00.000Z", + run_id: "run-1", + type: "message.accepted", + version: CAUSAL_EVENT_VERSION +}; + +const eventB: CausalEvent = { + ...eventA, + cause_event_ids: ["moltnet:e1"], + emitter: { ...eventA.emitter, seq: 2 }, + event_id: "moltnet:e2", + recorded_at: "2026-07-09T00:00:00.001Z" +}; + +const collisionEventTupleOne: CausalEvent = { + ...eventA, + event_id: "simfile:e3", + emitter: { + seq: 1, + stream_id: "foo", + system: "simfile" + }, + run_id: "run::moltnet" +}; + +const collisionEventTupleTwo: CausalEvent = { + ...eventA, + event_id: "moltnet:e3", + emitter: { + seq: 1, + stream_id: ":simfile:foo", + system: "moltnet" + }, + run_id: "run" +}; + +const finalForStream = { + final_seq: 2, + emitter: { + stream_id: "network:room-1", + system: "moltnet" + }, + run_id: "run-1", + version: CAUSAL_STREAM_FINAL_VERSION +}; + +const finalForCollisionTupleOne = { + final_seq: 1, + emitter: { + stream_id: "foo", + system: "simfile" + }, + run_id: "run::moltnet", + version: CAUSAL_STREAM_FINAL_VERSION +}; + +const finalForCollisionTupleTwo = { + final_seq: 1, + emitter: { + stream_id: ":simfile:foo", + system: "moltnet" + }, + run_id: "run", + version: CAUSAL_STREAM_FINAL_VERSION +}; + +const exactDigestDomain = { + hash: "sha-256", + label: "content/exact-bytes", + output: "lowercase-hex", + subject_bytes: "exact-bytes", + version: "noopolis.causal-digest-domain.v1" +}; + +describe("parseCausalBundle", () => { + it("accepts mixed canonical event/final/domain input", () => { + const jsonl = `${JSON.stringify(eventA)}\n${JSON.stringify(eventB)}\n${JSON.stringify(finalForStream)}\n${JSON.stringify(exactDigestDomain)}\n`; + const { events, streamFinals, digestDomains, errors } = parseCausalBundle(jsonl); + expect(errors).toEqual([]); + expect(events).toHaveLength(2); + expect(streamFinals).toHaveLength(1); + expect(digestDomains).toHaveLength(1); + }); + + it("preserves blank lines while parsing", () => { + const jsonl = `${JSON.stringify(eventA)}\n\n${JSON.stringify(eventB)}\n`; + const { events, errors } = parseCausalBundle(jsonl); + expect(events).toHaveLength(2); + expect(errors).toEqual([]); + }); + + it("keeps malformed JSON as line-local errors", () => { + const { errors, events } = parseCausalBundle("{\n"); + expect(events).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 1, message: expect.stringContaining("invalid JSON:") }); + }); + + it("rejects unknown versions", () => { + const jsonl = `${JSON.stringify({ ...eventA, version: "noopolis.unknown.v1" })}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 1, message: expect.stringContaining("invalid record") }); + }); + + it("rejects malformed event fields", () => { + const jsonl = `${JSON.stringify({ ...eventA, emitter: { ...eventA.emitter, seq: 0 } })}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(1); + }); + + it("rejects every duplicate event id", () => { + const duplicate = { + ...eventA, + event_id: "moltnet:e1", + emitter: { ...eventA.emitter, seq: 2, stream_id: "network:room-2" }, + recorded_at: "2026-07-10T00:00:00.000Z" + }; + const jsonl = `${JSON.stringify(eventA)}\n${JSON.stringify(duplicate)}\n`; + const { events, errors } = parseCausalBundle(jsonl); + expect(events).toHaveLength(2); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 2, message: "duplicate event_id: moltnet:e1" }); + }); + + it("rejects duplicate event seq slot in the same stream", () => { + const duplicateSlot = { ...eventA, event_id: "moltnet:e2", recorded_at: "2026-07-10T00:00:00.000Z" }; + const jsonl = `${JSON.stringify(eventA)}\n${JSON.stringify(duplicateSlot)}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain(`duplicate event slot`); + }); + + it("rejects duplicate stream finals", () => { + const duplicateFinal = { ...finalForStream }; + const jsonl = `${JSON.stringify(finalForStream)}\n${JSON.stringify(duplicateFinal)}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 2, message: expect.stringContaining("duplicate stream final for run-1::moltnet:network:room-1") }); + }); + + it("rejects final below observed sequence", () => { + const lowFinal = { ...finalForStream, final_seq: 1 }; + const jsonl = `${JSON.stringify(eventA)}\n${JSON.stringify(eventB)}\n${JSON.stringify(lowFinal)}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("below observed sequence 2"); + }); + + it("rejects empty-stream final paired with an observed event", () => { + const emptyFinal = { ...finalForStream, final_seq: 0 }; + const jsonl = `${JSON.stringify(eventA)}\n${JSON.stringify(emptyFinal)}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(2); + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line: 2, + message: expect.stringContaining("below observed sequence") + }), + expect.objectContaining({ + line: 2, + message: expect.stringContaining("has final_seq 0 but stream has observed events") + }) + ]) + ); + }); + + it("accepts unresolved direct causes", () => { + const unresolved = { ...eventA, event_id: "moltnet:e3", cause_event_ids: ["moltnet:missing"] }; + const jsonl = `${JSON.stringify(unresolved)}\n`; + const { events, errors } = parseCausalBundle(jsonl); + expect(events).toHaveLength(1); + expect(errors).toEqual([]); + }); + + it("rejects direct causes that resolve to another run", () => { + const otherRun = { ...eventA, event_id: "simfile:p1", run_id: "run-2", emitter: { ...eventA.emitter, system: "simfile" } }; + const causeFromAnotherRun = { ...eventA, event_id: "moltnet:e4", cause_event_ids: ["simfile:p1"] }; + const jsonl = `${JSON.stringify(otherRun)}\n${JSON.stringify(causeFromAnotherRun)}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 2, + message: expect.stringContaining("belongs to another run") + }); + }); + + it("rejects malformed digest domains with a line error", () => { + const badDomain = { ...exactDigestDomain, label: "bogus" }; + const { errors } = parseCausalBundle(`${JSON.stringify(badDomain)}\n`); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 1, message: expect.stringContaining("label") }); + }); + + it("keeps delimiter-based tuple collisions out of event slot identity", () => { + const jsonl = `${JSON.stringify(collisionEventTupleOne)}\n${JSON.stringify(collisionEventTupleTwo)}\n`; + const { errors, events } = parseCausalBundle(jsonl); + expect(errors).toEqual([]); + expect(events).toHaveLength(2); + }); + + it("keeps delimiter-based tuple collisions out of stream final identity", () => { + const jsonl = `${JSON.stringify(finalForCollisionTupleOne)}\n${JSON.stringify(finalForCollisionTupleTwo)}\n`; + const { errors, streamFinals } = parseCausalBundle(jsonl); + expect(errors).toEqual([]); + expect(streamFinals).toHaveLength(2); + }); + + it("keeps final checks separated for delimiter-collision tuples", () => { + const event = { ...collisionEventTupleOne, event_id: "simfile:e4" }; + const collidingFinalTupleTwo = { ...finalForCollisionTupleTwo, final_seq: 0 }; + const collidingFinalTupleOne = { ...finalForCollisionTupleOne, final_seq: 0 }; + + const jsonl = `${JSON.stringify(event)}\n${JSON.stringify(collidingFinalTupleOne)}\n${JSON.stringify(collidingFinalTupleTwo)}\n`; + const { errors } = parseCausalBundle(jsonl); + expect(errors).toHaveLength(2); + expect(errors).toContainEqual( + expect.objectContaining({ + line: 2, + message: expect.stringContaining("below observed sequence 1") + }) + ); + expect(errors).toContainEqual( + expect.objectContaining({ + line: 2, + message: expect.stringContaining("has final_seq 0 but stream has observed events") + }) + ); + expect(errors.filter((error) => error.line === 3)).toHaveLength(0); + }); +}); + +describe("parseCausalBundleBytes", () => { + it("rejects invalid UTF-8 bytes", () => { + const { errors, events } = parseCausalBundleBytes(Uint8Array.from([0xff])); + expect(events).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.stringContaining("invalid JSON:") + }); + }); + + it("rejects BOM-prefixed JSONL bytes", () => { + const bytes = new TextEncoder().encode(`\uFEFF{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"moltnet:m1","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2026-07-09T00:00:00.000Z","cause_event_ids":[],"payload":{}}`); + const { errors } = parseCausalBundleBytes(bytes); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.stringContaining("leading BOM") + }); + }); +}); diff --git a/src/contracts/bundle.ts b/src/contracts/bundle.ts new file mode 100644 index 0000000..b82512f --- /dev/null +++ b/src/contracts/bundle.ts @@ -0,0 +1,297 @@ +import { parseCausalEvent, type CausalEvent } from "./envelope.js"; +import { parseCausalDigestDomain, type CausalDigestDomain } from "./digestDomain.js"; +import { CAUSAL_STREAM_FINAL_VERSION, parseCausalStreamFinal, type CausalStreamFinal } from "./streamFinal.js"; +import { parseCanonicalJson } from "./canonicalJson.js"; + +export interface BundleParseError { + line: number; + message: string; +} + +export interface BundleParseResult { + digestDomains: CausalDigestDomain[]; + errors: BundleParseError[]; + events: CausalEvent[]; + streamFinals: CausalStreamFinal[]; +} + +type BundleRecordKind = "causal-event" | "causal-stream-final" | "causal-digest-domain"; + +type BundleLineRecord = { + kind: BundleRecordKind; + value: unknown; +}; + +const isJsonWhitespace = (character: string) => + character === " " || character === "\t" || character === "\n" || character === "\r"; + +const trimJsonWhitespace = (input: string) => { + let start = 0; + while (isJsonWhitespace(input[start] ?? "")) start += 1; + let end = input.length; + while (end > start && isJsonWhitespace(input[end - 1])) end -= 1; + return input.slice(start, end); +}; + +const classifyRecord = (value: unknown): BundleRecordKind | "invalid" => { + if (typeof value !== "object" || value === null) return "invalid"; + if (!("version" in value) || typeof value.version !== "string") return "invalid"; + + if (value.version === "noopolis.causal-event.v1") return "causal-event"; + if (value.version === CAUSAL_STREAM_FINAL_VERSION) return "causal-stream-final"; + if (value.version === "noopolis.causal-digest-domain.v1") return "causal-digest-domain"; + return "invalid"; +}; + +const parseBundleLine = (line: string, lineNumber: number): BundleLineRecord | BundleParseError | null => { + const trimmed = trimJsonWhitespace(line); + if (trimmed.length === 0) { + return null; + } + + let parsed: unknown; + try { + parsed = parseCanonicalJson(trimmed); + } catch (error) { + return { + line: lineNumber, + message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` + }; + } + + const kind = classifyRecord(parsed); + if (kind === "invalid") { + return { + line: lineNumber, + message: "invalid record: unknown version or malformed record shape" + }; + } + + return { kind, value: parsed }; +}; + +const parseStreamKey = (runId: string, system: string, streamId: string): string => + JSON.stringify([runId, system, streamId]); + +const parseStreamSlotKey = (runId: string, system: string, streamId: string, seq: number): string => + JSON.stringify([runId, system, streamId, seq]); + +const streamPairLabel = (runId: string, system: string, streamId: string): string => + `${runId}::${system}:${streamId}`; + +const streamSlotLabel = (runId: string, system: string, streamId: string, seq: number): string => + `${streamPairLabel(runId, system, streamId)}:${seq}`; + +const parseLineEvent = (lineNumber: number, value: unknown): CausalEvent | BundleParseError => { + try { + return parseCausalEvent(value); + } catch (error) { + return { + line: lineNumber, + message: error instanceof Error ? error.message : String(error) + }; + } +}; + +const parseLineFinal = (lineNumber: number, value: unknown): CausalStreamFinal | BundleParseError => { + try { + return parseCausalStreamFinal(value); + } catch (error) { + return { + line: lineNumber, + message: error instanceof Error ? error.message : String(error) + }; + } +}; + +const parseLineDigestDomain = ( + lineNumber: number, + value: unknown +): CausalDigestDomain | BundleParseError => { + try { + return parseCausalDigestDomain(value); + } catch (error) { + return { + line: lineNumber, + message: error instanceof Error ? error.message : String(error) + }; + } +}; + +const addError = (errors: BundleParseError[], error: BundleParseError) => { + errors.push(error); +}; + +export const parseCausalBundle = (jsonl: string): BundleParseResult => { + const result: BundleParseResult = { + digestDomains: [], + errors: [], + events: [], + streamFinals: [] + }; + + const eventById = new Map(); + const eventLineById = new Map(); + const streamFinalByKey = new Map(); + const streamFinalLineByKey = new Map(); + const seenEventSlots = new Set(); + + const lines = jsonl.split("\n"); + lines.forEach((line, index) => { + const lineNumber = index + 1; + const parsedLine = parseBundleLine(line, lineNumber); + if (parsedLine === null) { + return; + } + if ("kind" in parsedLine) { + if (parsedLine.kind === "causal-event") { + const parsed = parseLineEvent(lineNumber, parsedLine.value); + if ("line" in parsed) { + addError(result.errors, parsed); + return; + } + + if (eventById.has(parsed.event_id)) { + addError(result.errors, { + line: lineNumber, + message: `duplicate event_id: ${parsed.event_id}` + }); + } else { + eventById.set(parsed.event_id, parsed); + eventLineById.set(parsed.event_id, lineNumber); + } + + const slotWithSeq = parseStreamSlotKey( + parsed.run_id, + parsed.emitter.system, + parsed.emitter.stream_id, + parsed.emitter.seq + ); + if (seenEventSlots.has(slotWithSeq)) { + addError(result.errors, { + line: lineNumber, + message: `duplicate event slot: ${streamSlotLabel( + parsed.run_id, + parsed.emitter.system, + parsed.emitter.stream_id, + parsed.emitter.seq + )}` + }); + } else { + seenEventSlots.add(slotWithSeq); + } + + result.events.push(parsed); + return; + } + + if (parsedLine.kind === "causal-stream-final") { + const parsed = parseLineFinal(lineNumber, parsedLine.value); + if ("line" in parsed) { + addError(result.errors, parsed); + return; + } + + const key = parseStreamKey(parsed.run_id, parsed.emitter.system, parsed.emitter.stream_id); + if (streamFinalByKey.has(key)) { + addError(result.errors, { + line: lineNumber, + message: `duplicate stream final for ${streamPairLabel(parsed.run_id, parsed.emitter.system, parsed.emitter.stream_id)}` + }); + } else { + streamFinalByKey.set(key, parsed); + streamFinalLineByKey.set(key, lineNumber); + } + + result.streamFinals.push(parsed); + return; + } + + const parsed = parseLineDigestDomain(lineNumber, parsedLine.value); + if ("line" in parsed) { + addError(result.errors, parsed); + return; + } + result.digestDomains.push(parsed); + return; + } + + addError(result.errors, parsedLine); + }); + + const maxSeqByStream = new Map(); + for (const event of result.events) { + const key = parseStreamKey(event.run_id, event.emitter.system, event.emitter.stream_id); + const previous = maxSeqByStream.get(key) ?? 0; + maxSeqByStream.set(key, Math.max(previous, event.emitter.seq)); + } + + for (const event of result.events) { + const line = eventLineById.get(event.event_id) ?? 0; + for (const causeId of event.cause_event_ids) { + const cause = eventById.get(causeId); + if (!cause) { + continue; + } + if (cause.run_id !== event.run_id) { + addError(result.errors, { + line, + message: `cause ${causeId} for event ${event.event_id} belongs to another run` + }); + } + } + } + + for (const streamFinal of result.streamFinals) { + const key = parseStreamKey(streamFinal.run_id, streamFinal.emitter.system, streamFinal.emitter.stream_id); + const maxObservedSeq = maxSeqByStream.get(key) ?? 0; + const finalLine = streamFinalLineByKey.get(key) ?? 0; + + if (streamFinal.final_seq < maxObservedSeq) { + addError(result.errors, { + line: finalLine, + message: `stream final for ${streamPairLabel( + streamFinal.run_id, + streamFinal.emitter.system, + streamFinal.emitter.stream_id + )} is below observed sequence ${maxObservedSeq}` + }); + } + + if (streamFinal.final_seq === 0 && maxObservedSeq > 0) { + addError(result.errors, { + line: finalLine, + message: `stream final for ${streamPairLabel( + streamFinal.run_id, + streamFinal.emitter.system, + streamFinal.emitter.stream_id + )} has final_seq 0 but stream has observed events` + }); + } + } + + return result; +}; + +export const parseCausalBundleBytes = (jsonl: Uint8Array): BundleParseResult => { + const hasBom = jsonl.length >= 3 && jsonl[0] === 0xef && jsonl[1] === 0xbb && jsonl[2] === 0xbf; + if (hasBom) { + return { + digestDomains: [], + errors: [{ line: 1, message: "invalid JSON: leading BOM is not allowed" }], + events: [], + streamFinals: [] + }; + } + + try { + return parseCausalBundle(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(jsonl)); + } catch (error) { + return { + digestDomains: [], + errors: [{ line: 1, message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }], + events: [], + streamFinals: [] + }; + } +}; diff --git a/src/contracts/canonicalJson.test.ts b/src/contracts/canonicalJson.test.ts new file mode 100644 index 0000000..9d1e24f --- /dev/null +++ b/src/contracts/canonicalJson.test.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { canonicalJsonBytes, canonicalJsonStringify, parseCanonicalJson, parseCanonicalJsonBytes } from "./canonicalJson.js"; + +describe("parseCanonicalJson", () => { + it("rejects duplicate decoded object keys", () => { + expect(() => parseCanonicalJson(`{"a":1,"a":2}`)).toThrow(/duplicate key/); + }); + + it("rejects escaped duplicate decoded keys", () => { + expect(() => parseCanonicalJson(`{"a":1,"\\u0061":2}`)).toThrow(/duplicate key/); + }); + + it("rejects lone surrogate escapes", () => { + expect(() => parseCanonicalJson(`{"value":"\\uD800"}`)).toThrow(/invalid high surrogate/i); + }); + + it("rejects lone surrogates in object keys", () => { + expect(() => parseCanonicalJson(`{"\\uD800":1}`)).toThrow(/lone high surrogate|invalid high surrogate/i); + }); + + it("does not treat BOM as trim whitespace", () => { + expect(() => parseCanonicalJson("\uFEFF{\"a\":1}")).toThrow(/invalid token/i); + }); + + it("accepts only JSON whitespace as trim boundaries", () => { + expect(() => parseCanonicalJson("\u000B{\"a\":1}\u000B")).toThrow(/invalid token/i); + }); + + it("accepts tab and CR/LF as boundaries", () => { + expect(parseCanonicalJson("\t{\n\"a\":1\r\n}")).toEqual({ a: 1 }); + }); + + it("treats duplicate escaped keys as duplicates even with proto-like names", () => { + expect(() => parseCanonicalJson(`{"__proto__":1,"\\u005f\\u005fproto__":2}`)).toThrow(/duplicate key/); + }); + + it("accepts finite decimal fractions", () => { + expect(parseCanonicalJson(`{"n":1.5}`)).toEqual({ n: 1.5 }); + }); + + it("keeps __proto__ as an own data property", () => { + const parsed = parseCanonicalJson(`{"__proto__":1,"safe":2}`) as Record; + expect(Object.getPrototypeOf(parsed)).toBe(null); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + }); + + it("rejects malformed UTF-8 byte input", () => { + expect(() => parseCanonicalJsonBytes(new Uint8Array([0x80, 0xFF]))).toThrow(/invalid utf-8|UTF-8/i); + }); + + it("rejects leading UTF-8 BOM byte sequence", () => { + expect(() => parseCanonicalJsonBytes(new Uint8Array([0xef, 0xbb, 0xbf, 0x7b, 0x7d]))).toThrow(/leading BOM|BOM/i); + }); + + it("accepts ordinary UTF-8 byte input", () => { + expect(parseCanonicalJsonBytes(new TextEncoder().encode("{\"a\":1}"))).toEqual({ a: 1 }); + }); +}); + +describe("canonicalJsonStringify", () => { + it("sorts UTF-16 object keys deterministically", () => { + expect(canonicalJsonStringify({ b: 1, a: 1, "Ω": 1 })).toBe('{"a":1,"b":1,"Ω":1}'); + }); + + it("preserves array order", () => { + expect(canonicalJsonStringify([{ z: 1 }, { a: 1 }])).toBe('[{"z":1},{"a":1}]'); + }); + + it("rejects negative zero", () => { + expect(() => canonicalJsonStringify({ value: -0 })).toThrow(/negative zero/); + }); + + it("rejects unsafe integers", () => { + expect(() => canonicalJsonStringify({ value: Number.MAX_SAFE_INTEGER + 1 })).toThrow(/unsafe integer/); + }); + + it("matches ECMAScript JSON.stringify number spellings", () => { + const safeCases = [ + { value: 0, expected: "0" }, + { value: 0.000001, expected: "0.000001" }, + { value: -0.000001, expected: "-0.000001" }, + { value: 1e-7, expected: "1e-7" }, + { value: -1e-7, expected: "-1e-7" }, + { value: 0.00000095, expected: "9.5e-7" }, + { value: -0.00000095, expected: "-9.5e-7" }, + { value: 0.0000015, expected: "0.0000015" }, + { value: -0.0000015, expected: "-0.0000015" }, + { value: 0.125, expected: "0.125" }, + { value: -0.125, expected: "-0.125" }, + { value: 2.2250738585072014e-308, expected: "2.2250738585072014e-308" }, + { value: -2.2250738585072014e-308, expected: "-2.2250738585072014e-308" }, + { value: 1.25, expected: "1.25" }, + { value: -1.25, expected: "-1.25" }, + { value: 999999.999999, expected: "999999.999999" }, + { value: -999999.999999, expected: "-999999.999999" }, + { value: 1e-10, expected: "1e-10" }, + { value: 5e-324, expected: "5e-324" } + ]; + + for (const entry of safeCases) { + expect(JSON.stringify(entry.value)).toBe(entry.expected); + expect(canonicalJsonStringify({ value: entry.value })).toBe(`{"value":${entry.expected}}`); + } + + const unsafeSafeBoundaryCases = [ + { value: 1e20, expected: "100000000000000000000" }, + { value: -1e20, expected: "-100000000000000000000" }, + { value: 1e21, expected: "1e+21" }, + { value: -1e21, expected: "-1e+21" }, + { value: 1.7976931348623157e+308, expected: "1.7976931348623157e+308" } + ]; + + for (const entry of unsafeSafeBoundaryCases) { + expect(JSON.stringify(entry.value)).toBe(entry.expected); + expect(() => canonicalJsonStringify({ value: entry.value })).toThrow(/unsafe integer/); + } + }); + + it("freezes formatter-only threshold hex and SHA evidence", () => { + const evidence = [ + { + expected: "100000000000000000000", + canonicalHex: "7b2276223a3130303030303030303030303030303030303030307d", + canonicalSha256: "8e85c1241e1aebc42bf95fcc16c0fef8b6d1de48d57f31096b9a7be664c2cebb" + }, + { + expected: "1e+21", + canonicalHex: "7b2276223a31652b32317d", + canonicalSha256: "9e24d139b060bd89f98e510a1e34d48fade6aeb668a016e6fb2e296762a3aefa" + }, + { + expected: "-100000000000000000000", + canonicalHex: "7b2276223a2d3130303030303030303030303030303030303030307d", + canonicalSha256: "afc9b072f80459d6f66c82bf9dfe00d5b152c945b67d6a02e9ac640965802fee" + }, + { + expected: "-1e+21", + canonicalHex: "7b2276223a2d31652b32317d", + canonicalSha256: "e2c8234e1548bc80ba238ba3913aeb55c4c324df829c9c0641b7966a8a9b1067" + } + ]; + + for (const entry of evidence) { + const canonicalText = `{"v":${entry.expected}}`; + expect(Buffer.from(canonicalText).toString("hex")).toBe(entry.canonicalHex); + expect(createHash("sha256").update(canonicalText).digest("hex")).toBe(entry.canonicalSha256); + } + }); +}); + +describe("canonicalJsonBytes", () => { + it("returns canonical UTF-8 bytes", () => { + const bytes = canonicalJsonBytes({ b: 1, a: 1 }); + expect(bytes).toEqual(new TextEncoder().encode('{"a":1,"b":1}')); + }); +}); + +describe("canonical runtime validation", () => { + it("rejects sparse arrays", () => { + const sparse = [1]; + sparse[2] = 2; + expect(() => canonicalJsonStringify(sparse)).toThrow(/sparse array/); + }); + + it("rejects accessor properties", () => { + const target = {}; + Object.defineProperty(target, "value", { + enumerable: true, + get() { + return 1; + } + }); + expect(() => canonicalJsonStringify({ target })).toThrow(/accessors?/); + }); + + it("rejects non-plain objects", () => { + const value = new Date(); + expect(() => canonicalJsonStringify({ value })).toThrow(/non-plain object/); + }); + + it("rejects cycles", () => { + const value: Record = {}; + value.self = value; + expect(() => canonicalJsonStringify(value)).toThrow(/cyclic/); + }); + + it("rejects BigInt and symbol members", () => { + expect(() => canonicalJsonStringify({ value: 1n })).toThrow(/bigint/); + expect(() => canonicalJsonStringify({ value: Symbol("x") as unknown as string })).toThrow(/symbol/); + }); + + it("accepts plain objects with plain nested arrays", () => { + const value = { a: [{ b: 2 }, { c: 3 }] }; + expect(canonicalJsonStringify(value)).toBe('{"a":[{"b":2},{"c":3}]}'); + }); +}); diff --git a/src/contracts/canonicalJson.ts b/src/contracts/canonicalJson.ts new file mode 100644 index 0000000..0f11a54 --- /dev/null +++ b/src/contracts/canonicalJson.ts @@ -0,0 +1,302 @@ +import { TextDecoder, TextEncoder } from "node:util"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); +const utf8Bom = [0xef, 0xbb, 0xbf]; +const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r"; +const isDigit = (c: string) => c >= "0" && c <= "9"; +const isHex = (c: string) => /[0-9a-fA-F]/.test(c); +const utf16Less = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); +const trimJsonWhitespace = (input: string) => { + let start = 0; + while (isWs(input[start] ?? "")) start += 1; + let end = input.length; + while (end > start && isWs(input[end - 1])) end -= 1; + return input.slice(start, end); +}; + +const validateString = (value: string, path: string) => { + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(i + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new Error(`${path}: lone high surrogate`); + } + i += 1; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + throw new Error(`${path}: lone low surrogate`); + } + } +}; + +const validateNumber = (value: number, path: string) => { + if (!Number.isFinite(value)) throw new Error(`${path}: non-finite number`); + if (Object.is(value, -0)) throw new Error(`${path}: negative zero`); + if (Number.isInteger(value) && !Number.isSafeInteger(value)) throw new Error(`${path}: unsafe integer`); +}; + +const isPlainObject = (value: object): value is Record => { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +const isArrayIndex = (key: string) => /^(0|[1-9]\d*)$/.test(key); + +const canonicalize = (value: unknown, seen: Set, path: string): unknown => { + if (value === null) return value; + if (typeof value === "boolean") return value; + if (typeof value === "number") { + validateNumber(value, path); + return value; + } + if (typeof value === "string") { + validateString(value, path); + return value; + } + if (value === undefined || typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") { + throw new Error(`${path}: ${typeof value} is not canonical JSON`); + } + if (typeof value !== "object") throw new Error(`${path}: unsupported type`); + if (seen.has(value)) throw new Error(`${path}: cyclic value`); + seen.add(value); + + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path}: symbol-keyed value`); + for (const key of Object.getOwnPropertyNames(value)) { + if (key === "length") continue; + if (!isArrayIndex(key) || Number(key) >= value.length) throw new Error(`${path}: non-index array property ${key}`); + } + const out = new Array(value.length); + for (let i = 0; i < value.length; i += 1) { + if (!Object.prototype.hasOwnProperty.call(value, i)) throw new Error(`${path}: sparse array`); + const desc = Object.getOwnPropertyDescriptor(value, String(i)); + if (!desc || desc.get || desc.set || !desc.enumerable) throw new Error(`${path}: array accessor`); + out[i] = canonicalize(desc.value, seen, `${path}[${i}]`); + } + seen.delete(value); + return out; + } + + if (!isPlainObject(value)) throw new Error(`${path}: non-plain object`); + if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path}: symbol-keyed property`); + const descriptors = Object.getOwnPropertyDescriptors(value); + const entries: [string, unknown][] = []; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = descriptors[key]; + if (!descriptor || descriptor.get || descriptor.set || !descriptor.enumerable || !("value" in descriptor)) { + throw new Error(`${path}: accessor/non-enumerable property`); + } + entries.push([key, canonicalize(descriptor.value, seen, `${path}.${key}`)]); + } + seen.delete(value); + const sortedEntries = entries.sort((left, right) => utf16Less(left[0], right[0])); + const out = Object.create(null) as Record; + for (const [key, sortedValue] of sortedEntries) { + out[key] = sortedValue; + } + return out; +}; + +export const canonicalJsonBytes = (value: unknown): Uint8Array => encoder.encode(canonicalJsonStringify(value)); +export const canonicalJsonStringify = (value: unknown): string => JSON.stringify(canonicalize(value, new Set(), "root")); + +const hasBOM = (bytes: Uint8Array) => + bytes.length >= 3 && bytes[0] === utf8Bom[0] && bytes[1] === utf8Bom[1] && bytes[2] === utf8Bom[2]; + +class Parser { + private i = 0; + public constructor(private readonly text: string) {} + private skipWs() { + while (isWs(this.text[this.i] ?? "")) this.i += 1; + } + private parseHex4() { + let value = 0; + for (let n = 0; n < 4; n += 1) { + const ch = this.text[this.i]; + if (ch === undefined || !isHex(ch)) throw new Error(`invalid hex escape at position ${this.i}`); + value = value * 16 + Number.parseInt(ch, 16); + this.i += 1; + } + return value; + } + private parseUnicodeEscape() { + const high = this.parseHex4(); + if (high < 0xd800 || high > 0xdbff) return String.fromCharCode(high); + if (this.text[this.i] !== "\\" || this.text[this.i + 1] !== "u") throw new Error(`invalid high surrogate at position ${this.i}`); + this.i += 2; + const low = this.parseHex4(); + if (low < 0xdc00 || low > 0xdfff) throw new Error(`invalid low surrogate at position ${this.i}`); + return String.fromCodePoint(0x10000 + ((high - 0xd800) << 10) + (low - 0xdc00)); + } + private parseString(path: string) { + if (this.text[this.i] !== '"') throw new Error(`invalid string start at position ${this.i}`); + this.i += 1; + let out = ""; + while (this.i < this.text.length) { + const ch = this.text[this.i]; + if (ch === undefined) throw new Error(`unterminated string at position ${this.i}`); + if (ch === '"') { + this.i += 1; + validateString(out, path); + return out; + } + if (ch === "\\") { + const escaped = this.text[this.i + 1]; + if (escaped === undefined) throw new Error(`unterminated escape at position ${this.i}`); + const map: Record = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + if (Object.prototype.hasOwnProperty.call(map, escaped)) { + out += map[escaped]!; + this.i += 2; + continue; + } + if (escaped === "u") { + this.i += 2; + out += this.parseUnicodeEscape(); + continue; + } + throw new Error(`invalid escape \\${escaped} at position ${this.i}`); + } + const code = ch.charCodeAt(0); + if (code <= 0x1f) throw new Error(`control character must be escaped at position ${this.i}`); + if (code >= 0xd800 && code <= 0xdbff) { + const low = this.text.charCodeAt(this.i + 1); + if (low < 0xdc00 || low > 0xdfff) throw new Error(`invalid high surrogate at position ${this.i}`); + out += ch + (this.text[this.i + 1] ?? ""); + this.i += 2; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) throw new Error(`invalid low surrogate at position ${this.i}`); + out += ch; + this.i += 1; + } + throw new Error(`unterminated string at position ${this.i}`); + } + private parseNumber(path: string) { + const start = this.i; + if (this.text[this.i] === "-") this.i += 1; + if (this.text[this.i] === "0") { + this.i += 1; + if (isDigit(this.text[this.i] ?? "")) throw new Error(`invalid number at position ${start}`); + } else { + if (!isDigit(this.text[this.i] ?? "")) throw new Error(`invalid number at position ${start}`); + while (isDigit(this.text[this.i] ?? "")) this.i += 1; + } + if (this.text[this.i] === ".") { + this.i += 1; + if (!isDigit(this.text[this.i] ?? "")) throw new Error(`invalid number fraction at position ${start}`); + while (isDigit(this.text[this.i] ?? "")) this.i += 1; + } + if (this.text[this.i] === "e" || this.text[this.i] === "E") { + this.i += 1; + if (this.text[this.i] === "+" || this.text[this.i] === "-") this.i += 1; + if (!isDigit(this.text[this.i] ?? "")) throw new Error(`invalid number exponent at position ${start}`); + while (isDigit(this.text[this.i] ?? "")) this.i += 1; + } + const raw = this.text.slice(start, this.i); + const value = Number(raw); + validateNumber(value, `${path} ${raw}`); + return value; + } + private parseLiteral(token: string) { + if (!this.text.startsWith(token, this.i)) return undefined; + this.i += token.length; + return token === "true" ? true : token === "false" ? false : null; + } + private parseArray(path: string) { + this.i += 1; + const out: unknown[] = []; + while (this.i < this.text.length) { + this.skipWs(); + if (this.text[this.i] === "]") { + this.i += 1; + return out; + } + out.push(this.parseValue(`${path}[${out.length}]`)); + this.skipWs(); + if (this.text[this.i] === ",") { + this.i += 1; + this.skipWs(); + if (this.text[this.i] === "]") throw new Error(`invalid trailing comma at position ${this.i}`); + continue; + } + if (this.text[this.i] === "]") { + this.i += 1; + return out; + } + throw new Error(`invalid array terminator at position ${this.i}`); + } + throw new Error("unterminated array"); + } + private parseObject(path: string) { + this.i += 1; + const out: Record = Object.create(null); + const seenKeys = new Set(); + while (this.i < this.text.length) { + this.skipWs(); + if (this.text[this.i] === "}") { + this.i += 1; + return out; + } + if (this.text[this.i] !== '"') throw new Error(`invalid object key at position ${this.i}`); + const key = this.parseString(`${path} key`); + if (seenKeys.has(key)) throw new Error(`duplicate key "${key}"`); + seenKeys.add(key); + this.skipWs(); + if (this.text[this.i] !== ":") throw new Error(`invalid object key/value delimiter at position ${this.i}`); + this.i += 1; + this.skipWs(); + out[key] = this.parseValue(`${path}.${key}`); + this.skipWs(); + if (this.text[this.i] === ",") { + this.i += 1; + this.skipWs(); + if (this.text[this.i] === "}") throw new Error(`invalid trailing comma at position ${this.i}`); + continue; + } + if (this.text[this.i] === "}") { + this.i += 1; + return out; + } + throw new Error(`invalid object terminator at position ${this.i}`); + } + throw new Error("unterminated object"); + } + private parseValue(path: string) { + this.skipWs(); + const ch = this.text[this.i]; + if (ch === undefined) throw new Error("unexpected end of JSON"); + if (ch === "{") return this.parseObject(path); + if (ch === "[") return this.parseArray(path); + if (ch === '"') return this.parseString(`${path} value`); + if (ch === "-" || isDigit(ch)) return this.parseNumber(`${path} value`); + const literal = this.parseLiteral("true"); + if (literal !== undefined) return literal; + const literalFalse = this.parseLiteral("false"); + if (literalFalse !== undefined) return literalFalse; + const literalNull = this.parseLiteral("null"); + if (literalNull !== undefined) return literalNull; + throw new Error(`invalid token at position ${this.i}`); + } + public parse(): unknown { + const value = this.parseValue("root"); + this.skipWs(); + if (this.i !== this.text.length) throw new Error(`unexpected trailing content at position ${this.i}`); + return value; + } +} + +export const parseCanonicalJson = (input: string): unknown => { + const text = trimJsonWhitespace(input); + if (text.length === 0) throw new Error("empty JSON"); + return canonicalize(new Parser(text).parse(), new Set(), "root"); +}; + +export const parseCanonicalJsonBytes = (bytes: Uint8Array): unknown => { + if (hasBOM(bytes)) { + throw new Error("invalid UTF-8: leading BOM is not allowed"); + } + return parseCanonicalJson(decoder.decode(bytes)); +}; diff --git a/src/contracts/digestDomain.test.ts b/src/contracts/digestDomain.test.ts new file mode 100644 index 0000000..14b68d3 --- /dev/null +++ b/src/contracts/digestDomain.test.ts @@ -0,0 +1,226 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { + CAUSAL_DIGEST_DOMAIN_VERSION, + RECOGNIZED_DIGEST_LABELS, + SUPPORTED_CAUSAL_DIGEST_DECLARATIONS, + areCausalDigestDeclarationsComparable, + getCanonicalDigestDeclarationBytes, + parseCausalDigestDomain, + subjectHashBytesFromDigestDomain, + subjectHashFromDigestDomain, + validateCausalDigestDomain +} from "./digestDomain.js"; + +import { canonicalJsonBytes, parseCanonicalJson } from "./canonicalJson.js"; +import { parseCausalEvent } from "./envelope.js"; +import type { CausalDigestDomain, CausalDigestLabel } from "./digestDomain.js"; +import type { CausalEvent } from "./envelope.js"; + +const sha256Hex = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); + +const declarationByLabel = (label: (typeof RECOGNIZED_DIGEST_LABELS)[number]) => + SUPPORTED_CAUSAL_DIGEST_DECLARATIONS.find((declaration) => declaration.label === label); + +const canonicalJsonDomain = () => + declarationByLabel("causal-event/canonical-json") as (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]; + +const exactUtf8Domain = () => declarationByLabel("content/exact-utf8") as (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]; + +const exactBytesDomain = () => declarationByLabel("content/exact-bytes") as (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]; + +const goldenEvent: CausalEvent = { + cause_event_ids: [], + emitter: { + seq: 4, + stream_id: "network:room-1", + system: "moltnet" + }, + event_id: "moltnet:hashable-1", + payload: {}, + principal_id: "agent:agent-1", + recorded_at: "2026-07-09T00:00:00.000Z", + run_id: "run-1", + type: "message.accepted", + version: "noopolis.causal-event.v1" +}; + +const canonicalEventBytes = () => + canonicalJsonBytes(parseCausalEvent(goldenEvent)); + +describe("causal digest domain schema", () => { + it("accepts all exact supported declaration tuples", () => { + for (const declaration of SUPPORTED_CAUSAL_DIGEST_DECLARATIONS) { + expect(validateCausalDigestDomain(declaration).success).toBe(true); + } + }); + + it("rejects unknown labels", () => { + expect( + validateCausalDigestDomain({ + hash: "sha-256", + label: "content/unknown", + output: "lowercase-hex", + subject_bytes: "exact-bytes", + version: CAUSAL_DIGEST_DOMAIN_VERSION + } as unknown).success + ).toBe(false); + }); + + it("rejects unknown top-level fields", () => { + const result = validateCausalDigestDomain({ + hash: "sha-256", + label: "content/exact-bytes", + output: "lowercase-hex", + subject_bytes: "exact-bytes", + version: CAUSAL_DIGEST_DOMAIN_VERSION, + extra: true + } as { + hash: string; + label: string; + output: string; + subject_bytes: string; + version: string; + extra: boolean; + }); + expect(result.success).toBe(false); + }); + + it("accepts all labels and rejects one-field declaration tuple mutation", () => { + for (const declaration of SUPPORTED_CAUSAL_DIGEST_DECLARATIONS) { + expect(validateCausalDigestDomain(declaration).success).toBe(true); + expect(validateCausalDigestDomain({ ...declaration, hash: "sha-1" }).success).toBe(false); + expect(validateCausalDigestDomain({ ...declaration, output: "lowercase-HEX" }).success).toBe(false); + expect(validateCausalDigestDomain({ ...declaration, subject_bytes: "wrong-metadata" }).success).toBe(false); + } + }); + + it("computes canonical declaration bytes that ignore object member order", () => { + const declaration = declarationByLabel("content/exact-bytes"); + if (!declaration) throw new Error("missing exact-bytes declaration"); + + const original = getCanonicalDigestDeclarationBytes(declaration); + const reordered = getCanonicalDigestDeclarationBytes({ + output: declaration.output, + subject_bytes: declaration.subject_bytes, + version: declaration.version, + hash: declaration.hash, + label: declaration.label + } as CausalDigestDomain); + + expect(original).toEqual(reordered); + expect(Array.from(original)).toEqual(Array.from(reordered)); + }); + + it("computes identical declaration bytes from whitespace-varied spellings", () => { + const declaration = declarationByLabel("causal-event/canonical-json"); + if (!declaration) throw new Error("missing causal-event declaration"); + + const dense = `{"version":"${CAUSAL_DIGEST_DOMAIN_VERSION}","label":"causal-event/canonical-json","hash":"sha-256","subject_bytes":"noopolis.canonical-json.v1:utf-8","output":"lowercase-hex"}`; + const spaced = ` { \n "label": "causal-event/canonical-json" ,\n "version" : "${CAUSAL_DIGEST_DOMAIN_VERSION}" ,\n "subject_bytes" : "noopolis.canonical-json.v1:utf-8" ,\n "hash" : "sha-256" ,\n "output" : "lowercase-hex"\n } `; + + const byDense = parseCanonicalJson(dense); + const bySpaced = parseCanonicalJson(spaced); + + expect(areCausalDigestDeclarationsComparable(byDense, bySpaced)).toBe(true); + expect(getCanonicalDigestDeclarationBytes(declaration)).toEqual(getCanonicalDigestDeclarationBytes(byDense)); + expect(getCanonicalDigestDeclarationBytes(declaration)).toEqual(getCanonicalDigestDeclarationBytes(bySpaced)); + }); + + it("treats different declarations as not comparable", () => { + const canonicalJson = declarationByLabel("causal-event/canonical-json"); + const exactUtf8 = declarationByLabel("content/exact-utf8"); + if (!canonicalJson || !exactUtf8) throw new Error("missing declarations"); + expect(areCausalDigestDeclarationsComparable(canonicalJson, exactUtf8)).toBe(false); + }); +}); + +describe("digest domain hashing", () => { + const subjectsByLabel: Record = { + "causal-event/canonical-json": goldenEvent, + "content/exact-utf8": "exact UTF-8 subject", + "content/exact-bytes": new Uint8Array([0x00, 0x01, 0xff, 0x7a]) + }; + + const subjectHashHelpers: Array<{ + name: string; + fn: (domain: CausalDigestDomain, subject: unknown) => unknown; + }> = [ + { name: "subjectHashBytesFromDigestDomain", fn: subjectHashBytesFromDigestDomain }, + { name: "subjectHashFromDigestDomain", fn: subjectHashFromDigestDomain } + ]; + + it("validates runtime digest declarations for both subject hash exports", () => { + for (const declaration of SUPPORTED_CAUSAL_DIGEST_DECLARATIONS) { + const subject = subjectsByLabel[declaration.label]; + for (const helper of subjectHashHelpers) { + const expectThrow = (candidate: unknown): void => { + expect(() => helper.fn(candidate as CausalDigestDomain, subject)).toThrow(/invalid digest domain|unsupported digest label|lone surrogate/); + }; + + expectThrow({ ...declaration, subject_bytes: "wrong-subject-bytes" } as unknown as CausalDigestDomain); + expectThrow({ ...declaration, hash: "sha-1" } as unknown as CausalDigestDomain); + expectThrow({ ...declaration, output: "base64" } as unknown as CausalDigestDomain); + expectThrow({ ...declaration, version: "noopolis.causal-digest-domain.v2" } as unknown as CausalDigestDomain); + expectThrow({ ...declaration, label: "content/unknown" } as unknown as CausalDigestDomain); + expectThrow({ ...declaration, extra_field: true } as unknown as CausalDigestDomain); + } + } + }); + + it("hashes causal events from canonical-json declarations via canonical bytes", () => { + const declaration = canonicalJsonDomain(); + const eventInputSorted = JSON.stringify(goldenEvent); + const eventInputShuffled = `{"recorded_at":"${goldenEvent.recorded_at}","type":"${goldenEvent.type}","version":"noopolis.causal-event.v1","run_id":"${goldenEvent.run_id}","event_id":"${goldenEvent.event_id}","payload":{},"principal_id":"${goldenEvent.principal_id}","cause_event_ids":[],"emitter":{"system":"moltnet","stream_id":"network:room-1","seq":4}}`; + + const expected = sha256Hex(canonicalEventBytes()); + const objectInputHash = subjectHashFromDigestDomain(declaration as CausalDigestDomain, goldenEvent); + const canonicalInputHash = subjectHashFromDigestDomain(declaration as CausalDigestDomain, eventInputSorted); + const shuffledInputHash = subjectHashFromDigestDomain(declaration as CausalDigestDomain, eventInputShuffled); + + expect(objectInputHash).toBe(expected); + expect(canonicalInputHash).toBe(expected); + expect(shuffledInputHash).toBe(expected); + }); + + it("rejects non-object or malformed serialized canonical-json subjects", () => { + const declaration = canonicalJsonDomain(); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, 12345)).toThrow(); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, "{\"recorded_at\":true}")).toThrow(); + }); + + it("hashes exact UTF-8 strings preserving content and null/newline bytes", () => { + const declaration = exactUtf8Domain(); + const composed = "café\nline-2\u0000©"; + const decomposed = "cafe\u0301\nline-2\u0000©"; + + const composedHash = subjectHashFromDigestDomain(declaration as CausalDigestDomain, composed); + const decomposedHash = subjectHashFromDigestDomain(declaration as CausalDigestDomain, decomposed); + expect(composedHash).toBe(sha256Hex(new TextEncoder().encode(composed))); + expect(decomposedHash).toBe(sha256Hex(new TextEncoder().encode(decomposed))); + expect(composedHash).not.toBe(decomposedHash); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, "valid text")).not.toThrow(); + }); + + it("rejects exact UTF-8 subjects with lone surrogate", () => { + const declaration = exactUtf8Domain(); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, "a\uD800")).toThrow("lone surrogate"); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, "\uDC00a")).toThrow("lone surrogate"); + }); + + it("hashes exact byte subjects without transformation and rejects non-bytes", () => { + const declaration = exactBytesDomain(); + const bytes = new Uint8Array([0x00, 0xff, 0x10, 0x0a, 0x61, 0x62]); + + expect(subjectHashFromDigestDomain(declaration as CausalDigestDomain, bytes)).toBe(sha256Hex(bytes)); + expect(subjectHashBytesFromDigestDomain(declaration as CausalDigestDomain, bytes)).toEqual(bytes); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, bytes.toString())).toThrow("byte sequence"); + expect(() => subjectHashFromDigestDomain(declaration as CausalDigestDomain, [0x00, 0xff, 0x10])).toThrow("byte sequence"); + }); + + it("parses valid digest domains through parseCausalDigestDomain", () => { + const declaration = canonicalJsonDomain(); + expect(parseCausalDigestDomain(declaration as CausalDigestDomain)).toEqual(declaration); + }); +}); diff --git a/src/contracts/digestDomain.ts b/src/contracts/digestDomain.ts new file mode 100644 index 0000000..1c36310 --- /dev/null +++ b/src/contracts/digestDomain.ts @@ -0,0 +1,205 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +import { canonicalJsonBytes, parseCanonicalJson } from "./canonicalJson.js"; +import { parseCausalEvent } from "./envelope.js"; + +export const CAUSAL_DIGEST_DOMAIN_VERSION = "noopolis.causal-digest-domain.v1" as const; + +const encoder = new TextEncoder(); + +export const SUPPORTED_CAUSAL_DIGEST_DECLARATIONS = [ + { + hash: "sha-256", + label: "causal-event/canonical-json", + output: "lowercase-hex", + subject_bytes: "noopolis.canonical-json.v1:utf-8", + version: CAUSAL_DIGEST_DOMAIN_VERSION + }, + { + hash: "sha-256", + label: "content/exact-utf8", + output: "lowercase-hex", + subject_bytes: "exact-utf8", + version: CAUSAL_DIGEST_DOMAIN_VERSION + }, + { + hash: "sha-256", + label: "content/exact-bytes", + output: "lowercase-hex", + subject_bytes: "exact-bytes", + version: CAUSAL_DIGEST_DOMAIN_VERSION + } +] as const; + +export type CausalDigestLabel = (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]["label"]; +export type CausalDigestTupleHash = (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]["hash"]; +export type CausalDigestTupleOutput = (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]["output"]; +export type CausalDigestSubjectBytesType = (typeof SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)[number]["subject_bytes"]; + +const CAUSAL_DIGEST_LABELS = SUPPORTED_CAUSAL_DIGEST_DECLARATIONS.map((entry) => entry.label) as [ + "causal-event/canonical-json", + "content/exact-utf8", + "content/exact-bytes" +]; + +export const RECOGNIZED_DIGEST_LABELS = CAUSAL_DIGEST_LABELS; + +export interface CausalDigestDomain { + version: typeof CAUSAL_DIGEST_DOMAIN_VERSION; + label: CausalDigestLabel; + hash: CausalDigestTupleHash; + subject_bytes: CausalDigestSubjectBytesType; + output: CausalDigestTupleOutput; +} + +const canonicalEventDigestDeclarationSchema = z + .object({ + hash: z.literal("sha-256"), + label: z.literal("causal-event/canonical-json"), + output: z.literal("lowercase-hex"), + subject_bytes: z.literal("noopolis.canonical-json.v1:utf-8"), + version: z.literal(CAUSAL_DIGEST_DOMAIN_VERSION) + }) + .strict(); + +const exactUtf8DigestDeclarationSchema = z + .object({ + hash: z.literal("sha-256"), + label: z.literal("content/exact-utf8"), + output: z.literal("lowercase-hex"), + subject_bytes: z.literal("exact-utf8"), + version: z.literal(CAUSAL_DIGEST_DOMAIN_VERSION) + }) + .strict(); + +const exactBytesDigestDeclarationSchema = z + .object({ + hash: z.literal("sha-256"), + label: z.literal("content/exact-bytes"), + output: z.literal("lowercase-hex"), + subject_bytes: z.literal("exact-bytes"), + version: z.literal(CAUSAL_DIGEST_DOMAIN_VERSION) + }) + .strict(); + +const causalDigestDomainSchema = z.discriminatedUnion("label", [ + canonicalEventDigestDeclarationSchema, + exactUtf8DigestDeclarationSchema, + exactBytesDigestDeclarationSchema +]); + +const hashSha256 = (input: Uint8Array): string => createHash("sha256").update(input).digest("hex"); + +const validateUtf16String = (value: string): void => { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) { + throw new Error("subject text contains a lone surrogate"); + } + index += 1; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + throw new Error("subject text contains a lone surrogate"); + } + } +}; + +const canonicalBytesFromDomain = (domain: CausalDigestDomain): Uint8Array => + canonicalJsonBytes({ + hash: domain.hash, + label: domain.label, + output: domain.output, + subject_bytes: domain.subject_bytes, + version: domain.version + }); + +const areCanonicalBytesEqual = (left: Uint8Array, right: Uint8Array): boolean => { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +}; + +export const getCanonicalDigestDeclarationBytes = (value: unknown): Uint8Array => { + const result = validateCausalDigestDomain(value); + if (!result.success) { + throw new Error( + `invalid digest domain: ${result.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; ")}` + ); + } + return canonicalBytesFromDomain(result.data); +}; + +export const areCausalDigestDeclarationsComparable = (left: unknown, right: unknown): boolean => { + try { + const leftBytes = getCanonicalDigestDeclarationBytes(left); + const rightBytes = getCanonicalDigestDeclarationBytes(right); + return areCanonicalBytesEqual(leftBytes, rightBytes); + } catch { + return false; + } +}; + +export const validateCausalDigestDomain = (value: unknown): z.ZodSafeParseResult => + causalDigestDomainSchema.safeParse(value) as z.ZodSafeParseResult; + +export const parseCausalDigestDomain = (value: unknown): CausalDigestDomain => { + const result = validateCausalDigestDomain(value); + if (!result.success) { + throw new Error( + `invalid digest domain: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` + ); + } + return result.data; +}; + +const subjectBytesForCanonicalJson = (subject: unknown): Uint8Array => { + if (typeof subject === "string") { + return canonicalJsonBytes(parseCausalEvent(parseCanonicalJson(subject))); + } + return canonicalJsonBytes(parseCausalEvent(subject)); +}; + +const subjectBytesForExactUtf8 = (subject: unknown): Uint8Array => { + if (typeof subject !== "string") { + throw new Error("subject must be a UTF-8 string"); + } + validateUtf16String(subject); + return encoder.encode(subject); +}; + +const subjectBytesForExactBytes = (subject: unknown): Uint8Array => { + if (!(subject instanceof Uint8Array)) { + throw new Error("subject must be a byte sequence"); + } + return subject; +}; + +const subjectBytesForDigestDomain = (domain: CausalDigestDomain, subject: unknown): Uint8Array => { + const parsedDomain = parseCausalDigestDomain(domain); + if (parsedDomain.label === "causal-event/canonical-json") { + return subjectBytesForCanonicalJson(subject); + } + if (parsedDomain.label === "content/exact-utf8") { + return subjectBytesForExactUtf8(subject); + } + if (parsedDomain.label === "content/exact-bytes") { + return subjectBytesForExactBytes(subject); + } + throw new Error(`unsupported digest label ${parsedDomain.label}`); +}; + +export const subjectHashBytesFromDigestDomain = (domain: CausalDigestDomain, subject: unknown): Uint8Array => { + return subjectBytesForDigestDomain(domain, subject); +}; + +export const subjectHashFromDigestDomain = (domain: CausalDigestDomain, subject: unknown): string => { + return hashSha256(subjectBytesForDigestDomain(domain, subject)); +}; diff --git a/src/contracts/envelope.test.ts b/src/contracts/envelope.test.ts new file mode 100644 index 0000000..e110d39 --- /dev/null +++ b/src/contracts/envelope.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "vitest"; + +import { + CAUSAL_EVENT_VERSION, + canonicalJsonStringify, + causalEventSchema, + hashCausalEvent, + parseCausalEvent, + parseCausalJsonl, + parseCausalJsonlBytes, + validateCausalEvent +} from "./envelope.js"; +import type { CausalEvent } from "./envelope.js"; + +const goldenRecord: CausalEvent = { + cause_event_ids: [], + emitter: { seq: 1, stream_id: "network:room-1", system: "moltnet" }, + event_id: "moltnet:m1", + payload: { content_sha256: "a".repeat(64), message_id: "m1" }, + principal_id: "agent:agent-1", + recorded_at: "2026-07-09T00:00:00.000Z", + run_id: "run-1", + type: "message.accepted", + version: CAUSAL_EVENT_VERSION +}; + +describe("causalEventSchema", () => { + it("accepts a golden record byte-for-byte", () => { + const result = validateCausalEvent(goldenRecord); + expect(result.success).toBe(true); + expect(result.success && result.data).toEqual(goldenRecord); + }); + + it("rejects unknown top-level fields", () => { + const result = validateCausalEvent({ ...goldenRecord, extra: true }); + expect(result.success).toBe(false); + }); + + it("rejects unknown emitter system", () => { + const result = validateCausalEvent({ + ...goldenRecord, + emitter: { ...goldenRecord.emitter, system: "bogus" } + }); + expect(result.success).toBe(false); + }); + + it("rejects unsafe seq integers", () => { + const result = validateCausalEvent({ + ...goldenRecord, + emitter: { ...goldenRecord.emitter, seq: Number.MAX_SAFE_INTEGER + 1 } + }); + expect(result.success).toBe(false); + }); + + it("rejects seq below one", () => { + const result = validateCausalEvent({ + ...goldenRecord, + emitter: { ...goldenRecord.emitter, seq: 0 } + }); + expect(result.success).toBe(false); + }); + + it("rejects event_id that does not match emitter", () => { + const result = validateCausalEvent({ ...goldenRecord, event_id: "simfile:m1" }); + expect(result.success).toBe(false); + }); + + it("rejects malformed cause ids", () => { + const result = validateCausalEvent({ + ...goldenRecord, + cause_event_ids: ["missing-colon"] + }); + expect(result.success).toBe(false); + }); + + it("rejects duplicate cause ids while preserving order", () => { + const result = validateCausalEvent({ + ...goldenRecord, + cause_event_ids: ["simfile:parent-1", "simfile:parent-1"] + }); + expect(result.success).toBe(false); + }); + + it("accepts each recognized emitter system", () => { + for (const system of ["simfile", "moltnet", "mneme", "daimon"] as const) { + const result = validateCausalEvent({ + ...goldenRecord, + emitter: { ...goldenRecord.emitter, system }, + event_id: `${system}:local-1` + }); + expect(result.success).toBe(true); + } + }); + + it("accepts principal_id grammar", () => { + const result = validateCausalEvent({ + ...goldenRecord, + principal_id: "system:simfile.world" + }); + expect(result.success).toBe(true); + }); + + it("rejects broad-date recorded_at values", () => { + const result = validateCausalEvent({ + ...goldenRecord, + recorded_at: "2026-07-09 00:00:00" + }); + expect(result.success).toBe(false); + }); + + it("accepts valid leap-day recorded_at", () => { + const result = validateCausalEvent({ + ...goldenRecord, + event_id: "moltnet:local-2", + emitter: { ...goldenRecord.emitter, stream_id: "stream-2" }, + recorded_at: "2024-02-29T00:00:00Z" + }); + expect(result.success).toBe(true); + }); + + it("rejects impossible recorded_at dates and offsets", () => { + expect( + validateCausalEvent({ + ...goldenRecord, + event_id: "moltnet:local-3", + emitter: { ...goldenRecord.emitter, stream_id: "stream-3" }, + recorded_at: "2026-02-29T00:00:00Z" + }).success + ).toBe(false); + + expect( + validateCausalEvent({ + ...goldenRecord, + event_id: "moltnet:local-4", + emitter: { ...goldenRecord.emitter, stream_id: "stream-4" }, + recorded_at: "2026-04-31T00:00:00Z" + }).success + ).toBe(false); + + expect( + validateCausalEvent({ + ...goldenRecord, + event_id: "moltnet:local-5", + emitter: { ...goldenRecord.emitter, stream_id: "stream-5" }, + recorded_at: "2026-01-01T00:00:00+24:00" + }).success + ).toBe(false); + + expect( + validateCausalEvent({ + ...goldenRecord, + event_id: "moltnet:local-6", + emitter: { ...goldenRecord.emitter, stream_id: "stream-6" }, + recorded_at: "2026-07-31T24:00:00Z" + }).success + ).toBe(false); + }); + + it("accepts recorded_at with timezone offsets and fractional seconds", () => { + const result = validateCausalEvent({ + ...goldenRecord, + recorded_at: "2026-07-09T00:00:00.123+02:30", + event_id: "moltnet:room:1", + emitter: { ...goldenRecord.emitter, stream_id: "room:1" } + }); + expect(result.success).toBe(true); + }); +}); + +describe("parseCausalEvent", () => { + it("returns a parsed event for a valid record", () => { + expect(parseCausalEvent(goldenRecord)).toEqual(goldenRecord); + }); +}); + +describe("parseCausalJsonl", () => { + it("keeps blank lines", () => { + const jsonl = `${JSON.stringify(goldenRecord)}\n\n${JSON.stringify({ + ...goldenRecord, + emitter: { ...goldenRecord.emitter, seq: 2 }, + event_id: "moltnet:m2" + })}\n`; + const { errors, events } = parseCausalJsonl(jsonl); + expect(errors).toEqual([]); + expect(events).toHaveLength(2); + }); + + it("aggregates raw JSON duplicate-key errors with line numbers", () => { + const jsonl = `{"v":1,"v":2}\n`; + const { errors, events } = parseCausalJsonl(jsonl); + expect(events).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 1, message: expect.stringContaining("duplicate key") }); + }); + + it("aggregates escaped-key duplicate errors with line numbers", () => { + const jsonl = `{"a":1,"\\u0061":2}\n`; + const { errors, events } = parseCausalJsonl(jsonl); + expect(events).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ line: 1, message: expect.stringContaining("invalid JSON: duplicate key") }); + }); + + it("reports schema errors separately from parse errors", () => { + const jsonl = `{"not":"record"}\n`; + const { errors, events } = parseCausalJsonl(jsonl); + expect(events).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]?.line).toBe(1); + }); + + it("labels malformed JSON with an invalid JSON prefix", () => { + const { errors } = parseCausalJsonl(`{`); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.stringContaining("invalid JSON:") + }); + }); + + it("accepts valid leap-day JSONL records", () => { + const { errors, events } = parseCausalJsonl( + `{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"moltnet:m3","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2024-02-29T00:00:00Z","cause_event_ids":[],"payload":{}}\n` + ); + expect(errors).toEqual([]); + expect(events).toHaveLength(1); + }); + + it("rejects impossible recorded_at dates in JSONL parsing", () => { + const { errors } = parseCausalJsonl( + `{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"moltnet:m4","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2026-04-31T00:00:00Z","cause_event_ids":[],"payload":{}}\n` + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.not.stringContaining("invalid JSON:") + }); + }); + + it("reports malformed bytes as invalid JSON", () => { + const { errors } = parseCausalJsonlBytes(Uint8Array.from([0xff])); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.stringContaining("invalid JSON:") + }); + }); + + it("rejects BOM-prefixed JSONL bytes", () => { + const bytes = new TextEncoder().encode( + `\uFEFF{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"moltnet:m5","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2026-07-09T00:00:00.000Z","cause_event_ids":[],"payload":{}}` + ); + const { errors } = parseCausalJsonlBytes(bytes); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.stringContaining("invalid JSON:") + }); + }); + + it("accepts ordinary UTF-8 JSONL bytes", () => { + const { errors, events } = parseCausalJsonlBytes( + new TextEncoder().encode( + `${JSON.stringify({ + ...goldenRecord, + event_id: "moltnet:m6", + emitter: { ...goldenRecord.emitter, stream_id: "network:room-1", seq: 1 } + })}\n` + ) + ); + expect(errors).toEqual([]); + expect(events).toHaveLength(1); + }); + + it("rejects a runtime hostile JSON number", () => { + const jsonl = `{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"moltnet:m1","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2026-07-09T00:00:00.000Z","cause_event_ids":[],"payload":{"value":-0}}\n`; + const { errors, events } = parseCausalJsonl(jsonl); + expect(events).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]?.line).toBe(1); + }); + + it("rejects lone surrogate in keys as a JSON parse error", () => { + const { errors } = parseCausalJsonl(`{"\\uD800":1}\n`); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + line: 1, + message: expect.stringContaining("invalid JSON:") + }); + }); + + it("keeps malformed id in schema validation layer", () => { + const { errors } = parseCausalJsonl(`{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"bad","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2026-07-09T00:00:00.000Z","cause_event_ids":[],"payload":{}}\n`); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ line: 1, message: expect.not.stringContaining("invalid JSON:") }); + }); +}); + +describe("canonical hashing", () => { + it("is deterministic across key order", () => { + const reordered = { + version: goldenRecord.version, + type: goldenRecord.type, + run_id: goldenRecord.run_id, + recorded_at: goldenRecord.recorded_at, + principal_id: goldenRecord.principal_id, + payload: goldenRecord.payload, + event_id: goldenRecord.event_id, + emitter: goldenRecord.emitter, + cause_event_ids: goldenRecord.cause_event_ids + }; + + expect(hashCausalEvent(goldenRecord)).toBe(hashCausalEvent(reordered)); + }); + + it("differs when payload data differs", () => { + const mutated = { + ...goldenRecord, + payload: { ...goldenRecord.payload, content_sha256: "b".repeat(64) } + }; + expect(hashCausalEvent(goldenRecord)).not.toBe(hashCausalEvent(mutated)); + }); +}); + +describe("causalJsonStringify", () => { + it("sorts keys and rejects non-finite values through the canonical helper", () => { + expect(canonicalJsonStringify(goldenRecord)).toContain('"cause_event_ids":[]'); + }); +}); diff --git a/src/contracts/envelope.ts b/src/contracts/envelope.ts new file mode 100644 index 0000000..13dd97b --- /dev/null +++ b/src/contracts/envelope.ts @@ -0,0 +1,213 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { + assertUniqueCauseEventIds, + parseCausalCauseId, + parseCausalEventId, + type CausalEventSystem +} from "./ids.js"; +import { canonicalJsonBytes, parseCanonicalJson } from "./canonicalJson.js"; +import { TextDecoder } from "node:util"; + +export { canonicalJsonStringify } from "./canonicalJson.js"; + +export type { CausalEventSystem } from "./ids.js"; + +export const CAUSAL_EVENT_VERSION = "noopolis.causal-event.v1" as const; + +export const PRINCIPAL_GRAMMAR_SOURCE = "^(agent|operator|system):.+"; +const DATE_TIME_ZONED_PATTERN = + /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d+)?(?:Z|([+-])([01]\d|2[0-3]):([0-5]\d))$/; +const isLeapYear = (year: number) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +const daysInMonth = (year: number, month: number) => { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } +}; +const isValidCalendarDate = (year: number, month: number, day: number) => day >= 1 && day <= daysInMonth(year, month); +const isRecordedAt = (value: string) => { + const match = value.match(DATE_TIME_ZONED_PATTERN); + if (!match) return false; + + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + if (!isValidCalendarDate(year, month, day)) return false; + return true; +}; +const isJsonWhitespace = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r"; +const trimJsonWhitespace = (input: string) => { + let start = 0; + while (isJsonWhitespace(input[start] ?? "")) start += 1; + let end = input.length; + while (end > start && isJsonWhitespace(input[end - 1])) end -= 1; + return input.slice(start, end); +}; + +export interface CausalEventEmitter { + system: CausalEventSystem; + seq: number; + stream_id: string; +} + +export interface CausalEvent> { + cause_event_ids: string[]; + emitter: CausalEventEmitter; + event_id: string; + payload: TPayload; + principal_id: string; + recorded_at: string; + run_id: string; + type: string; + version: typeof CAUSAL_EVENT_VERSION; +} + +const causalEventEmitterSchema = z.object({ + seq: z.number().int().min(1).superRefine((value, context) => { + if (!Number.isSafeInteger(value)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "must be a safe integer", + path: ["seq"] + }); + } + }), + stream_id: z.string().min(1), + system: z.enum(["simfile", "moltnet", "mneme", "daimon"] as const) +}); + +export const causalEventSchema = z + .object({ + cause_event_ids: z + .array(z.string().min(1)) + .superRefine((causes, context) => { + try { + for (const cause of causes) { + parseCausalCauseId(cause); + } + assertUniqueCauseEventIds(causes); + } catch (error) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : String(error), + path: ["cause_event_ids"] + }); + } + }), + emitter: causalEventEmitterSchema.strict(), + event_id: z.string().min(2), + payload: z.record(z.string(), z.unknown()), + principal_id: z.string().regex(new RegExp(PRINCIPAL_GRAMMAR_SOURCE)), + recorded_at: z.string().refine(isRecordedAt, "recorded_at must be an RFC 3339 compatible timestamp"), + run_id: z.string().min(1), + type: z.string().min(1), + version: z.literal(CAUSAL_EVENT_VERSION) + }) + .strict() + .superRefine((value, context) => { + try { + parseCausalEventId(value.event_id); + const eventId = value.event_id; + if (!eventId.startsWith(`${value.emitter.system}:`)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "event_id system prefix must match emitter.system", + path: ["event_id"] + }); + } + } catch (error) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : String(error), + path: ["event_id"] + }); + } + + return; + }); + +export const validateCausalEvent = (value: unknown): z.ZodSafeParseResult => + causalEventSchema.safeParse(value) as z.ZodSafeParseResult; + +export const parseCausalEvent = (value: unknown): CausalEvent => { + const result = validateCausalEvent(value); + if (!result.success) { + throw new Error( + `invalid causal event: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` + ); + } + return result.data; +}; + +export interface CausalJsonlParseError { + line: number; + message: string; +} + +export interface CausalJsonlParseResult { + errors: CausalJsonlParseError[]; + events: CausalEvent[]; +} + +export const parseCausalJsonl = (jsonl: string): CausalJsonlParseResult => { + const events: CausalEvent[] = []; + const errors: CausalJsonlParseError[] = []; + + const lines = jsonl.split("\n"); + lines.forEach((rawLine, index) => { + const line = trimJsonWhitespace(rawLine); + if (line.length === 0) { + return; + } + + const lineNumber = index + 1; + let parsed: unknown; + try { + parsed = parseCanonicalJson(line); + } catch (error) { + errors.push({ + line: lineNumber, + message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` + }); + return; + } + + const result = validateCausalEvent(parsed); + if (!result.success) { + errors.push({ + line: lineNumber, + message: result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ") + }); + return; + } + events.push(result.data); + }); + + return { errors, events }; +}; + +export const parseCausalJsonlBytes = (jsonl: Uint8Array): CausalJsonlParseResult => { + const hasBom = jsonl.length >= 3 && jsonl[0] === 0xef && jsonl[1] === 0xbb && jsonl[2] === 0xbf; + try { + if (hasBom) throw new Error("invalid UTF-8: leading BOM is not allowed"); + return parseCausalJsonl(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(jsonl)); + } catch (error) { + return { + events: [], + errors: [{ line: 1, message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }] + }; + } +}; + +export const hashCausalEvent = (event: CausalEvent): string => + createHash("sha256").update(canonicalJsonBytes(event)).digest("hex"); diff --git a/src/contracts/golden.test.ts b/src/contracts/golden.test.ts new file mode 100644 index 0000000..d4a3b48 --- /dev/null +++ b/src/contracts/golden.test.ts @@ -0,0 +1,243 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { canonicalJsonStringify, parseCanonicalJson } from "./canonicalJson.js"; +import { parseCausalBundle } from "./bundle.js"; + +interface GoldenCase { + name: string; + accept: boolean; + canonical_hex: string | null; + raw_jsonl: string; + sha256: string | null; +} + +const EXPECTED_MANIFEST_KEYS = ["accept", "canonical_hex", "name", "raw_jsonl", "sha256"]; + +const validateParsedCase = (entry: unknown): GoldenCase => { + expect(typeof entry).toBe("object"); + expect(entry).not.toBeNull(); + + const manifest = entry as Record; + expect(Object.keys(manifest).sort()).toEqual(EXPECTED_MANIFEST_KEYS); + + expect(typeof manifest.name).toBe("string"); + expect(typeof manifest.accept).toBe("boolean"); + const canonical = manifest.canonical_hex; + expect(canonical === null || typeof canonical === "string").toBe(true); + expect(typeof manifest.raw_jsonl).toBe("string"); + const sha = manifest.sha256; + expect(sha === null || typeof sha === "string").toBe(true); + + return manifest as unknown as GoldenCase; +}; + +const loadGoldenCases = (): GoldenCase[] => { + const fileUrl = new URL("./goldens/causal-contract.v1.json", import.meta.url); + const raw = readFileSync(fileUrl, "utf8"); + const parsed = JSON.parse(raw) as unknown; + + expect(Array.isArray(parsed)).toBe(true); + const cases = parsed as unknown[]; + for (const entry of cases) { + validateParsedCase(entry); + } + + const normalized = cases as GoldenCase[]; + expect(normalized.length).toBeGreaterThan(0); + + const canonicalHexPattern = /^[0-9a-f]{2,}$/u; + const shaPattern = /^[0-9a-f]{64}$/u; + + for (const entry of normalized) { + if (entry.accept) { + expect(entry.canonical_hex).not.toBeNull(); + expect(entry.sha256).not.toBeNull(); + if (entry.canonical_hex !== null) { + expect(canonicalHexPattern.test(entry.canonical_hex)).toBe(true); + } + if (entry.sha256 !== null) { + expect(shaPattern.test(entry.sha256)).toBe(true); + } + continue; + } + + expect(entry.canonical_hex).toBeNull(); + expect(entry.sha256).toBeNull(); + } + + return normalized; +}; + +const cases = loadGoldenCases(); + +const expectedAccepted = [ + "accept-normative-event", + "accept-nested-reordering-and-unicode", + "accept-empty-payload", + "accept-finite-fraction", + "accept-positive-1e-6-fixed", + "accept-negative-1e-6-fixed", + "accept-positive-1e-7-exponent", + "accept-negative-1e-7-exponent", + "accept-positive-below-1e-6", + "accept-negative-below-1e-6", + "accept-positive-above-1e-6", + "accept-negative-above-1e-6", + "accept-digest-domain-causal-event", + "accept-digest-domain-exact-utf8", + "accept-digest-domain-exact-bytes", + "accept-final-seq-zero", + "accept-non-empty-final", + "accept-multi-stream" +]; + +const expectedRejected = [ + "reject-unknown-event-top-level", + "reject-unknown-event-emitter-field", + "reject-unknown-final-top-level", + "reject-unknown-final-emitter-field", + "reject-unknown-digest-top-level", + "reject-wrong-version-event", + "reject-wrong-version-final", + "reject-wrong-version-digest", + "reject-malformed-event-id", + "reject-unrecognized-event-id-system", + "reject-mismatched-event-id-prefix", + "reject-unrecognized-cause-id", + "reject-repeated-causes", + "reject-duplicate-event-id", + "reject-duplicate-stream-slot", + "reject-present-cross-run-cause", + "reject-duplicate-final", + "reject-below-observed-final", + "reject-empty-final-contradiction", + "reject-unknown-digest-domain", + "reject-altered-digest-hash", + "reject-altered-digest-subject-bytes", + "reject-altered-digest-output", + "reject-duplicate-decoded-key", + "reject-escaped-equivalent-keys", + "reject-negative-zero", + "reject-unsafe-integer", + "reject-invalid-unicode", + "reject-invalid-json-spelling", + "reject-non-finite-json-spelling" +]; + + const normalizeNameInventory = (values: string[]) => new Set(values); + +describe("causal contract golden corpus", () => { + it("validates strict manifest shape and unique names", () => { + expect(cases.length).toBe(expectedAccepted.length + expectedRejected.length); + expect( + cases.map((entry) => entry.name).filter((name, index, all) => all.indexOf(name) !== index) + ).toEqual([]); + + const seen = new Set(); + const accepted = []; + const rejected = []; + for (const entry of cases) { + if (entry.accept) { + accepted.push(entry.name); + expect(entry.canonical_hex).not.toBeNull(); + expect(entry.sha256).not.toBeNull(); + expect(/^[0-9a-f]{2,}$/.test(entry.canonical_hex!)).toBe(true); + expect(/^[0-9a-f]{64}$/.test(entry.sha256!)).toBe(true); + } else { + rejected.push(entry.name); + expect(entry.canonical_hex).toBeNull(); + expect(entry.sha256).toBeNull(); + } + + expect(typeof entry.raw_jsonl).toBe("string"); + expect(entry.raw_jsonl.length).toBeGreaterThan(0); + expect(entry.name.length).toBeGreaterThan(0); + seen.add(entry.name); + } + expect(seen.size).toBe(cases.length); + + expect(new Set(accepted)).toEqual(normalizeNameInventory(expectedAccepted)); + expect(new Set(rejected)).toEqual(normalizeNameInventory(expectedRejected)); + for (const name of expectedAccepted) { + const found = accepted.includes(name); + expect(found).toBe(true); + } + for (const name of expectedRejected) { + const found = rejected.includes(name); + expect(found).toBe(true); + } + }); + + it("validates accepted cases through the bundle preflight and literal hash oracles", () => { + const accepted = new Map(cases.filter((entry) => entry.accept).map((entry) => [entry.name, entry])); + + for (const name of expectedAccepted) { + const entry = accepted.get(name); + if (!entry) { + throw new Error(`missing accepted case ${name}`); + } + + const result = parseCausalBundle(entry.raw_jsonl); + expect(result.errors).toEqual([]); + if (result.events.length > 0 || result.streamFinals.length > 0 || result.digestDomains.length > 0) { + expect(result.events.length + result.streamFinals.length + result.digestDomains.length).toBeGreaterThan(0); + } + + const canonicalLines = entry.raw_jsonl + .split("\n") + .map((line) => parseCanonicalJson(line)) + .map((value) => canonicalJsonStringify(value)); + const canonicalJoined = canonicalLines.join("\n"); + + expect(Buffer.from(canonicalJoined).toString("hex")).toBe(entry.canonical_hex); + expect(createHash("sha256").update(canonicalJoined).digest("hex")).toBe(entry.sha256); + } + }); + + it("validates rejected cases through bundle preflight and placeholder null oracles", () => { + const rejected = new Map(cases.filter((entry) => !entry.accept).map((entry) => [entry.name, entry])); + + for (const name of expectedRejected) { + const entry = rejected.get(name); + if (!entry) { + throw new Error(`missing rejected case ${name}`); + } + + const result = parseCausalBundle(entry.raw_jsonl); + expect(result.errors.length).toBeGreaterThan(0); + expect(entry.accept).toBe(false); + expect(entry.canonical_hex).toBeNull(); + expect(entry.sha256).toBeNull(); + + expect(entry.raw_jsonl).toMatch(/\S/); + } + }); + + it("enforces exact manifest keys for parsed manifest cases", () => { + const sampleCase = cases[0]; + const exactKeysCase = { + accept: sampleCase.accept, + canonical_hex: sampleCase.canonical_hex, + name: sampleCase.name, + raw_jsonl: sampleCase.raw_jsonl, + sha256: sampleCase.sha256 + }; + expect(() => validateParsedCase(exactKeysCase)).not.toThrow(); + + const extraKeyCase = { + ...exactKeysCase, + unexpected: "unexpected" + }; + expect(() => validateParsedCase(extraKeyCase)).toThrow(); + + const missingKeyCase = { + accept: sampleCase.accept, + canonical_hex: sampleCase.canonical_hex, + raw_jsonl: sampleCase.raw_jsonl, + sha256: sampleCase.sha256 + }; + expect(() => validateParsedCase(missingKeyCase)).toThrow(); + }); +}); diff --git a/src/contracts/goldens/causal-contract.v1.json b/src/contracts/goldens/causal-contract.v1.json new file mode 100644 index 0000000..843e823 --- /dev/null +++ b/src/contracts/goldens/causal-contract.v1.json @@ -0,0 +1,338 @@ +[ + { + "name": "accept-normative-event", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept\",\"event_id\":\"moltnet:event-1\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:room-1\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root\"],\"payload\":{\"content_sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"message_id\":\"m1\"}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f74225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a726f6f6d2d31222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a6576656e742d31222c227061796c6f6164223a7b22636f6e74656e745f736861323536223a226161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c226d6573736167655f6964223a226d31227d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d616363657074222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "e9fcf0b64990e37759ad280d162a10dd903807cade2189f61d85567f0e136d9f" + }, + { + "name": "accept-nested-reordering-and-unicode", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept\",\"event_id\":\"moltnet:event-2\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:room-2\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-2\",\"recorded_at\":\"2026-07-09T00:00:00.001Z\",\"cause_event_ids\":[\"moltnet:event-1\"],\"payload\":{\"a\":{\"\\u0061bc\":\"ok\",\"a\":\"first\",\"zeta\":\"ω\",\"😀\":\"smile\"},\"nested\":{\"arr\":[{\"a\":1,\"b\":2},{\"c\":3,\"ℤ\":\"unicode\"}],\"emoji\":\"cafe\\u0301\"},\"z\":1}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b226d6f6c746e65743a6576656e742d31225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a726f6f6d2d32222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a6576656e742d32222c227061796c6f6164223a7b2261223a7b2261223a226669727374222c22616263223a226f6b222c227a657461223a22cf89222c22f09f9880223a22736d696c65227d2c226e6573746564223a7b22617272223a5b7b2261223a312c2262223a327d2c7b2263223a332c22e284a4223a22756e69636f6465227d5d2c22656d6f6a69223a2263616665cc81227d2c227a223a317d2c227072696e636970616c5f6964223a226167656e743a6167656e742d32222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030315a222c2272756e5f6964223a2272756e2d616363657074222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "b1a2a08326288b1e8d03344864e2639fb9d8979b548a8c079a2944e49b67e74d" + }, + { + "name": "accept-empty-payload", + "accept": true, + "raw_jsonl": "{\"principal_id\":\"agent:agent-3\",\"recorded_at\":\"2026-07-09T00:00:00.002Z\",\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept\",\"event_id\":\"moltnet:event-3\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"network:room-1\",\"seq\":2},\"type\":\"message.accepted\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b5d2c22656d6974746572223a7b22736571223a322c2273747265616d5f6964223a226e6574776f726b3a726f6f6d2d31222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a6576656e742d33222c227061796c6f6164223a7b7d2c227072696e636970616c5f6964223a226167656e743a6167656e742d33222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030325a222c2272756e5f6964223a2272756e2d616363657074222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "69b023e2e8f53274d83d0b56c0e5a149a40771391d6acf2f1602133b4cb1f076" + }, + { + "name": "accept-finite-fraction", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept\",\"event_id\":\"moltnet:event-4\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"network:room-1\",\"seq\":3},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-4\",\"recorded_at\":\"2026-07-09T00:00:00.003Z\",\"cause_event_ids\":[\"moltnet:event-1\"],\"payload\":{\"score\":0.125,\"ratio\":1.25,\"delta\":2e-3}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b226d6f6c746e65743a6576656e742d31225d2c22656d6974746572223a7b22736571223a332c2273747265616d5f6964223a226e6574776f726b3a726f6f6d2d31222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a6576656e742d34222c227061796c6f6164223a7b2264656c7461223a302e3030322c22726174696f223a312e32352c2273636f7265223a302e3132357d2c227072696e636970616c5f6964223a226167656e743a6167656e742d34222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030335a222c2272756e5f6964223a2272756e2d616363657074222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "ed1057d164cf48298f8b8ff21701de7d07d342f22a236a3569b681ae5753d1f2" + }, + { + "name": "accept-positive-1e-6-fixed", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-pos-fixed-1e-6\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-accept-pos-fixed-1e-6\"],\"payload\":{\"v\":0.000001}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d6163636570742d706f732d66697865642d31652d36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d706f732d66697865642d31652d36222c227061796c6f6164223a7b2276223a302e3030303030317d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "3a872a312f3b29e53833f1c9be9803dfd32395e3ddde19886f276bfa0d751c94" + }, + { + "name": "accept-negative-1e-6-fixed", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-neg-fixed-1e-6\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-neg-fixed-1e-6\"],\"payload\":{\"v\":-0.000001}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d6e65672d66697865642d31652d36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d6e65672d66697865642d31652d36222c227061796c6f6164223a7b2276223a2d302e3030303030317d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "c924426c7bcc579d480edc9c9cf2408ae234dde63d06fa779ceab939812f5ec6" + }, + { + "name": "accept-positive-1e-7-exponent", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-pos-exp-1e-7\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-pos-exp-1e-7\"],\"payload\":{\"v\":1e-7}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d706f732d6578702d31652d37225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d706f732d6578702d31652d37222c227061796c6f6164223a7b2276223a31652d377d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "8575afbf675a29137f85520b55f88e93cb23c21790536f5d69667a39bbf35698" + }, + { + "name": "accept-negative-1e-7-exponent", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-neg-exp-1e-7\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-neg-exp-1e-7\"],\"payload\":{\"v\":-1e-7}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d6e65672d6578702d31652d37225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d6e65672d6578702d31652d37222c227061796c6f6164223a7b2276223a2d31652d377d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "944e872cfb2e884df955914381044a558ad5affbf20739efb2dd681a4911a36a" + }, + { + "name": "accept-positive-below-1e-6", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-pos-below-1e-6\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-pos-below-1e-6\"],\"payload\":{\"v\":9.5e-7}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d706f732d62656c6f772d31652d36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d706f732d62656c6f772d31652d36222c227061796c6f6164223a7b2276223a392e35652d377d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "9c02f58cf3bc7da804f24e51621eb6dbcb9dc52265cfd8e13d293dfa99f20615" + }, + { + "name": "accept-negative-below-1e-6", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-neg-below-1e-6\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-neg-below-1e-6\"],\"payload\":{\"v\":-9.5e-7}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d6e65672d62656c6f772d31652d36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d6e65672d62656c6f772d31652d36222c227061796c6f6164223a7b2276223a2d392e35652d377d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "d2c77b99fcaa3082bd43346466a67dd9269d2cd735825aa84dbc35bd18ce5d18" + }, + { + "name": "accept-positive-above-1e-6", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-pos-above-1e-6\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-pos-above-1e-6\"],\"payload\":{\"v\":0.0000015}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d706f732d61626f76652d31652d36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d706f732d61626f76652d31652d36222c227061796c6f6164223a7b2276223a302e303030303031357d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "5c185c73ce330a2c280b42cfed4e86067c26a4cb0e533086d25a667bbe084bff" + }, + { + "name": "accept-negative-above-1e-6", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-accept-numbers\",\"event_id\":\"moltnet:boundary-neg-above-1e-6\",\"emitter\":{\"seq\":1,\"stream_id\":\"network:boundary\",\"system\":\"moltnet\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-19T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:root-neg-above-1e-6\"],\"payload\":{\"v\":-0.0000015}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f742d6e65672d61626f76652d31652d36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a226e6574776f726b3a626f756e64617279222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a626f756e646172792d6e65672d61626f76652d31652d36222c227061796c6f6164223a7b2276223a2d302e303030303031357d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d31395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d6163636570742d6e756d62657273222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "6a6df6976dc9a8a818b0392e78599b16c91ef61b20810f89932d8b55e857585d" + }, + { + "name": "accept-digest-domain-causal-event", + "accept": true, + "raw_jsonl": "{\"label\":\"causal-event/canonical-json\",\"hash\":\"sha-256\",\"subject_bytes\":\"noopolis.canonical-json.v1:utf-8\",\"output\":\"lowercase-hex\",\"version\":\"noopolis.causal-digest-domain.v1\"}", + "canonical_hex": "7b2268617368223a227368612d323536222c226c6162656c223a2263617573616c2d6576656e742f63616e6f6e6963616c2d6a736f6e222c226f7574707574223a226c6f776572636173652d686578222c227375626a6563745f6279746573223a226e6f6f706f6c69732e63616e6f6e6963616c2d6a736f6e2e76313a7574662d38222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6469676573742d646f6d61696e2e7631227d", + "sha256": "50964c23088aff04be2e4909db59f9a0b8c23f32f42a74c6027eebf9f3f2ddf6" + }, + { + "name": "accept-digest-domain-exact-utf8", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v1\",\"label\":\"content/exact-utf8\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-utf8\",\"output\":\"lowercase-hex\"}", + "canonical_hex": "7b2268617368223a227368612d323536222c226c6162656c223a22636f6e74656e742f65786163742d75746638222c226f7574707574223a226c6f776572636173652d686578222c227375626a6563745f6279746573223a2265786163742d75746638222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6469676573742d646f6d61696e2e7631227d", + "sha256": "81bcfaa404e253d79364235277981a48a458d45b7788e75571419586ee3a68d0" + }, + { + "name": "accept-digest-domain-exact-bytes", + "accept": true, + "raw_jsonl": "{\"output\":\"lowercase-hex\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-bytes\",\"label\":\"content/exact-bytes\",\"version\":\"noopolis.causal-digest-domain.v1\"}", + "canonical_hex": "7b2268617368223a227368612d323536222c226c6162656c223a22636f6e74656e742f65786163742d6279746573222c226f7574707574223a226c6f776572636173652d686578222c227375626a6563745f6279746573223a2265786163742d6279746573222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6469676573742d646f6d61696e2e7631227d", + "sha256": "2013da6c378bd7800e4c5d338bc0955f281222fa6ba35c4c6de19ee765f6a5da" + }, + { + "name": "accept-final-seq-zero", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-empty\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"network:empty\"},\"final_seq\":0}", + "canonical_hex": "7b22656d6974746572223a7b2273747265616d5f6964223a226e6574776f726b3a656d707479222c2273797374656d223a226d6f6c746e6574227d2c2266696e616c5f736571223a302c2272756e5f6964223a2272756e2d656d707479222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d73747265616d2d66696e616c2e7631227d", + "sha256": "4fa5f02f6f4596b9cb23b1491afcb55de4bf8cb099547ba1e0c522ad5df387fb" + }, + { + "name": "accept-non-empty-final", + "accept": true, + "raw_jsonl": "{\"run_id\":\"run-final\",\"version\":\"noopolis.causal-stream-final.v1\",\"final_seq\":4,\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"network:final\"}}", + "canonical_hex": "7b22656d6974746572223a7b2273747265616d5f6964223a226e6574776f726b3a66696e616c222c2273797374656d223a226d6f6c746e6574227d2c2266696e616c5f736571223a342c2272756e5f6964223a2272756e2d66696e616c222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d73747265616d2d66696e616c2e7631227d", + "sha256": "d9c2f89c380160ed68287124d23e4bca79141627e4df0917ba6308078ef99177" + }, + { + "name": "accept-multi-stream", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-bundle\",\"event_id\":\"moltnet:bundle-1\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream-a\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{\"v\":1}}\n{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-bundle\",\"event_id\":\"simfile:bundle-2\",\"emitter\":{\"system\":\"simfile\",\"stream_id\":\"stream-b\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.001Z\",\"cause_event_ids\":[\"moltnet:bundle-1\"],\"payload\":{\"v\":2}}\n{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-bundle\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream-a\"},\"final_seq\":1}\n{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-bundle\",\"final_seq\":1,\"emitter\":{\"system\":\"simfile\",\"stream_id\":\"stream-b\"}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b5d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d2d61222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a62756e646c652d31222c227061796c6f6164223a7b2276223a317d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d62756e646c65222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d0a7b2263617573655f6576656e745f696473223a5b226d6f6c746e65743a62756e646c652d31225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d2d62222c2273797374656d223a2273696d66696c65227d2c226576656e745f6964223a2273696d66696c653a62756e646c652d32222c227061796c6f6164223a7b2276223a327d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030315a222c2272756e5f6964223a2272756e2d62756e646c65222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d0a7b22656d6974746572223a7b2273747265616d5f6964223a2273747265616d2d61222c2273797374656d223a226d6f6c746e6574227d2c2266696e616c5f736571223a312c2272756e5f6964223a2272756e2d62756e646c65222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d73747265616d2d66696e616c2e7631227d0a7b22656d6974746572223a7b2273747265616d5f6964223a2273747265616d2d62222c2273797374656d223a2273696d66696c65227d2c2266696e616c5f736571223a312c2272756e5f6964223a2272756e2d62756e646c65222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d73747265616d2d66696e616c2e7631227d", + "sha256": "331c92c26092623856acb4e69167c727337a1d9bd1803864edcbf013a4a7a5ad" + }, + { + "name": "reject-unknown-event-top-level", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj1\",\"event_id\":\"moltnet:rj1\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:unknown\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{},\"extra\":true}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unknown-event-emitter-field", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj1\",\"event_id\":\"moltnet:rj2\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:unknown\",\"seq\":1,\"unexpected\":\"bad\"},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unknown-final-top-level", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-rj2\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:final\",\"seq\":1},\"final_seq\":1,\"extra\":true}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unknown-final-emitter-field", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-rj2\",\"final_seq\":1,\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:final\",\"unexpected\":true}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unknown-digest-top-level", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v1\",\"label\":\"content/exact-bytes\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-bytes\",\"output\":\"lowercase-hex\",\"extra\":true}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-wrong-version-event", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v0\",\"run_id\":\"run-rj3\",\"event_id\":\"moltnet:rj3\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:v0\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-wrong-version-final", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-stream-final.v0\",\"run_id\":\"run-rj3\",\"final_seq\":1,\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:v0\"}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-wrong-version-digest", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v0\",\"label\":\"content/exact-bytes\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-bytes\",\"output\":\"lowercase-hex\"}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-malformed-event-id", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj4\",\"event_id\":\"moltnet\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unrecognized-event-id-system", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj4\",\"event_id\":\"unknown:rj4\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-mismatched-event-id-prefix", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj4\",\"event_id\":\"simfile:rj4\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unrecognized-cause-id", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj4\",\"event_id\":\"moltnet:rj5\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[\"x:rj6\",\"moltnet:rj6\"],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-repeated-causes", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj5\",\"event_id\":\"moltnet:rj7\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[\"simfile:rj\",\"simfile:rj\"],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-duplicate-event-id", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj6\",\"event_id\":\"moltnet:dup\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:dup\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}\n{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj6\",\"event_id\":\"moltnet:dup\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:dup2\",\"seq\":2},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:01.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-duplicate-stream-slot", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj7\",\"event_id\":\"moltnet:slot1\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:slot\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}\n{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj7\",\"event_id\":\"moltnet:slot2\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:slot\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:01.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-present-cross-run-cause", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-a\",\"event_id\":\"moltnet:origin\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:run-a\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}\n{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-b\",\"event_id\":\"simfile:cross-run\",\"emitter\":{\"system\":\"simfile\",\"stream_id\":\"stream:run-b\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:01.000Z\",\"cause_event_ids\":[\"moltnet:origin\"],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-duplicate-final", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-rj8\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:dup-final\"},\"final_seq\":1}\n{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-rj8\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:dup-final\"},\"final_seq\":2}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-below-observed-final", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj9\",\"event_id\":\"moltnet:late\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:late\",\"seq\":2},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:02.000Z\",\"cause_event_ids\":[],\"payload\":{}}\n{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-rj9\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:late\"},\"final_seq\":1}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-empty-final-contradiction", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj10\",\"event_id\":\"moltnet:empty\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:empty\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:01.000Z\",\"cause_event_ids\":[],\"payload\":{}}\n{\"version\":\"noopolis.causal-stream-final.v1\",\"run_id\":\"run-rj10\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:empty\"},\"final_seq\":0}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unknown-digest-domain", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v1\",\"label\":\"content/unknown\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-bytes\",\"output\":\"lowercase-hex\"}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-altered-digest-hash", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v1\",\"label\":\"content/exact-bytes\",\"hash\":\"sha-1\",\"subject_bytes\":\"exact-bytes\",\"output\":\"lowercase-hex\"}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-altered-digest-subject-bytes", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v1\",\"label\":\"content/exact-bytes\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-bytes-v2\",\"output\":\"lowercase-hex\"}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-altered-digest-output", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-digest-domain.v1\",\"label\":\"content/exact-bytes\",\"hash\":\"sha-256\",\"subject_bytes\":\"exact-bytes\",\"output\":\"base64\"}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-duplicate-decoded-key", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj11\",\"event_id\":\"moltnet:rj11\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:dup-key\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{\"a\":1,\"a\":2}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-escaped-equivalent-keys", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj11\",\"event_id\":\"moltnet:rj12\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:dup-key\",\"seq\":2},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.001Z\",\"cause_event_ids\":[],\"payload\":{\"\\u0061\":1,\"a\":2}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-negative-zero", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj12\",\"event_id\":\"moltnet:rj13\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:negzero\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{\"value\":-0}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-unsafe-integer", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj13\",\"event_id\":\"moltnet:rj14\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:unsafe\",\"seq\":9007199254740992},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-invalid-unicode", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj14\",\"event_id\":\"moltnet:rj15\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:unicode\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{\"value\":\"\\uD800\"}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-invalid-json-spelling", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj15\",\"event_id\":\"moltnet:rj16\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad-json\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[],\"payload\":{}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-non-finite-json-spelling", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj16\",\"event_id\":\"moltnet:rj17\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad-json\",\"seq\":2},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.001Z\",\"cause_event_ids\":[],\"payload\":{\"value\":Infinity}}", + "canonical_hex": null, + "sha256": null + } +] diff --git a/src/contracts/ids.test.ts b/src/contracts/ids.test.ts new file mode 100644 index 0000000..1d6b3bc --- /dev/null +++ b/src/contracts/ids.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + assertUniqueCauseEventIds, + CausalEventSystem, + eventIdMatchesEmitterSystem, + parseCausalCauseId, + parseCausalEventId, + RECOGNIZED_CAUSAL_SYSTEMS +} from "./ids.js"; + +describe("causal event and cause ids", () => { + it("parses valid event ids and preserves local suffix", () => { + const parsed = parseCausalEventId("moltnet:network:room-1"); + expect(parsed.system).toBe("moltnet"); + expect(parsed.local).toBe("network:room-1"); + expect(parsed.local.startsWith("network")).toBe(true); + }); + + it.each(RECOGNIZED_CAUSAL_SYSTEMS)("accepts recognized system %s", (system) => { + const parsed = parseCausalEventId(`${system}:local`); + expect(parsed.system).toBe(system); + }); + + it("rejects unknown systems", () => { + expect(() => parseCausalEventId("unknown:local")).toThrow(/unrecognized causal system/); + expect(() => parseCausalCauseId("foo")).toThrow(/must be :/); + }); + + it("rejects empty local suffix", () => { + expect(() => parseCausalEventId("moltnet:")).toThrow(/local suffix/); + }); + + it("rejects missing separator", () => { + expect(() => parseCausalEventId("moltnet")).toThrow(/must be :/); + }); + + it("preserves local text with additional colons", () => { + const parsed = parseCausalEventId("simfile:team:agent:1"); + expect(parsed.local).toBe("team:agent:1"); + }); + + it("enforces unique causes", () => { + expect(() => assertUniqueCauseEventIds(["simfile:a", "simfile:a"])).toThrow(/duplicate cause_event_ids/); + }); + + it("checks event id prefix matching emitter system", () => { + expect(eventIdMatchesEmitterSystem("daimon:event-1", "daimon" as CausalEventSystem)).toBe(true); + expect(eventIdMatchesEmitterSystem("moltnet:event-1", "daimon" as CausalEventSystem)).toBe(false); + }); +}); diff --git a/src/contracts/ids.ts b/src/contracts/ids.ts new file mode 100644 index 0000000..63732f9 --- /dev/null +++ b/src/contracts/ids.ts @@ -0,0 +1,53 @@ +export const RECOGNIZED_CAUSAL_SYSTEMS = ["simfile", "moltnet", "mneme", "daimon"] as const; + +export type CausalEventSystem = (typeof RECOGNIZED_CAUSAL_SYSTEMS)[number]; + +export interface CausalEventId { + local: string; + system: CausalEventSystem; +} + +export const isRecognizedCausalSystem = (value: string): value is CausalEventSystem => + (RECOGNIZED_CAUSAL_SYSTEMS as readonly string[]).includes(value); + +export const parseCausalEventId = (value: unknown): CausalEventId => { + if (typeof value !== "string" || value.length === 0) { + throw new Error("causal event id must be a non-empty string"); + } + + const separator = value.indexOf(":"); + if (separator < 0) { + throw new Error("causal event id must be :"); + } + + const system = value.slice(0, separator); + const local = value.slice(separator + 1); + if (local.length === 0) { + throw new Error("causal event id local suffix must be non-empty"); + } + if (!isRecognizedCausalSystem(system)) { + throw new Error(`unrecognized causal system ${system}`); + } + + return { local, system }; +} + +export const parseCausalCauseId = parseCausalEventId; + +export const assertUniqueCauseEventIds = (causeEventIds: readonly string[]): void => { + const seen = new Set(); + for (const causeEventId of causeEventIds) { + if (seen.has(causeEventId)) { + throw new Error(`duplicate cause_event_ids entry: ${causeEventId}`); + } + seen.add(causeEventId); + } +}; + +export const eventIdMatchesEmitterSystem = (eventId: string, system: CausalEventSystem): boolean => { + const separator = eventId.indexOf(":"); + if (separator < 0) { + return false; + } + return eventId.slice(0, separator) === system; +}; diff --git a/src/contracts/index.ts b/src/contracts/index.ts new file mode 100644 index 0000000..8b01281 --- /dev/null +++ b/src/contracts/index.ts @@ -0,0 +1,6 @@ +export * from "./canonicalJson.js"; +export * from "./digestDomain.js"; +export * from "./envelope.js"; +export * from "./ids.js"; +export * from "./streamFinal.js"; +export * from "./bundle.js"; diff --git a/src/contracts/streamFinal.test.ts b/src/contracts/streamFinal.test.ts new file mode 100644 index 0000000..9a415a8 --- /dev/null +++ b/src/contracts/streamFinal.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + CAUSAL_STREAM_FINAL_VERSION, + parseCausalStreamFinal, + validateCausalStreamFinal +} from "./streamFinal.js"; + +const goldenFinal = { + emitter: { + stream_id: "network:room-1", + system: "moltnet" + }, + final_seq: 0, + run_id: "run-1", + version: CAUSAL_STREAM_FINAL_VERSION +}; + +describe("causal stream final", () => { + it("accepts a strict canonical final declaration", () => { + const result = validateCausalStreamFinal(goldenFinal); + expect(result.success).toBe(true); + expect(result.success && result.data).toEqual(goldenFinal); + }); + + it("rejects unexpected emitter fields", () => { + const result = validateCausalStreamFinal({ + ...goldenFinal, + emitter: { ...goldenFinal.emitter, seq: 4 } + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown emitter systems", () => { + const result = validateCausalStreamFinal({ + ...goldenFinal, + emitter: { ...goldenFinal.emitter, system: "bogus" } + }); + expect(result.success).toBe(false); + }); + + it("rejects missing top-level fields", () => { + const result = validateCausalStreamFinal({ + emitter: goldenFinal.emitter, + final_seq: 0, + version: CAUSAL_STREAM_FINAL_VERSION + }); + expect(result.success).toBe(false); + }); + + it("rejects an invalid version", () => { + const result = validateCausalStreamFinal({ ...goldenFinal, version: "noopolis.causal-stream-final.v0" }); + expect(result.success).toBe(false); + }); + + it("accepts every recognized emitter system", () => { + for (const system of ["simfile", "moltnet", "mneme", "daimon"] as const) { + const result = validateCausalStreamFinal({ + ...goldenFinal, + emitter: { ...goldenFinal.emitter, system }, + final_seq: 1, + run_id: `run-${system}` + }); + expect(result.success).toBe(true); + } + }); + + it("rejects negative final sequences", () => { + expect(validateCausalStreamFinal({ ...goldenFinal, final_seq: -1 }).success).toBe(false); + }); + + it("rejects non-integer final sequences", () => { + expect(validateCausalStreamFinal({ ...goldenFinal, final_seq: 1.5 }).success).toBe(false); + }); + + it("rejects unsafe integer final sequences", () => { + const result = validateCausalStreamFinal({ + ...goldenFinal, + final_seq: Number.MAX_SAFE_INTEGER + 1 + }); + expect(result.success).toBe(false); + }); + + it("rejects empty run_id and stream_id", () => { + expect(validateCausalStreamFinal({ ...goldenFinal, run_id: "" }).success).toBe(false); + expect(validateCausalStreamFinal({ ...goldenFinal, emitter: { ...goldenFinal.emitter, stream_id: "" } }).success).toBe(false); + }); + + it("parses a valid final declaration via parseCausalStreamFinal", () => { + expect(parseCausalStreamFinal(goldenFinal)).toEqual(goldenFinal); + }); +}); diff --git a/src/contracts/streamFinal.ts b/src/contracts/streamFinal.ts new file mode 100644 index 0000000..44e5c04 --- /dev/null +++ b/src/contracts/streamFinal.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +import type { CausalEventSystem } from "./ids.js"; + +export const CAUSAL_STREAM_FINAL_VERSION = "noopolis.causal-stream-final.v1" as const; + +const streamFinalSchema = z + .object({ + final_seq: z + .number() + .int() + .min(0) + .superRefine((value, context) => { + if (!Number.isSafeInteger(value)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "must be a safe integer", + path: ["final_seq"] + }); + } + }), + emitter: z + .object({ + stream_id: z.string().min(1), + system: z.enum(["simfile", "moltnet", "mneme", "daimon"] as const) + }) + .strict(), + run_id: z.string().min(1), + version: z.literal(CAUSAL_STREAM_FINAL_VERSION) + }) + .strict() + .transform((value) => ({ + ...value, + emitter: { + system: value.emitter.system, + stream_id: value.emitter.stream_id + } + })); + +export interface CausalStreamFinal { + version: typeof CAUSAL_STREAM_FINAL_VERSION; + run_id: string; + emitter: { + system: CausalEventSystem; + stream_id: string; + }; + final_seq: number; +} + +export type CausalStreamFinalSafeParseResult = z.ZodSafeParseResult; + +export const validateCausalStreamFinal = (value: unknown): CausalStreamFinalSafeParseResult => + streamFinalSchema.safeParse(value) as CausalStreamFinalSafeParseResult; + +export const parseCausalStreamFinal = (value: unknown): CausalStreamFinal => { + const result = validateCausalStreamFinal(value); + if (!result.success) { + throw new Error( + `invalid stream final: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` + ); + } + return result.data; +}; diff --git a/src/envelope.test.ts b/src/envelope.test.ts index a2a7e0c..2139ec2 100644 --- a/src/envelope.test.ts +++ b/src/envelope.test.ts @@ -4,6 +4,7 @@ import { CAUSAL_EVENT_VERSION, canonicalJsonStringify, causalEventSchema, + PRINCIPAL_GRAMMAR_SOURCE, hashCausalEvent, parseCausalEvent, parseCausalJsonl, @@ -195,3 +196,11 @@ describe("causalEventSchema export", () => { expect(causalEventSchema.safeParse(goldenRecord).success).toBe(true); }); }); + +describe("compatibility facade", () => { + it("re-exports legacy named contracts from src/envelope.ts", () => { + expect(typeof CAUSAL_EVENT_VERSION).toBe("string"); + expect(PRINCIPAL_GRAMMAR_SOURCE).toBe("^(agent|operator|system):.+"); + expect(typeof canonicalJsonStringify).toBe("function"); + }); +}); diff --git a/src/envelope.ts b/src/envelope.ts index 65bc5a6..6ffae48 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -1,176 +1,15 @@ -import { createHash } from "node:crypto"; - -import { z } from "zod"; - -export const CAUSAL_EVENT_VERSION = "noopolis.causal-event.v1" as const; - -/** - * B62 principal grammar (specs/CAUSAL.md §3), verbatim from - * `specs/causal-event.v1.schema.json`'s `principal_id.pattern`: - * `agent:` | `operator:` | - * `system:[.]`. This is the single source of truth for - * the grammar — `causalEventSchema` enforces it directly on the wire - * (below), and `principal.ts`'s `parsePrincipal`/`isAuthenticatedPrincipal` - * import this constant rather than hardcoding a second copy of the pattern. - */ -export const PRINCIPAL_GRAMMAR_SOURCE = "^(agent|operator|system):.+"; - -const CAUSAL_EVENT_SYSTEMS = ["simfile", "moltnet", "mneme", "daimon"] as const; - -export type CausalEventSystem = (typeof CAUSAL_EVENT_SYSTEMS)[number]; - -export interface CausalEventEmitter { - system: CausalEventSystem; - stream_id: string; - seq: number; -} - -export interface CausalEvent> { - cause_event_ids: string[]; - emitter: CausalEventEmitter; - event_id: string; - payload: TPayload; - principal_id: string; - recorded_at: string; - run_id: string; - type: string; - version: typeof CAUSAL_EVENT_VERSION; -} - -const causalEventEmitterSchema = z - .object({ - seq: z.number().int().min(1), - stream_id: z.string().min(1), - system: z.enum(CAUSAL_EVENT_SYSTEMS) - }) - .strict(); - -export const causalEventSchema = z - .object({ - cause_event_ids: z.array(z.string().min(1)), - emitter: causalEventEmitterSchema, - event_id: z.string().regex(/^[^:]+:.+$/, "event_id must be :"), - payload: z.record(z.string(), z.unknown()), - principal_id: z.string().regex(new RegExp(PRINCIPAL_GRAMMAR_SOURCE), "principal_id must match : (specs/CAUSAL.md §3)"), - recorded_at: z.string().min(1), - run_id: z.string().min(1), - type: z.string().min(1), - version: z.literal(CAUSAL_EVENT_VERSION) - }) - .strict() - .superRefine((value, context) => { - if (Number.isNaN(Date.parse(value.recorded_at))) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "recorded_at must be a valid ISO 8601 timestamp", - path: ["recorded_at"] - }); - } - - if (!value.event_id.startsWith(`${value.emitter.system}:`)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "event_id system prefix must match emitter.system", - path: ["event_id"] - }); - } - }); - -export const validateCausalEvent = (value: unknown): z.ZodSafeParseResult => - causalEventSchema.safeParse(value) as z.ZodSafeParseResult; - -export const parseCausalEvent = (value: unknown): CausalEvent => { - const result = validateCausalEvent(value); - if (!result.success) { - throw new Error( - `invalid causal event: ${result.error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; ")}` - ); - } - - return result.data; -}; - -export interface CausalJsonlParseError { - line: number; - message: string; -} - -export interface CausalJsonlParseResult { - errors: CausalJsonlParseError[]; - events: CausalEvent[]; -} - -/** - * Parses newline-delimited CausalEvent JSON records. Malformed JSON and - * schema violations are collected as errors keyed by 1-based line number - * rather than thrown, so callers (in particular the conformance harness) - * can report every problem in a fixture instead of stopping at the first. - */ -export const parseCausalJsonl = (jsonl: string): CausalJsonlParseResult => { - const events: CausalEvent[] = []; - const errors: CausalJsonlParseError[] = []; - - const lines = jsonl.split("\n"); - lines.forEach((rawLine, index) => { - const line = rawLine.trim(); - if (line.length === 0) { - return; - } - - const lineNumber = index + 1; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch (error) { - errors.push({ - line: lineNumber, - message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` - }); - return; - } - - const result = validateCausalEvent(parsed); - if (!result.success) { - errors.push({ - line: lineNumber, - message: result.error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; ") - }); - return; - } - - events.push(result.data); - }); - - return { errors, events }; -}; - -/** - * Deterministically stringifies a value with object keys sorted - * recursively, so structurally identical records hash the same regardless - * of key order in the source JSON. - */ -export const canonicalJsonStringify = (value: unknown): string => { - const sort = (input: unknown): unknown => { - if (Array.isArray(input)) { - return input.map(sort); - } - - if (input !== null && typeof input === "object") { - const sortedEntries = Object.entries(input as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entryValue]) => [key, sort(entryValue)] as const); - return Object.fromEntries(sortedEntries); - } - - return input; - }; - - return JSON.stringify(sort(value)); -}; - -export const hashCausalEvent = (event: CausalEvent): string => - createHash("sha256").update(canonicalJsonStringify(event)).digest("hex"); +export { + CAUSAL_EVENT_VERSION, + PRINCIPAL_GRAMMAR_SOURCE, + canonicalJsonStringify, + causalEventSchema, + CausalEvent, + CausalEventEmitter, + CausalEventSystem, + CausalJsonlParseError, + CausalJsonlParseResult, + hashCausalEvent, + parseCausalEvent, + parseCausalJsonl, + validateCausalEvent +} from "./contracts/envelope.js"; diff --git a/src/index.ts b/src/index.ts index f2941c6..a6bab1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./contracts/index.js"; export * from "./envelope.js"; export * from "./reconcile.js"; export * from "./seq.js"; From 6e3c8c6e3caaab5c290636f6a2a91b52a85a37a5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 00:58:48 +0200 Subject: [PATCH 02/11] fix: preserve canonical key order --- src/contracts/canonicalJson.test.ts | 19 ++++++++++ src/contracts/canonicalJson.ts | 38 ++++++++++++++++++- src/contracts/golden.test.ts | 7 +++- src/contracts/goldens/causal-contract.v1.json | 21 ++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/contracts/canonicalJson.test.ts b/src/contracts/canonicalJson.test.ts index 9d1e24f..1f9f940 100644 --- a/src/contracts/canonicalJson.test.ts +++ b/src/contracts/canonicalJson.test.ts @@ -62,6 +62,13 @@ describe("parseCanonicalJson", () => { describe("canonicalJsonStringify", () => { it("sorts UTF-16 object keys deterministically", () => { expect(canonicalJsonStringify({ b: 1, a: 1, "Ω": 1 })).toBe('{"a":1,"b":1,"Ω":1}'); + expect(canonicalJsonStringify({ "10": 1, "2": 2, a: 3 })).toBe('{"10":1,"2":2,"a":3}'); + }); + + it("orders nested mixed UTF-16 keys with direct primitive serialization", () => { + expect(canonicalJsonStringify({ z: 1, "2": 2, "10": 10, a: { "2": "two", "10": "ten", "a": "letter", "😀": "smile" } })).toBe( + '{"10":10,"2":2,"a":{"10":"ten","2":"two","a":"letter","😀":"smile"},"z":1}' + ); }); it("preserves array order", () => { @@ -195,4 +202,16 @@ describe("canonical runtime validation", () => { const value = { a: [{ b: 2 }, { c: 3 }] }; expect(canonicalJsonStringify(value)).toBe('{"a":[{"b":2},{"c":3}]}'); }); + + it("rejects runtime lone high-surrogate string keys", () => { + expect(() => canonicalJsonStringify({ "\ud800": 1 })).toThrow(/lone high surrogate/i); + }); + + it("rejects runtime lone low-surrogate string keys", () => { + expect(() => canonicalJsonStringify({ "\udc00": 1 })).toThrow(/lone low surrogate/i); + }); + + it("accepts valid supplementary runtime string keys", () => { + expect(canonicalJsonStringify({ "\uD834\uDF06": "clef" })).toBe(JSON.stringify({ "\uD834\uDF06": "clef" })); + }); }); diff --git a/src/contracts/canonicalJson.ts b/src/contracts/canonicalJson.ts index 0f11a54..6d41147 100644 --- a/src/contracts/canonicalJson.ts +++ b/src/contracts/canonicalJson.ts @@ -85,6 +85,7 @@ const canonicalize = (value: unknown, seen: Set, path: string): unknown const descriptors = Object.getOwnPropertyDescriptors(value); const entries: [string, unknown][] = []; for (const key of Object.getOwnPropertyNames(value)) { + validateString(key, `${path} key ${key}`); const descriptor = descriptors[key]; if (!descriptor || descriptor.get || descriptor.set || !descriptor.enumerable || !("value" in descriptor)) { throw new Error(`${path}: accessor/non-enumerable property`); @@ -100,8 +101,43 @@ const canonicalize = (value: unknown, seen: Set, path: string): unknown return out; }; +const writeCanonicalValue = (value: unknown, out: string[]) => { + if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + out.push(JSON.stringify(value)); + return; + } + if (Array.isArray(value)) { + out.push("["); + for (let i = 0; i < value.length; i += 1) { + if (i > 0) out.push(","); + writeCanonicalValue(value[i], out); + } + out.push("]"); + return; + } + if (typeof value === "object") { + const asRecord = value as Record; + const keys = Object.keys(asRecord).sort(utf16Less); + out.push("{"); + for (let i = 0; i < keys.length; i += 1) { + if (i > 0) out.push(","); + const key = keys[i]!; + out.push(JSON.stringify(key)); + out.push(":"); + writeCanonicalValue(asRecord[key], out); + } + out.push("}"); + return; + } + throw new Error("unsupported canonical value"); +}; + export const canonicalJsonBytes = (value: unknown): Uint8Array => encoder.encode(canonicalJsonStringify(value)); -export const canonicalJsonStringify = (value: unknown): string => JSON.stringify(canonicalize(value, new Set(), "root")); +export const canonicalJsonStringify = (value: unknown): string => { + const out: string[] = []; + writeCanonicalValue(canonicalize(value, new Set(), "root"), out); + return out.join(""); +}; const hasBOM = (bytes: Uint8Array) => bytes.length >= 3 && bytes[0] === utf8Bom[0] && bytes[1] === utf8Bom[1] && bytes[2] === utf8Bom[2]; diff --git a/src/contracts/golden.test.ts b/src/contracts/golden.test.ts index d4a3b48..2401c33 100644 --- a/src/contracts/golden.test.ts +++ b/src/contracts/golden.test.ts @@ -90,7 +90,8 @@ const expectedAccepted = [ "accept-digest-domain-exact-bytes", "accept-final-seq-zero", "accept-non-empty-final", - "accept-multi-stream" + "accept-multi-stream", + "accept-nested-numeric-key-order" ]; const expectedRejected = [ @@ -123,7 +124,9 @@ const expectedRejected = [ "reject-unsafe-integer", "reject-invalid-unicode", "reject-invalid-json-spelling", - "reject-non-finite-json-spelling" + "reject-non-finite-json-spelling", + "reject-lone-high-surrogate-runtime-key", + "reject-lone-low-surrogate-runtime-key" ]; const normalizeNameInventory = (values: string[]) => new Set(values); diff --git a/src/contracts/goldens/causal-contract.v1.json b/src/contracts/goldens/causal-contract.v1.json index 843e823..b1ed419 100644 --- a/src/contracts/goldens/causal-contract.v1.json +++ b/src/contracts/goldens/causal-contract.v1.json @@ -125,6 +125,27 @@ "canonical_hex": "7b2263617573655f6576656e745f696473223a5b5d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d2d61222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a62756e646c652d31222c227061796c6f6164223a7b2276223a317d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d62756e646c65222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d0a7b2263617573655f6576656e745f696473223a5b226d6f6c746e65743a62756e646c652d31225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d2d62222c2273797374656d223a2273696d66696c65227d2c226576656e745f6964223a2273696d66696c653a62756e646c652d32222c227061796c6f6164223a7b2276223a327d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030315a222c2272756e5f6964223a2272756e2d62756e646c65222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d0a7b22656d6974746572223a7b2273747265616d5f6964223a2273747265616d2d61222c2273797374656d223a226d6f6c746e6574227d2c2266696e616c5f736571223a312c2272756e5f6964223a2272756e2d62756e646c65222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d73747265616d2d66696e616c2e7631227d0a7b22656d6974746572223a7b2273747265616d5f6964223a2273747265616d2d62222c2273797374656d223a2273696d66696c65227d2c2266696e616c5f736571223a312c2272756e5f6964223a2272756e2d62756e646c65222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d73747265616d2d66696e616c2e7631227d", "sha256": "331c92c26092623856acb4e69167c727337a1d9bd1803864edcbf013a4a7a5ad" }, + { + "name": "accept-nested-numeric-key-order", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-nested-numeric-keys\",\"event_id\":\"moltnet:order-1\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:order\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.100Z\",\"cause_event_ids\":[\"simfile:root\"],\"payload\":{\"a\":{\"10\":\"ten\",\"2\":\"two\",\"z\":\"zig\"},\"10\":\"ten\",\"2\":\"two\",\"emoji\":\"smile\\u2665\"}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f74225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d3a6f72646572222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a6f726465722d31222c227061796c6f6164223a7b223130223a2274656e222c2232223a2274776f222c2261223a7b223130223a2274656e222c2232223a2274776f222c227a223a227a6967227d2c22656d6f6a69223a22736d696c65e299a5227d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3130305a222c2272756e5f6964223a2272756e2d6e65737465642d6e756d657269632d6b657973222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "10105a0f835ee611c46312811485cd450a02fdbf947da3c8fa804613c9b11449" + }, + { + "name": "reject-lone-high-surrogate-runtime-key", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj18\",\"event_id\":\"moltnet:rj18\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:key-bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.002Z\",\"cause_event_ids\":[\"simfile:root\"],\"payload\":{\"\\uD800\":1}}", + "canonical_hex": null, + "sha256": null + }, + { + "name": "reject-lone-low-surrogate-runtime-key", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj19\",\"event_id\":\"moltnet:rj19\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:key-bad\",\"seq\":2},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.003Z\",\"cause_event_ids\":[\"simfile:root\"],\"payload\":{\"\\uDC00\":1}}", + "canonical_hex": null, + "sha256": null + }, { "name": "reject-unknown-event-top-level", "accept": false, From a43a55026b965edc8d25261940ef4deaff8b88ec Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 18:09:00 +0200 Subject: [PATCH 03/11] feat(reconcile): harden graph and sequence core --- src/AGENTS.md | 1 + src/reconcile.ts | 243 +++++-------------------------------- src/reconcileGraph.test.ts | 74 +++++++++++ src/reconcileGraph.ts | 127 +++++++++++++++++++ src/seq.test.ts | 19 ++- src/seq.ts | 75 +++++------- 6 files changed, 276 insertions(+), 263 deletions(-) create mode 100644 src/reconcileGraph.test.ts create mode 100644 src/reconcileGraph.ts diff --git a/src/AGENTS.md b/src/AGENTS.md index 2110517..dc97a92 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -5,6 +5,7 @@ src/ ├── index.ts # Barrel export for package public API ├── envelope.ts # Compatibility facade re-exporting contract exports ├── reconcile.ts # Pure reconciler + backward cause-chain tracing +├── reconcileGraph.ts # Iterative graph indexing, SCCs, and state propagation ├── seq.ts # Per-(run_id, system:stream_id) contiguity checker ├── AGENTS.md # This guide └── contracts/ # Contract-level canonical envelope/finality/digest/bundle parsers diff --git a/src/reconcile.ts b/src/reconcile.ts index 41f2571..4a4d38a 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -1,238 +1,57 @@ -import type { CausalEvent, CausalEventSystem } from "./envelope.js"; -import { hashCausalEvent } from "./envelope.js"; -import { streamKey } from "./seq.js"; +import type { CausalEvent } from "./envelope.js"; +import { reconcileGraph } from "./reconcileGraph.js"; export type ReconciliationState = "complete" | "divergent" | "partial" | "stale" | "unknown"; export interface ReconciledRecord { event: CausalEvent; + localState: ReconciliationState; + /** Stable, machine-readable explanations for local state. */ + reasonCodes: string[]; + /** Compatibility projection of reasonCodes. */ reasons: string[]; + /** Highest-precedence local or reachable-cause state. */ state: ReconciliationState; } export interface ReconcileOptions { /** - * Declared final seq per (run_id, system:stream_id) stream, keyed by - * `streamKey(run_id, system, stream_id)`. When a stream's declared final - * seq exceeds the maximum seq actually ingested, the stream (and any - * dangling cause that resolves to it) is reported `stale` rather than - * `partial` or `unknown` — the gap is explained, not merely observed. + * Compatibility-only final hints, keyed by streamKey(). This does not seal a + * bundle and must not be used as production final authority. */ declaredFinalSeq?: Record; } export interface ReconcileResult { byEventId: Map; - /** Every raw occurrence seen per event_id, in ingestion order. Exposed so callers can inspect divergent duplicates. */ + /** Canonically ordered, isolated copies of every raw occurrence per id. */ occurrencesByEventId: Map; } -export interface CausalEdge { - from: string; - to: string; -} - -const buildSlotKey = (event: CausalEvent): string => - `${streamKey(event.run_id, event.emitter.system, event.emitter.stream_id)}#${event.emitter.seq}`; - -/** - * Pure, single-pass reconciler over parsed CausalEvent[] gathered from N - * sources. See specs/CAUSAL.md §4-§5 for the state definitions and - * precedence (divergent > unknown > partial > stale > complete). - */ -export const reconcileEvents = ( - events: CausalEvent[], - options: ReconcileOptions = {} -): ReconcileResult => { - const declaredFinalSeq = options.declaredFinalSeq ?? {}; - - // 1. Index by event_id; track every raw occurrence to detect divergence. - const occurrencesByEventId = new Map(); - for (const event of events) { - const occurrences = occurrencesByEventId.get(event.event_id) ?? []; - occurrences.push(event); - occurrencesByEventId.set(event.event_id, occurrences); - } - - const index = new Map(); - const divergentEventIds = new Set(); - for (const [eventId, occurrences] of occurrencesByEventId) { - const hashes = new Set(occurrences.map(hashCausalEvent)); - if (hashes.size > 1) { - divergentEventIds.add(eventId); - } - // First occurrence wins as the canonical record for graph traversal; - // divergence is still reported via divergentEventIds regardless. - index.set(eventId, occurrences[0]!); - } - - // 2a. Same (run_id, system:stream_id, seq) slot occupied by different - // event_ids is also divergent, even if neither event_id repeats on its own. - const bySlot = new Map(); - for (const event of events) { - const key = buildSlotKey(event); - const occupants = bySlot.get(key) ?? []; - occupants.push(event); - bySlot.set(key, occupants); - } - for (const occupants of bySlot.values()) { - const distinctEventIds = new Set(occupants.map((occupant) => occupant.event_id)); - if (distinctEventIds.size > 1) { - for (const occupant of occupants) { - divergentEventIds.add(occupant.event_id); - } - } - } - - // 2b. Conflicting fact hashes across stores: moltnet message.accepted - // content_sha256 vs. daimon turn.input.submitted input_content_sha256 for - // the same message_id. - const contentHashByMessageId = new Map(); - for (const event of index.values()) { - if (event.emitter.system === "moltnet" && event.type === "message.accepted") { - const messageId = event.payload.message_id; - const contentHash = event.payload.content_sha256; - if (typeof messageId === "string" && typeof contentHash === "string") { - contentHashByMessageId.set(messageId, contentHash); - } - } - } - for (const event of index.values()) { - if (event.emitter.system !== "daimon" || event.type !== "turn.input.submitted") { - continue; - } - const inputMessageIds = event.payload.input_message_ids; - const inputContentHash = event.payload.input_content_sha256; - if (!Array.isArray(inputMessageIds) || typeof inputContentHash !== "string") { - continue; - } - for (const messageId of inputMessageIds) { - if (typeof messageId !== "string") { - continue; - } - const knownHash = contentHashByMessageId.get(messageId); - if (knownHash !== undefined && knownHash !== inputContentHash) { - divergentEventIds.add(event.event_id); - } - } - } - - // Systems we have any ingested telemetry from at all, for the - // unknown-vs-partial distinction on dangling causes. - const seenSystems = new Set(events.map((event) => event.emitter.system)); - - const streamMaxSeq = new Map(); - for (const event of events) { - const key = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); - streamMaxSeq.set(key, Math.max(streamMaxSeq.get(key) ?? 0, event.emitter.seq)); - } - - const staleStreamKeys = new Set(); - for (const [key, declared] of Object.entries(declaredFinalSeq)) { - const maxSeq = streamMaxSeq.get(key) ?? 0; - if (declared > maxSeq) { - staleStreamKeys.add(key); - } - } - - const byEventId = new Map(); - - for (const [eventId, event] of index) { - const reasons: string[] = []; - - let hasUnknownCause = false; - let hasPartialCause = false; - let hasStaleCause = false; - - for (const causeId of event.cause_event_ids) { - if (index.has(causeId)) { - continue; - } - - const causeSystem = causeId.split(":")[0] ?? ""; - const resolvesToStaleStream = [...staleStreamKeys].some((key) => - key.startsWith(`${event.run_id}::${causeSystem}:`) - ); - - if (resolvesToStaleStream) { - hasStaleCause = true; - } else if (!seenSystems.has(causeSystem as CausalEventSystem)) { - hasUnknownCause = true; - } else { - hasPartialCause = true; - } - } - - const ownStreamKey = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); - const ownStreamIsStale = staleStreamKeys.has(ownStreamKey); - - let state: ReconciliationState; - if (divergentEventIds.has(eventId)) { - state = "divergent"; - reasons.push( - "duplicate event_id, conflicting (run_id, stream_id, seq) slot, or conflicting cross-store fact hash" - ); - } else if (hasUnknownCause) { - state = "unknown"; - reasons.push("cause_event_ids references a system with no ingested events"); - } else if (hasPartialCause) { - state = "partial"; - reasons.push("cause_event_ids references an event not yet ingested"); - } else if (hasStaleCause || ownStreamIsStale) { - state = "stale"; - reasons.push( - hasStaleCause - ? "cause_event_ids references a stream behind its declared final seq" - : "stream is behind its declared final seq" - ); - } else { - state = "complete"; - } - - byEventId.set(eventId, { event, reasons, state }); - } - - return { byEventId, occurrencesByEventId }; -}; +export interface CausalEdge { from: string; to: string; } -/** - * Walks cause_event_ids backward from startEventId only — never - * recorded_at, never a trace_id — collecting edges in deterministic - * depth-first order (each event's own cause_event_ids order, recursing - * before moving to the next sibling cause). Stops at any cause_event_id - * that cannot be resolved in the index (the edge is still emitted so - * callers can see the dangling reference). - */ -export const traceCausesBackward = ( - result: ReconcileResult | Map, - startEventId: string -): CausalEdge[] => { - const lookup = - result instanceof Map - ? result - : new Map([...result.byEventId].map(([eventId, record]) => [eventId, record.event])); +/** Pure reconciliation over parsed events; it never mutates or retains caller data. */ +export const reconcileEvents = (events: CausalEvent[], options: ReconcileOptions = {}): ReconcileResult => + reconcileGraph(events, options.declaredFinalSeq ?? {}); +/** Iterative depth-first backward tracing, preserving declared cause order. */ +export const traceCausesBackward = (result: ReconcileResult | Map, startEventId: string): CausalEdge[] => { + const lookup = result instanceof Map ? result : new Map([...result.byEventId].map(([id, record]) => [id, record.event])); const edges: CausalEdge[] = []; const visited = new Set(); - - const visit = (eventId: string): void => { - if (visited.has(eventId)) { - return; - } - visited.add(eventId); - - const event = lookup.get(eventId); - if (!event) { - return; - } - - for (const causeId of event.cause_event_ids) { - edges.push({ from: eventId, to: causeId }); - visit(causeId); - } - }; - - visit(startEventId); - + const stack: Array<{ id: string; next: number }> = [{ id: startEventId, next: -1 }]; + while (stack.length) { + const frame = stack.at(-1)!; + if (frame.next === -1) { + if (visited.has(frame.id) || !lookup.has(frame.id)) { stack.pop(); continue; } + visited.add(frame.id); + frame.next = 0; + } + const causes = lookup.get(frame.id)!.cause_event_ids; + if (frame.next >= causes.length) { stack.pop(); continue; } + const cause = causes[frame.next++]!; + edges.push({ from: frame.id, to: cause }); + if (!visited.has(cause) && lookup.has(cause)) stack.push({ id: cause, next: -1 }); + } return edges; }; diff --git a/src/reconcileGraph.test.ts b/src/reconcileGraph.test.ts new file mode 100644 index 0000000..7a95ac7 --- /dev/null +++ b/src/reconcileGraph.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { CAUSAL_EVENT_VERSION, type CausalEvent } from "./envelope.js"; +import { reconcileEvents, traceCausesBackward } from "./reconcile.js"; + +const event = (id: string, causes: string[] = [], run = "run-1", seq = 1): CausalEvent => ({ + cause_event_ids: causes, emitter: { seq, stream_id: `stream:${id}`, system: "moltnet" }, event_id: id, + payload: { id }, principal_id: "moltnet:authn:test", recorded_at: "2026-07-20T00:00:00.000Z", + run_id: run, type: "message.accepted", version: CAUSAL_EVENT_VERSION +}); + +describe("reconcileEvents graph hardening", () => { + it("marks every cycle member and its descendant divergent without contaminating its ancestor", () => { + const outside = event("moltnet:outside"); + const a = event("moltnet:a", ["moltnet:b", "moltnet:outside"]); + const b = event("moltnet:b", ["moltnet:c"]); + const c = event("moltnet:c", ["moltnet:a"]); + const child = event("moltnet:child", ["moltnet:a"]); + const result = reconcileEvents([outside, a, b, c, child]); + for (const id of ["moltnet:a", "moltnet:b", "moltnet:c", "moltnet:child"]) expect(result.byEventId.get(id)?.state).toBe("divergent"); + expect(result.byEventId.get("moltnet:outside")?.state).toBe("complete"); + expect(result.byEventId.get("moltnet:a")?.reasonCodes).toContain("cycle"); + }); + + it("rejects self and present cross-run causes, including an ambiguous id", () => { + const self = event("moltnet:self", ["moltnet:self"]); + const foreign = event("moltnet:foreign", [], "run-2"); + const child = event("moltnet:child", ["moltnet:foreign"]); + const result = reconcileEvents([self, foreign, child]); + expect(result.byEventId.get("moltnet:self")?.localState).toBe("divergent"); + expect(result.byEventId.get("moltnet:child")?.reasonCodes).toContain("cross-run-cause"); + expect(result.byEventId.get("moltnet:child")?.state).toBe("divergent"); + }); + + it("makes a same-id fact in two runs ambiguous to a direct in-run reference", () => { + const inRun = event("moltnet:shared", [], "run-1"); + const otherRun = event("moltnet:shared", [], "run-2"); + const child = event("daimon:child", ["moltnet:shared"]); + const result = reconcileEvents([inRun, otherRun, child]); + expect(result.byEventId.get("daimon:child")?.localState).toBe("divergent"); + expect(result.byEventId.get("daimon:child")?.reasonCodes).toContain("cross-run-cause"); + }); + + it("propagates precedence from reachable causes and scopes missing authority by run", () => { + const knownElsewhere = event("mneme:elsewhere", [], "run-2"); + const unknown = event("daimon:unknown", ["mneme:missing"]); + const partialRoot = event("moltnet:partial-root", ["moltnet:missing"]); + const partialChild = event("daimon:partial-child", ["moltnet:partial-root"]); + const result = reconcileEvents([knownElsewhere, unknown, partialRoot, partialChild]); + expect(result.byEventId.get("daimon:unknown")?.state).toBe("unknown"); + expect(result.byEventId.get("moltnet:partial-root")?.state).toBe("partial"); + expect(result.byEventId.get("daimon:partial-child")?.state).toBe("partial"); + }); + + it("selects an isolated canonical representative independently of input order", () => { + const first = event("moltnet:duplicate"); + const second = { ...first, payload: { id: "other" } }; + const left = reconcileEvents([first, second]); + const right = reconcileEvents([second, first]); + expect(left.byEventId.get("moltnet:duplicate")).toEqual(right.byEventId.get("moltnet:duplicate")); + first.payload.id = "mutated"; + first.cause_event_ids.push("moltnet:anything"); + expect(left.byEventId.get("moltnet:duplicate")?.event.payload.id).not.toBe("mutated"); + expect(left.occurrencesByEventId.get("moltnet:duplicate")?.[0]?.cause_event_ids).toEqual([]); + }); + + it("handles a 10k chain iteratively in reconciliation and tracing", () => { + const events: CausalEvent[] = []; + for (let n = 0; n < 10_100; n += 1) events.push(event(`moltnet:${n}`, n ? [`moltnet:${n - 1}`] : [], "run-1", n + 1)); + const result = reconcileEvents(events); + expect(result.byEventId.get("moltnet:10099")?.state).toBe("complete"); + expect(traceCausesBackward(result, "moltnet:10099")).toHaveLength(10_099); + }); +}); diff --git a/src/reconcileGraph.ts b/src/reconcileGraph.ts new file mode 100644 index 0000000..24c5c80 --- /dev/null +++ b/src/reconcileGraph.ts @@ -0,0 +1,127 @@ +import { canonicalJsonStringify, hashCausalEvent, type CausalEvent, type CausalEventSystem } from "./envelope.js"; +import type { ReconcileResult, ReconciledRecord, ReconciliationState } from "./reconcile.js"; +import { streamKey, streamSlotKey } from "./seq.js"; + +type Occurrence = { canonical: string; event: CausalEvent; hash: string }; +const rank: Record = { complete: 0, stale: 1, partial: 2, unknown: 3, divergent: 4 }; +const compare = (a: string, b: string) => a.localeCompare(b); +const copy = (event: CausalEvent): CausalEvent => JSON.parse(canonicalJsonStringify(event)) as CausalEvent; +const reasonText: Record = { + "conflicting-event-id": "duplicate event_id has conflicting facts", + "cross-run-event-id": "event_id occurs in more than one run", + "conflicting-stream-slot": "stream sequence slot has conflicting event ids", + "conflicting-cross-store-fact": "cross-store message content hash conflicts", + "self-cause": "event references itself as a cause", + cycle: "event is a member of a causal cycle", + "cross-run-cause": "cause is present only in another run", + "missing-cause-unknown": "cause references a system with no events in this run", + "missing-cause-partial": "cause event is not ingested in this run", + "stream-behind-declared-final": "stream is behind its declared final seq", + "cause-behind-declared-final": "cause stream is behind its declared final seq" +}; +const reasonRank: Record = { + "conflicting-event-id": 0, "conflicting-stream-slot": 1, "conflicting-cross-store-fact": 2, + "cross-run-cause": 3, "cross-run-event-id": 4, "self-cause": 5, cycle: 6, + "missing-cause-unknown": 10, "missing-cause-partial": 20, + "cause-behind-declared-final": 30, "stream-behind-declared-final": 31 +}; +const maxState = (left: ReconciliationState, right: ReconciliationState) => rank[left] >= rank[right] ? left : right; + +/** Graph-only implementation kept separate from the compatibility façade. */ +export const reconcileGraph = (events: CausalEvent[], declaredFinalSeq: Record): ReconcileResult => { + const byId = new Map(); + const slots = new Map>(); + const maxByStream = new Map(); + const systemsByRun = new Map>(); + for (const raw of events) { + const hash = hashCausalEvent(raw); // exactly once per input occurrence + const canonical = canonicalJsonStringify(raw); + const occurrence = { canonical, event: copy(raw), hash }; + const group = byId.get(raw.event_id) ?? []; + group.push(occurrence); byId.set(raw.event_id, group); + const slot = streamSlotKey(raw.run_id, raw.emitter.system, raw.emitter.stream_id, raw.emitter.seq); + const occupants = slots.get(slot) ?? new Set(); occupants.add(raw.event_id); slots.set(slot, occupants); + const key = streamKey(raw.run_id, raw.emitter.system, raw.emitter.stream_id); + maxByStream.set(key, Math.max(maxByStream.get(key) ?? 0, raw.emitter.seq)); + const systems = systemsByRun.get(raw.run_id) ?? new Set(); systems.add(raw.emitter.system); systemsByRun.set(raw.run_id, systems); + } + const occurrencesByEventId = new Map(); + const reps = new Map(); + const localCodes = new Map>(); + for (const id of [...byId.keys()].sort(compare)) { + const occurrences = byId.get(id)!; + occurrences.sort((a, b) => compare(`${a.event.run_id}\u0000${a.hash}\u0000${a.canonical}`, `${b.event.run_id}\u0000${b.hash}\u0000${b.canonical}`)); + occurrencesByEventId.set(id, occurrences.map(({ event }) => copy(event))); + reps.set(id, occurrences[0]!); + const codes = new Set(); localCodes.set(id, codes); + if (new Set(occurrences.map(({ hash }) => hash)).size > 1) codes.add("conflicting-event-id"); + if (new Set(occurrences.map(({ event }) => event.run_id)).size > 1) codes.add("cross-run-event-id"); + } + for (const ids of slots.values()) if (ids.size > 1) for (const id of ids) localCodes.get(id)?.add("conflicting-stream-slot"); + const staleSystemsByRun = new Map>(); + for (const [key, finalSeq] of Object.entries(declaredFinalSeq)) { + const max = maxByStream.get(key) ?? 0; + if (finalSeq > max) { + const tuple = JSON.parse(key) as unknown[]; + if (tuple.length === 3 && typeof tuple[0] === "string" && typeof tuple[1] === "string") { + const set = staleSystemsByRun.get(tuple[0]) ?? new Set(); set.add(tuple[1]); staleSystemsByRun.set(tuple[0], set); + } + } + } + for (const [id, rep] of reps) { + const event = rep.event; + const own = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); + if ((declaredFinalSeq[own] ?? 0) > (maxByStream.get(own) ?? 0)) localCodes.get(id)!.add("stream-behind-declared-final"); + } + const moltnet = new Map>(); + for (const { event } of reps.values()) if (event.emitter.system === "moltnet" && event.type === "message.accepted") { + const { message_id, content_sha256 } = event.payload; + if (typeof message_id === "string" && typeof content_sha256 === "string") { + const key = JSON.stringify([event.run_id, message_id]); + (moltnet.get(key) ?? moltnet.set(key, new Set()).get(key)!).add(content_sha256); + } + } + for (const [id, { event }] of reps) if (event.emitter.system === "daimon" && event.type === "turn.input.submitted") { + const { input_message_ids, input_content_sha256 } = event.payload; + if (Array.isArray(input_message_ids) && typeof input_content_sha256 === "string" && input_message_ids.some((messageId) => typeof messageId === "string" && [...(moltnet.get(JSON.stringify([event.run_id, messageId])) ?? [])].some((value) => value !== input_content_sha256))) localCodes.get(id)!.add("conflicting-cross-store-fact"); + } + const causes = new Map(); + const reverse = new Map(); + for (const id of reps.keys()) reverse.set(id, []); + for (const [id, { event }] of reps) { + const resolved: string[] = []; + for (const cause of event.cause_event_ids) { + const target = reps.get(cause); + if (!target) { + const system = cause.split(":", 1)[0] ?? ""; + if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add("cause-behind-declared-final"); + else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); + else localCodes.get(id)!.add("missing-cause-unknown"); + } else if (cause === id) localCodes.get(id)!.add("self-cause"); + else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); + else { resolved.push(cause); reverse.get(cause)!.push(id); } + } + causes.set(id, resolved); + } + // Iterative Kosaraju SCC: no call-stack dependence. + const order: string[] = []; const seen = new Set(); + for (const start of [...reps.keys()].sort(compare)) if (!seen.has(start)) { + const stack: Array<[string, number]> = [[start, 0]]; seen.add(start); + while (stack.length) { const top = stack.at(-1)!; const next = causes.get(top[0])![top[1]]; if (next !== undefined) { top[1] += 1; if (!seen.has(next)) { seen.add(next); stack.push([next, 0]); } } else { order.push(top[0]); stack.pop(); } } + } + const assigned = new Set(); + for (const start of order.reverse()) if (!assigned.has(start)) { + const component: string[] = []; const stack = [start]; assigned.add(start); + while (stack.length) { const id = stack.pop()!; component.push(id); for (const parent of reverse.get(id)!) if (!assigned.has(parent)) { assigned.add(parent); stack.push(parent); } } + if (component.length > 1) for (const id of component) localCodes.get(id)!.add("cycle"); + } + const localState = new Map(); + for (const [id, codes] of localCodes) localState.set(id, codes.has("conflicting-event-id") || codes.has("cross-run-event-id") || codes.has("conflicting-stream-slot") || codes.has("conflicting-cross-store-fact") || codes.has("self-cause") || codes.has("cycle") || codes.has("cross-run-cause") ? "divergent" : codes.has("missing-cause-unknown") ? "unknown" : codes.has("missing-cause-partial") ? "partial" : codes.size ? "stale" : "complete"); + const state = new Map(localState); const remaining = new Map(); const queue: string[] = []; const queued = new Set(); + for (const [id, deps] of causes) { remaining.set(id, deps.length); if (!deps.length || localState.get(id) === "divergent") { queue.push(id); queued.add(id); } } + queue.sort(compare); + for (let index = 0; index < queue.length; index += 1) { const id = queue[index]!; for (const child of reverse.get(id)!) { state.set(child, maxState(state.get(child)!, state.get(id)!)); const count = remaining.get(child)! - 1; remaining.set(child, count); if (!count && !queued.has(child)) { queue.push(child); queued.add(child); } } } + const byEventId = new Map(); + for (const id of [...reps.keys()].sort(compare)) { const codes = [...localCodes.get(id)!].sort((left, right) => reasonRank[left] - reasonRank[right] || compare(left, right)); byEventId.set(id, { event: copy(reps.get(id)!.event), localState: localState.get(id)!, reasonCodes: codes, reasons: codes.map((code) => reasonText[code]!), state: state.get(id)! }); } + return { byEventId, occurrencesByEventId }; +}; diff --git a/src/seq.test.ts b/src/seq.test.ts index c8e55bc..a5f564e 100644 --- a/src/seq.test.ts +++ b/src/seq.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { CAUSAL_EVENT_VERSION } from "./envelope.js"; import type { CausalEvent } from "./envelope.js"; -import { checkSeqContiguity, streamKey } from "./seq.js"; +import { checkSeqContiguity, streamKey, streamSlotKey } from "./seq.js"; const makeEvent = (overrides: Partial & { emitter: CausalEvent["emitter"] }): CausalEvent => ({ cause_event_ids: [], @@ -17,8 +17,10 @@ const makeEvent = (overrides: Partial & { emitter: CausalEvent["emi }); describe("streamKey", () => { - it("combines run_id, system, and stream_id", () => { - expect(streamKey("run-1", "moltnet", "network:room-1")).toBe("run-1::moltnet:network:room-1"); + it("uses a collision-safe tuple encoding", () => { + expect(streamKey("run-1", "moltnet", "network:room-1")).toBe('["run-1","moltnet","network:room-1"]'); + expect(streamKey("a::b", "c", "d:e")).not.toBe(streamKey("a", "b::c", "d:e")); + expect(streamSlotKey("a", "b", "c:1", 2)).not.toBe(streamSlotKey("a", "b:c", "1", 2)); }); }); @@ -40,7 +42,7 @@ describe("checkSeqContiguity", () => { const result = checkSeqContiguity(events); expect(result.gaps).toEqual([ - { maxSeq: 3, missing: [2], runId: "run-1", streamId: "network:room-1", system: "moltnet" } + { maxSeq: 3, missing: [{ from: 2, to: 2 }], runId: "run-1", streamId: "network:room-1", system: "moltnet" } ]); }); @@ -59,7 +61,7 @@ describe("checkSeqContiguity", () => { expect(result.gaps).toHaveLength(1); expect(result.gaps[0]).toEqual({ maxSeq: 3, - missing: [2], + missing: [{ from: 2, to: 2 }], runId: "run-1", streamId: "network:room-2", system: "moltnet" @@ -82,4 +84,11 @@ describe("checkSeqContiguity", () => { expect(result.gaps).toEqual([]); expect(result.maxSeqByStream.size).toBe(0); }); + + it("keeps sparse sequence gaps compact", () => { + const events = [1, Number.MAX_SAFE_INTEGER].map((seq) => + makeEvent({ emitter: { seq, stream_id: "sparse", system: "moltnet" } }) + ); + expect(checkSeqContiguity(events).gaps).toEqual([{ maxSeq: Number.MAX_SAFE_INTEGER, missing: [{ from: 2, to: Number.MAX_SAFE_INTEGER - 1 }], runId: "run-1", streamId: "sparse", system: "moltnet" }]); + }); }); diff --git a/src/seq.ts b/src/seq.ts index 6ea47a5..5fd973b 100644 --- a/src/seq.ts +++ b/src/seq.ts @@ -1,15 +1,22 @@ import type { CausalEvent } from "./envelope.js"; -/** - * Builds the (run_id, system:stream_id) grouping key used throughout the - * ledger for contiguity checks, staleness detection, and reconciliation. - */ +/** Opaque, collision-safe identity for a causal stream. */ export const streamKey = (runId: string, system: string, streamId: string): string => - `${runId}::${system}:${streamId}`; + JSON.stringify([runId, system, streamId]); + +/** Opaque, collision-safe identity for one stream sequence slot. */ +export const streamSlotKey = (runId: string, system: string, streamId: string, seq: number): string => + JSON.stringify([runId, system, streamId, seq]); + +export interface SeqRange { + from: number; + to: number; +} export interface SeqGap { maxSeq: number; - missing: number[]; + /** Sorted inclusive missing ranges; this never expands sparse sequence space. */ + missing: SeqRange[]; runId: string; streamId: string; system: string; @@ -20,53 +27,29 @@ export interface SeqContiguityResult { maxSeqByStream: Map; } -/** - * Requires seq contiguity from 1..max within each (run_id, system:stream_id) - * group. Duplicate seq values in the same stream do not themselves count as - * a gap here — conflicting occupants of the same slot are a reconciler - * concern (see reconcile.ts), not a contiguity concern. - */ +/** Checks contiguity with memory proportional to observed sequence values. */ export const checkSeqContiguity = (events: CausalEvent[]): SeqContiguityResult => { - const byStream = new Map(); - + const streams = new Map(); for (const event of events) { - const key = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); - const entry = byStream.get(key) ?? { - runId: event.run_id, - seqs: [], - streamId: event.emitter.stream_id, - system: event.emitter.system - }; - entry.seqs.push(event.emitter.seq); - byStream.set(key, entry); + const { run_id: runId, emitter } = event; + const key = streamKey(runId, emitter.system, emitter.stream_id); + const entry = streams.get(key) ?? { runId, seqs: [], streamId: emitter.stream_id, system: emitter.system }; + entry.seqs.push(emitter.seq); + streams.set(key, entry); } - const gaps: SeqGap[] = []; const maxSeqByStream = new Map(); - - for (const [key, entry] of byStream) { - const sorted = [...new Set(entry.seqs)].sort((left, right) => left - right); - const maxSeq = sorted[sorted.length - 1] ?? 0; + for (const [key, entry] of [...streams].sort(([left], [right]) => left.localeCompare(right))) { + const seqs = [...new Set(entry.seqs)].sort((left, right) => left - right); + const maxSeq = seqs.at(-1) ?? 0; maxSeqByStream.set(key, maxSeq); - - const present = new Set(sorted); - const missing: number[] = []; - for (let candidate = 1; candidate <= maxSeq; candidate += 1) { - if (!present.has(candidate)) { - missing.push(candidate); - } - } - - if (missing.length > 0) { - gaps.push({ - maxSeq, - missing, - runId: entry.runId, - streamId: entry.streamId, - system: entry.system - }); + const missing: SeqRange[] = []; + let expected = 1; + for (const seq of seqs) { + if (seq > expected) missing.push({ from: expected, to: seq - 1 }); + expected = seq + 1; } + if (missing.length) gaps.push({ maxSeq, missing, runId: entry.runId, streamId: entry.streamId, system: entry.system }); } - return { gaps, maxSeqByStream }; }; From f2458a8365b2a7dc9f06a79d0b7b8d8d380d5305 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 18:27:22 +0200 Subject: [PATCH 04/11] fix: harden reconciliation graph determinism --- README.md | 10 +++ src/reconcile.perf.test.ts | 16 +++- src/reconcile.test.ts | 15 ++++ src/reconcile.ts | 46 ++++++++++- src/reconcileGraph.test.ts | 88 ++++++++++++++++++++- src/reconcileGraph.ts | 153 +++++++++++++------------------------ src/seq.ts | 6 +- 7 files changed, 224 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 8524167..986a525 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # @noopolis/stele +## Reconciliation compatibility notes + +`streamKey(runId, system, streamId)` is an opaque JSON tuple encoding. Do +not parse it by delimiters; callers that persist or compare keys must treat +the complete returned string as the identity. + +`checkSeqContiguity().gaps[*].missing` contains sorted inclusive +`{ from, to }` ranges rather than one entry per missing sequence number. This +keeps sparse streams (including `Number.MAX_SAFE_INTEGER`) bounded in memory. + **The shared causal-event schema and reconciler for the Noopolis ecosystem.** Stele defines the canonical shape of a *causal event*, the rules for parsing and diff --git a/src/reconcile.perf.test.ts b/src/reconcile.perf.test.ts index ae7e871..ac385ee 100644 --- a/src/reconcile.perf.test.ts +++ b/src/reconcile.perf.test.ts @@ -43,7 +43,7 @@ const buildSyntheticStream = (streamCount: number, perStream: number): CausalEve return events; }; -describe("reconcileEvents — scale and single-pass linearity", () => { +describe("reconcileEvents — O(V log V + E) total scale", () => { afterEach(() => { vi.restoreAllMocks(); }); @@ -89,8 +89,8 @@ describe("reconcileEvents — scale and single-pass linearity", () => { const result = reconcileEvents(allEvents); const elapsedMs = Date.now() - startedAt; - // Structural single-index proof: one indexed record per distinct - // event_id, never a cross-product of the input. + // Structural proof: one indexed record per id, with one canonical + // O(V log V) ordering pass and iterative O(V + E) graph traversal. const distinctEventIds = new Set(allEvents.map((event) => event.event_id)); expect(result.byEventId.size).toBe(distinctEventIds.size); const totalOccurrences = [...result.occurrencesByEventId.values()].reduce( @@ -133,4 +133,14 @@ describe("reconcileEvents — scale and single-pass linearity", () => { // (a quadratic reconciler would hash on the order of N^2 times). expect(largeHashCalls).toBe(smallHashCalls * 2); }); + + it("handles a 10k-node cycle iteratively within the performance ceiling", () => { + const events = buildSyntheticStream(1, 10_000); + events[0]!.cause_event_ids = [events.at(-1)!.event_id]; + const startedAt = Date.now(); + const result = reconcileEvents(events); + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(result.byEventId.get(events[0]!.event_id)?.state).toBe("divergent"); + expect(result.byEventId.get(events.at(-1)!.event_id)?.state).toBe("divergent"); + }); }); diff --git a/src/reconcile.test.ts b/src/reconcile.test.ts index fc42f75..af03dc6 100644 --- a/src/reconcile.test.ts +++ b/src/reconcile.test.ts @@ -193,6 +193,21 @@ describe("reconcileEvents — full interaction chain", () => { }); describe("traceCausesBackward — guard branches", () => { + it("terminates self and two-node cycles while retaining the encountered edges", () => { + const self = { ...m1, cause_event_ids: ["moltnet:m1"] }; + expect(traceCausesBackward(new Map([[self.event_id, self]]), self.event_id)).toEqual([{ from: "moltnet:m1", to: "moltnet:m1" }]); + const a = { ...m1, event_id: "moltnet:a", cause_event_ids: ["moltnet:b"] }; + const b = { ...m1, event_id: "moltnet:b", cause_event_ids: ["moltnet:a"] }; + expect(traceCausesBackward(new Map([[a.event_id, a], [b.event_id, b]]), a.event_id)).toEqual([{ from: "moltnet:a", to: "moltnet:b" }, { from: "moltnet:b", to: "moltnet:a" }]); + }); + + it("records dangling causes and handles a deep iterative chain", () => { + const dangling = { ...m1, cause_event_ids: ["moltnet:absent"] }; + expect(traceCausesBackward(new Map([[dangling.event_id, dangling]]), dangling.event_id)).toEqual([{ from: "moltnet:m1", to: "moltnet:absent" }]); + const chain = new Map(); + for (let index = 0; index < 10_000; index += 1) chain.set(`moltnet:${index}`, { ...m1, event_id: `moltnet:${index}`, cause_event_ids: index ? [`moltnet:${index - 1}`] : [] }); + expect(traceCausesBackward(chain, "moltnet:9999")).toHaveLength(9_999); + }); it("returns no edges when the start event_id cannot be resolved", () => { const result = reconcileEvents(fullChain); expect(traceCausesBackward(result, "moltnet:does-not-exist")).toEqual([]); diff --git a/src/reconcile.ts b/src/reconcile.ts index 4a4d38a..ca39afc 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -1,5 +1,11 @@ import type { CausalEvent } from "./envelope.js"; import { reconcileGraph } from "./reconcileGraph.js"; +import { types } from "node:util"; + +export interface CheckedFinalHints { + finals: Map; + invalid: boolean; +} export type ReconciliationState = "complete" | "divergent" | "partial" | "stale" | "unknown"; @@ -30,9 +36,45 @@ export interface ReconcileResult { export interface CausalEdge { from: string; to: string; } +const systems = new Set(["moltnet", "daimon", "mneme", "simfile"]); + +/** + * Reads compatibility hints only through own data descriptors. `isProxy` + * rejects proxies before reflection, so getters and proxy `get` traps are + * never used as part of reconciliation. + */ +export const checkDeclaredFinalHints = (options: unknown): CheckedFinalHints => { + const empty = (): CheckedFinalHints => ({ finals: new Map(), invalid: false }); + if (options === undefined) return empty(); + try { + if (options === null || typeof options !== "object" || types.isProxy(options) || Object.getPrototypeOf(options) !== Object.prototype) return { finals: new Map(), invalid: true }; + const optionKeys = Reflect.ownKeys(options); + if (optionKeys.some((key) => key !== "declaredFinalSeq")) return { finals: new Map(), invalid: true }; + const optionDescriptor = Object.getOwnPropertyDescriptor(options, "declaredFinalSeq"); + if (!optionDescriptor) return empty(); + if (!("value" in optionDescriptor) || types.isProxy(optionDescriptor.value)) return { finals: new Map(), invalid: true }; + const hints = optionDescriptor.value; + if (hints === null || typeof hints !== "object" || Object.getPrototypeOf(hints) !== Object.prototype || Reflect.ownKeys(hints).some((key) => typeof key !== "string")) return { finals: new Map(), invalid: true }; + const finals = new Map(); + for (const key of Object.keys(hints)) { + const descriptor = Object.getOwnPropertyDescriptor(hints, key); + if (!descriptor || !("value" in descriptor) || !Number.isSafeInteger(descriptor.value) || descriptor.value < 0) return { finals: new Map(), invalid: true }; + const tuple = JSON.parse(key); + if (!Array.isArray(tuple) || tuple.length !== 3 || tuple.some((item) => typeof item !== "string") || tuple.some((item) => item.length === 0) || !systems.has(tuple[1]) || JSON.stringify(tuple) !== key) return { finals: new Map(), invalid: true }; + finals.set(key, descriptor.value); + } + return { finals, invalid: false }; + } catch { + return { finals: new Map(), invalid: true }; + } +}; + /** Pure reconciliation over parsed events; it never mutates or retains caller data. */ -export const reconcileEvents = (events: CausalEvent[], options: ReconcileOptions = {}): ReconcileResult => - reconcileGraph(events, options.declaredFinalSeq ?? {}); +export const reconcileEvents = (events: CausalEvent[], options?: ReconcileOptions): ReconcileResult => { + // Empty input has no indexed records, so compatibility input is irrelevant. + if (events.length === 0) return { byEventId: new Map(), occurrencesByEventId: new Map() }; + return reconcileGraph(events, checkDeclaredFinalHints(options)); +}; /** Iterative depth-first backward tracing, preserving declared cause order. */ export const traceCausesBackward = (result: ReconcileResult | Map, startEventId: string): CausalEdge[] => { diff --git a/src/reconcileGraph.test.ts b/src/reconcileGraph.test.ts index 7a95ac7..d9f2ead 100644 --- a/src/reconcileGraph.test.ts +++ b/src/reconcileGraph.test.ts @@ -1,7 +1,9 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { CAUSAL_EVENT_VERSION, type CausalEvent } from "./envelope.js"; import { reconcileEvents, traceCausesBackward } from "./reconcile.js"; +import { streamKey } from "./seq.js"; const event = (id: string, causes: string[] = [], run = "run-1", seq = 1): CausalEvent => ({ cause_event_ids: causes, emitter: { seq, stream_id: `stream:${id}`, system: "moltnet" }, event_id: id, @@ -22,14 +24,30 @@ describe("reconcileEvents graph hardening", () => { expect(result.byEventId.get("moltnet:a")?.reasonCodes).toContain("cycle"); }); + it("marks explicit two-node cycles and their descendants divergent", () => { + const a = event("moltnet:a", ["moltnet:b"]); + const b = event("moltnet:b", ["moltnet:a"]); + const child = event("moltnet:child", ["moltnet:b"]); + const outside = event("moltnet:outside"); + const result = reconcileEvents([child, outside, b, a]); + for (const id of ["moltnet:a", "moltnet:b", "moltnet:child"]) { + expect(result.byEventId.get(id)).toMatchObject({ state: "divergent" }); + expect(result.byEventId.get(id)?.reasonCodes.length).toBeGreaterThan(0); + } + expect(result.byEventId.get("moltnet:child")?.reasonCodes).toEqual(["ancestor-divergent"]); + expect(result.byEventId.get("moltnet:outside")?.state).toBe("complete"); + }); + it("rejects self and present cross-run causes, including an ambiguous id", () => { const self = event("moltnet:self", ["moltnet:self"]); const foreign = event("moltnet:foreign", [], "run-2"); const child = event("moltnet:child", ["moltnet:foreign"]); - const result = reconcileEvents([self, foreign, child]); + const descendant = event("daimon:descendant", ["moltnet:child"]); + const result = reconcileEvents([self, foreign, child, descendant]); expect(result.byEventId.get("moltnet:self")?.localState).toBe("divergent"); expect(result.byEventId.get("moltnet:child")?.reasonCodes).toContain("cross-run-cause"); expect(result.byEventId.get("moltnet:child")?.state).toBe("divergent"); + expect(result.byEventId.get("daimon:descendant")?.reasonCodes).toEqual(["ancestor-divergent"]); }); it("makes a same-id fact in two runs ambiguous to a direct in-run reference", () => { @@ -52,15 +70,79 @@ describe("reconcileEvents graph hardening", () => { expect(result.byEventId.get("daimon:partial-child")?.state).toBe("partial"); }); + it("explains highest-precedence state propagated through a diamond", () => { + const divergent = event("moltnet:divergent", ["moltnet:divergent"]); + const unknown = event("daimon:unknown", ["mneme:missing"]); + const partial = { ...event("mneme:partial", ["mneme:missing"]), emitter: { seq: 1, stream_id: "partial", system: "mneme" as const }, principal_id: "mneme:authn:test" }; + const stale = event("moltnet:stale", [], "run-1", 3); + const left = event("moltnet:left", ["moltnet:divergent", "daimon:unknown"]); + const right = event("moltnet:right", ["mneme:partial", "moltnet:stale"]); + const root = event("moltnet:root", ["moltnet:left", "moltnet:right"]); + const result = reconcileEvents([root, right, stale, partial, left, unknown, divergent], { + declaredFinalSeq: { [streamKey("run-1", "moltnet", "stream:moltnet:stale")]: 4 } + }); + expect(result.byEventId.get("moltnet:left")?.reasonCodes).toEqual(["ancestor-divergent"]); + expect(result.byEventId.get("moltnet:right")?.reasonCodes).toEqual(["ancestor-partial"]); + expect(result.byEventId.get("moltnet:root")?.reasonCodes).toEqual(["ancestor-divergent"]); + expect(result.byEventId.get("moltnet:root")?.state).toBe("divergent"); + for (const record of result.byEventId.values()) if (record.state !== "complete") expect(record.reasons.length).toBeGreaterThan(0); + }); + + it("treats final gaps as stale and propagates them to descendants", () => { + const first = event("moltnet:first", [], "run-1", 1); + const third = event("moltnet:third", [], "run-1", 3); + const child = event("daimon:child", ["moltnet:third"]); + // The helper uses a distinct stream per event; make a genuine shared stream gap. + const sharedFirst = { ...first, emitter: { ...first.emitter, stream_id: "shared" } }; + const sharedThird = { ...third, emitter: { ...third.emitter, stream_id: "shared" } }; + const sharedChild = { ...child, cause_event_ids: [sharedThird.event_id] }; + const gapped = reconcileEvents([sharedChild, sharedThird, sharedFirst], { declaredFinalSeq: { [streamKey("run-1", "moltnet", "shared")]: 3 } }); + expect(gapped.byEventId.get("moltnet:third")?.localState).toBe("stale"); + expect(gapped.byEventId.get("daimon:child")?.reasonCodes).toEqual(["ancestor-stale"]); + }); + + it("fails closed for hostile declared-final compatibility input without getters or proxy get traps", () => { + let getterRead = false; let proxyRead = false; let finalGetterRead = false; + const getterOptions = Object.defineProperty({}, "declaredFinalSeq", { enumerable: true, get: () => { getterRead = true; throw new Error("nope"); } }); + const proxyOptions = new Proxy({ declaredFinalSeq: {} }, { get: () => { proxyRead = true; throw new Error("nope"); } }); + const finalGetter = { declaredFinalSeq: Object.defineProperty({}, streamKey("run-1", "moltnet", "s"), { enumerable: true, get: () => { finalGetterRead = true; throw new Error("nope"); } }) }; + const finalProxy = { declaredFinalSeq: new Proxy({}, { get: () => { proxyRead = true; throw new Error("nope"); } }) }; + const invalidKey = { declaredFinalSeq: { legacy: 1 } }; + const invalidValue = { declaredFinalSeq: { [streamKey("run-1", "moltnet", "s")]: -1 } }; + const extraOption = { declaredFinalSeq: {}, extra: true }; + for (const options of [getterOptions, proxyOptions, finalGetter, finalProxy, invalidKey, invalidValue, extraOption]) { + const result = reconcileEvents([event("moltnet:only")], options as never); + expect(result.byEventId.get("moltnet:only")?.reasonCodes).toEqual(["invalid-declared-final-hints"]); + } + expect(getterRead).toBe(false); expect(finalGetterRead).toBe(false); expect(proxyRead).toBe(false); + expect(reconcileEvents([], proxyOptions as never).byEventId.size).toBe(0); + }); + + it("uses UTF-16 ordering, never localeCompare, for representatives and output", () => { + const z = event("moltnet:z"); const umlaut = event("moltnet:ä"); + const duplicate = { ...z, payload: { id: "ä" } }; + const result = reconcileEvents([umlaut, duplicate, z]); + expect([...result.byEventId.keys()]).toEqual(["moltnet:z", "moltnet:ä"]); + expect(reconcileEvents([z, duplicate]).byEventId.get("moltnet:z")).toEqual(reconcileEvents([duplicate, z]).byEventId.get("moltnet:z")); + expect(readFileSync(new URL("./reconcileGraph.ts", import.meta.url), "utf8") + readFileSync(new URL("./seq.ts", import.meta.url), "utf8")).not.toContain("localeCompare"); + }); + it("selects an isolated canonical representative independently of input order", () => { - const first = event("moltnet:duplicate"); + const first = { ...event("moltnet:duplicate"), payload: { id: "original", nested: { value: "original" } } }; const second = { ...first, payload: { id: "other" } }; - const left = reconcileEvents([first, second]); + const input = [first, second]; + const left = reconcileEvents(input); const right = reconcileEvents([second, first]); expect(left.byEventId.get("moltnet:duplicate")).toEqual(right.byEventId.get("moltnet:duplicate")); first.payload.id = "mutated"; first.cause_event_ids.push("moltnet:anything"); + first.emitter.stream_id = "mutated"; + (first.payload as { nested: { value: string } }).nested.value = "mutated"; + input.length = 0; expect(left.byEventId.get("moltnet:duplicate")?.event.payload.id).not.toBe("mutated"); + expect(left.byEventId.get("moltnet:duplicate")?.event.emitter.stream_id).not.toBe("mutated"); + const original = left.occurrencesByEventId.get("moltnet:duplicate")?.find(({ payload }) => payload.id === "original"); + expect((original?.payload.nested as { value: string }).value).toBe("original"); expect(left.occurrencesByEventId.get("moltnet:duplicate")?.[0]?.cause_event_ids).toEqual([]); }); diff --git a/src/reconcileGraph.ts b/src/reconcileGraph.ts index 24c5c80..d34a81e 100644 --- a/src/reconcileGraph.ts +++ b/src/reconcileGraph.ts @@ -1,127 +1,78 @@ import { canonicalJsonStringify, hashCausalEvent, type CausalEvent, type CausalEventSystem } from "./envelope.js"; -import type { ReconcileResult, ReconciledRecord, ReconciliationState } from "./reconcile.js"; -import { streamKey, streamSlotKey } from "./seq.js"; +import type { CheckedFinalHints, ReconcileResult, ReconciledRecord, ReconciliationState } from "./reconcile.js"; +import { checkSeqContiguity, compareUtf16, streamKey, streamSlotKey } from "./seq.js"; type Occurrence = { canonical: string; event: CausalEvent; hash: string }; const rank: Record = { complete: 0, stale: 1, partial: 2, unknown: 3, divergent: 4 }; -const compare = (a: string, b: string) => a.localeCompare(b); const copy = (event: CausalEvent): CausalEvent => JSON.parse(canonicalJsonStringify(event)) as CausalEvent; +const compareOccurrence = (left: Occurrence, right: Occurrence): number => + compareUtf16(left.event.event_id, right.event.event_id) || compareUtf16(left.event.run_id, right.event.run_id) || compareUtf16(left.hash, right.hash) || compareUtf16(left.canonical, right.canonical); const reasonText: Record = { - "conflicting-event-id": "duplicate event_id has conflicting facts", - "cross-run-event-id": "event_id occurs in more than one run", - "conflicting-stream-slot": "stream sequence slot has conflicting event ids", - "conflicting-cross-store-fact": "cross-store message content hash conflicts", - "self-cause": "event references itself as a cause", - cycle: "event is a member of a causal cycle", - "cross-run-cause": "cause is present only in another run", - "missing-cause-unknown": "cause references a system with no events in this run", - "missing-cause-partial": "cause event is not ingested in this run", - "stream-behind-declared-final": "stream is behind its declared final seq", - "cause-behind-declared-final": "cause stream is behind its declared final seq" + "conflicting-event-id": "duplicate event_id has conflicting facts", "cross-run-event-id": "event_id occurs in more than one run", + "conflicting-stream-slot": "stream sequence slot has conflicting event ids", "conflicting-cross-store-fact": "cross-store message content hash conflicts", + "self-cause": "event references itself as a cause", cycle: "event is a member of a causal cycle", "cross-run-cause": "cause is present only in another run", + "missing-cause-unknown": "cause references a system with no events in this run", "missing-cause-partial": "cause event is not ingested in this run", + "stream-behind-declared-final": "stream is behind its declared final seq", "cause-behind-declared-final": "cause stream is behind its declared final seq", + "invalid-declared-final-hints": "declared final hints have an invalid compatibility shape", + "ancestor-divergent": "an ancestor is divergent", "ancestor-unknown": "an ancestor is unknown", "ancestor-partial": "an ancestor is partial", "ancestor-stale": "an ancestor is stale" }; const reasonRank: Record = { - "conflicting-event-id": 0, "conflicting-stream-slot": 1, "conflicting-cross-store-fact": 2, - "cross-run-cause": 3, "cross-run-event-id": 4, "self-cause": 5, cycle: 6, - "missing-cause-unknown": 10, "missing-cause-partial": 20, - "cause-behind-declared-final": 30, "stream-behind-declared-final": 31 + "invalid-declared-final-hints": 0, "conflicting-event-id": 1, "conflicting-stream-slot": 2, "conflicting-cross-store-fact": 3, "cross-run-cause": 4, "cross-run-event-id": 5, "self-cause": 6, cycle: 7, + "missing-cause-unknown": 10, "missing-cause-partial": 20, "cause-behind-declared-final": 30, "stream-behind-declared-final": 31, + "ancestor-divergent": 40, "ancestor-unknown": 41, "ancestor-partial": 42, "ancestor-stale": 43 }; const maxState = (left: ReconciliationState, right: ReconciliationState) => rank[left] >= rank[right] ? left : right; +const localStateFor = (codes: Set): ReconciliationState => + codes.has("invalid-declared-final-hints") || ["conflicting-event-id", "cross-run-event-id", "conflicting-stream-slot", "conflicting-cross-store-fact", "self-cause", "cycle", "cross-run-cause"].some((code) => codes.has(code)) ? "divergent" : + codes.has("missing-cause-unknown") ? "unknown" : codes.has("missing-cause-partial") ? "partial" : codes.size ? "stale" : "complete"; -/** Graph-only implementation kept separate from the compatibility façade. */ -export const reconcileGraph = (events: CausalEvent[], declaredFinalSeq: Record): ReconcileResult => { - const byId = new Map(); - const slots = new Map>(); - const maxByStream = new Map(); - const systemsByRun = new Map>(); - for (const raw of events) { - const hash = hashCausalEvent(raw); // exactly once per input occurrence - const canonical = canonicalJsonStringify(raw); - const occurrence = { canonical, event: copy(raw), hash }; - const group = byId.get(raw.event_id) ?? []; - group.push(occurrence); byId.set(raw.event_id, group); - const slot = streamSlotKey(raw.run_id, raw.emitter.system, raw.emitter.stream_id, raw.emitter.seq); - const occupants = slots.get(slot) ?? new Set(); occupants.add(raw.event_id); slots.set(slot, occupants); - const key = streamKey(raw.run_id, raw.emitter.system, raw.emitter.stream_id); - maxByStream.set(key, Math.max(maxByStream.get(key) ?? 0, raw.emitter.seq)); - const systems = systemsByRun.get(raw.run_id) ?? new Set(); systems.add(raw.emitter.system); systemsByRun.set(raw.run_id, systems); +/** + * Graph reconciliation uses one canonical O(V log V) occurrence/output pass; + * SCC discovery and propagation are iterative O(V + E), with no traversal sort. + */ +export const reconcileGraph = (events: CausalEvent[], hints: CheckedFinalHints): ReconcileResult => { + const occurrences = events.map((raw) => ({ canonical: canonicalJsonStringify(raw), event: copy(raw), hash: hashCausalEvent(raw) })); // exactly once per occurrence + occurrences.sort(compareOccurrence); + const byId = new Map(); const slots = new Map>(); const systemsByRun = new Map>(); + for (const occurrence of occurrences) { + const event = occurrence.event; (byId.get(event.event_id) ?? byId.set(event.event_id, []).get(event.event_id)!).push(occurrence); + const slot = streamSlotKey(event.run_id, event.emitter.system, event.emitter.stream_id, event.emitter.seq); (slots.get(slot) ?? slots.set(slot, new Set()).get(slot)!).add(event.event_id); + (systemsByRun.get(event.run_id) ?? systemsByRun.set(event.run_id, new Set()).get(event.run_id)!).add(event.emitter.system); } - const occurrencesByEventId = new Map(); - const reps = new Map(); - const localCodes = new Map>(); - for (const id of [...byId.keys()].sort(compare)) { - const occurrences = byId.get(id)!; - occurrences.sort((a, b) => compare(`${a.event.run_id}\u0000${a.hash}\u0000${a.canonical}`, `${b.event.run_id}\u0000${b.hash}\u0000${b.canonical}`)); - occurrencesByEventId.set(id, occurrences.map(({ event }) => copy(event))); - reps.set(id, occurrences[0]!); + const reps = new Map(); const occurrencesByEventId = new Map(); const localCodes = new Map>(); + for (const [id, group] of byId) { + reps.set(id, group[0]!); occurrencesByEventId.set(id, group.map(({ event }) => copy(event))); const codes = new Set(); localCodes.set(id, codes); - if (new Set(occurrences.map(({ hash }) => hash)).size > 1) codes.add("conflicting-event-id"); - if (new Set(occurrences.map(({ event }) => event.run_id)).size > 1) codes.add("cross-run-event-id"); + if (hints.invalid) codes.add("invalid-declared-final-hints"); + if (new Set(group.map(({ hash }) => hash)).size > 1) codes.add("conflicting-event-id"); + if (new Set(group.map(({ event }) => event.run_id)).size > 1) codes.add("cross-run-event-id"); } - for (const ids of slots.values()) if (ids.size > 1) for (const id of ids) localCodes.get(id)?.add("conflicting-stream-slot"); + for (const ids of slots.values()) if (ids.size > 1) for (const id of ids) localCodes.get(id)!.add("conflicting-stream-slot"); + const contiguity = checkSeqContiguity(occurrences.map(({ event }) => event)); + const gapped = new Set(contiguity.gaps.map((gap) => streamKey(gap.runId, gap.system, gap.streamId))); const staleSystemsByRun = new Map>(); - for (const [key, finalSeq] of Object.entries(declaredFinalSeq)) { - const max = maxByStream.get(key) ?? 0; - if (finalSeq > max) { - const tuple = JSON.parse(key) as unknown[]; - if (tuple.length === 3 && typeof tuple[0] === "string" && typeof tuple[1] === "string") { - const set = staleSystemsByRun.get(tuple[0]) ?? new Set(); set.add(tuple[1]); staleSystemsByRun.set(tuple[0], set); - } - } + for (const [key, finalSeq] of hints.finals) { + const tuple = JSON.parse(key) as [string, string, string]; const max = contiguity.maxSeqByStream.get(key) ?? 0; + if (finalSeq > max || (finalSeq === max && gapped.has(key))) (staleSystemsByRun.get(tuple[0]) ?? staleSystemsByRun.set(tuple[0], new Set()).get(tuple[0])!).add(tuple[1]); } - for (const [id, rep] of reps) { - const event = rep.event; - const own = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); - if ((declaredFinalSeq[own] ?? 0) > (maxByStream.get(own) ?? 0)) localCodes.get(id)!.add("stream-behind-declared-final"); - } - const moltnet = new Map>(); - for (const { event } of reps.values()) if (event.emitter.system === "moltnet" && event.type === "message.accepted") { - const { message_id, content_sha256 } = event.payload; - if (typeof message_id === "string" && typeof content_sha256 === "string") { - const key = JSON.stringify([event.run_id, message_id]); - (moltnet.get(key) ?? moltnet.set(key, new Set()).get(key)!).add(content_sha256); - } - } - for (const [id, { event }] of reps) if (event.emitter.system === "daimon" && event.type === "turn.input.submitted") { - const { input_message_ids, input_content_sha256 } = event.payload; - if (Array.isArray(input_message_ids) && typeof input_content_sha256 === "string" && input_message_ids.some((messageId) => typeof messageId === "string" && [...(moltnet.get(JSON.stringify([event.run_id, messageId])) ?? [])].some((value) => value !== input_content_sha256))) localCodes.get(id)!.add("conflicting-cross-store-fact"); - } - const causes = new Map(); - const reverse = new Map(); - for (const id of reps.keys()) reverse.set(id, []); for (const [id, { event }] of reps) { - const resolved: string[] = []; - for (const cause of event.cause_event_ids) { - const target = reps.get(cause); - if (!target) { - const system = cause.split(":", 1)[0] ?? ""; - if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add("cause-behind-declared-final"); - else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); - else localCodes.get(id)!.add("missing-cause-unknown"); - } else if (cause === id) localCodes.get(id)!.add("self-cause"); - else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); - else { resolved.push(cause); reverse.get(cause)!.push(id); } - } - causes.set(id, resolved); + const key = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); const finalSeq = hints.finals.get(key); const max = contiguity.maxSeqByStream.get(key) ?? 0; + if (finalSeq !== undefined && (finalSeq > max || (finalSeq === max && gapped.has(key)))) localCodes.get(id)!.add("stream-behind-declared-final"); } - // Iterative Kosaraju SCC: no call-stack dependence. + const moltnet = new Map>(); + for (const { event } of reps.values()) if (event.emitter.system === "moltnet" && event.type === "message.accepted") { const { message_id, content_sha256 } = event.payload; if (typeof message_id === "string" && typeof content_sha256 === "string") (moltnet.get(JSON.stringify([event.run_id, message_id])) ?? moltnet.set(JSON.stringify([event.run_id, message_id]), new Set()).get(JSON.stringify([event.run_id, message_id]))!).add(content_sha256); } + for (const [id, { event }] of reps) if (event.emitter.system === "daimon" && event.type === "turn.input.submitted") { const { input_message_ids, input_content_sha256 } = event.payload; if (Array.isArray(input_message_ids) && typeof input_content_sha256 === "string" && input_message_ids.some((messageId) => typeof messageId === "string" && [...(moltnet.get(JSON.stringify([event.run_id, messageId])) ?? [])].some((value) => value !== input_content_sha256))) localCodes.get(id)!.add("conflicting-cross-store-fact"); } + const causes = new Map(); const reverse = new Map(); for (const id of reps.keys()) reverse.set(id, []); + for (const [id, { event }] of reps) { const resolved: string[] = []; for (const cause of event.cause_event_ids) { const target = reps.get(cause); if (!target) { const system = cause.split(":", 1)[0] ?? ""; if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add("cause-behind-declared-final"); else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); else localCodes.get(id)!.add("missing-cause-unknown"); } else if (cause === id) localCodes.get(id)!.add("self-cause"); else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); else { resolved.push(cause); reverse.get(cause)!.push(id); } } causes.set(id, resolved); } const order: string[] = []; const seen = new Set(); - for (const start of [...reps.keys()].sort(compare)) if (!seen.has(start)) { - const stack: Array<[string, number]> = [[start, 0]]; seen.add(start); - while (stack.length) { const top = stack.at(-1)!; const next = causes.get(top[0])![top[1]]; if (next !== undefined) { top[1] += 1; if (!seen.has(next)) { seen.add(next); stack.push([next, 0]); } } else { order.push(top[0]); stack.pop(); } } - } + for (const start of reps.keys()) if (!seen.has(start)) { const stack: Array<[string, number]> = [[start, 0]]; seen.add(start); while (stack.length) { const top = stack.at(-1)!; const next = causes.get(top[0])![top[1]]; if (next !== undefined) { top[1] += 1; if (!seen.has(next)) { seen.add(next); stack.push([next, 0]); } } else { order.push(top[0]); stack.pop(); } } } const assigned = new Set(); - for (const start of order.reverse()) if (!assigned.has(start)) { - const component: string[] = []; const stack = [start]; assigned.add(start); - while (stack.length) { const id = stack.pop()!; component.push(id); for (const parent of reverse.get(id)!) if (!assigned.has(parent)) { assigned.add(parent); stack.push(parent); } } - if (component.length > 1) for (const id of component) localCodes.get(id)!.add("cycle"); - } - const localState = new Map(); - for (const [id, codes] of localCodes) localState.set(id, codes.has("conflicting-event-id") || codes.has("cross-run-event-id") || codes.has("conflicting-stream-slot") || codes.has("conflicting-cross-store-fact") || codes.has("self-cause") || codes.has("cycle") || codes.has("cross-run-cause") ? "divergent" : codes.has("missing-cause-unknown") ? "unknown" : codes.has("missing-cause-partial") ? "partial" : codes.size ? "stale" : "complete"); + for (let index = order.length - 1; index >= 0; index -= 1) { const start = order[index]!; if (assigned.has(start)) continue; const component: string[] = []; const stack = [start]; assigned.add(start); while (stack.length) { const id = stack.pop()!; component.push(id); for (const parent of reverse.get(id)!) if (!assigned.has(parent)) { assigned.add(parent); stack.push(parent); } } if (component.length > 1) for (const id of component) localCodes.get(id)!.add("cycle"); } + const localState = new Map(); for (const [id, codes] of localCodes) localState.set(id, localStateFor(codes)); const state = new Map(localState); const remaining = new Map(); const queue: string[] = []; const queued = new Set(); for (const [id, deps] of causes) { remaining.set(id, deps.length); if (!deps.length || localState.get(id) === "divergent") { queue.push(id); queued.add(id); } } - queue.sort(compare); for (let index = 0; index < queue.length; index += 1) { const id = queue[index]!; for (const child of reverse.get(id)!) { state.set(child, maxState(state.get(child)!, state.get(id)!)); const count = remaining.get(child)! - 1; remaining.set(child, count); if (!count && !queued.has(child)) { queue.push(child); queued.add(child); } } } const byEventId = new Map(); - for (const id of [...reps.keys()].sort(compare)) { const codes = [...localCodes.get(id)!].sort((left, right) => reasonRank[left] - reasonRank[right] || compare(left, right)); byEventId.set(id, { event: copy(reps.get(id)!.event), localState: localState.get(id)!, reasonCodes: codes, reasons: codes.map((code) => reasonText[code]!), state: state.get(id)! }); } + for (const [id, rep] of reps) { const local = localState.get(id)!; const final = state.get(id)!; const codes = new Set(localCodes.get(id)!); if (final !== local) codes.add(`ancestor-${final}`); const ordered = [...codes].sort((left, right) => (reasonRank[left] ?? 99) - (reasonRank[right] ?? 99) || compareUtf16(left, right)); byEventId.set(id, { event: copy(rep.event), localState: local, reasonCodes: ordered, reasons: ordered.map((code) => reasonText[code]!), state: final }); } return { byEventId, occurrencesByEventId }; }; diff --git a/src/seq.ts b/src/seq.ts index 5fd973b..4f9c4d6 100644 --- a/src/seq.ts +++ b/src/seq.ts @@ -1,5 +1,9 @@ import type { CausalEvent } from "./envelope.js"; +/** Contract ordering: code-unit (UTF-16), never locale/ICU dependent. */ +export const compareUtf16 = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + /** Opaque, collision-safe identity for a causal stream. */ export const streamKey = (runId: string, system: string, streamId: string): string => JSON.stringify([runId, system, streamId]); @@ -39,7 +43,7 @@ export const checkSeqContiguity = (events: CausalEvent[]): SeqContiguityResult = } const gaps: SeqGap[] = []; const maxSeqByStream = new Map(); - for (const [key, entry] of [...streams].sort(([left], [right]) => left.localeCompare(right))) { + for (const [key, entry] of [...streams].sort(([left], [right]) => compareUtf16(left, right))) { const seqs = [...new Set(entry.seqs)].sort((left, right) => left - right); const maxSeq = seqs.at(-1) ?? 0; maxSeqByStream.set(key, maxSeq); From abb8332adf8c0a42da1e305ec771b6dab4ff5f0d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 18:33:36 +0200 Subject: [PATCH 05/11] fix: reject hidden declared final descriptors --- src/reconcile.ts | 11 +++++++---- src/reconcileGraph.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/reconcile.ts b/src/reconcile.ts index ca39afc..6506acc 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -52,13 +52,16 @@ export const checkDeclaredFinalHints = (options: unknown): CheckedFinalHints => if (optionKeys.some((key) => key !== "declaredFinalSeq")) return { finals: new Map(), invalid: true }; const optionDescriptor = Object.getOwnPropertyDescriptor(options, "declaredFinalSeq"); if (!optionDescriptor) return empty(); - if (!("value" in optionDescriptor) || types.isProxy(optionDescriptor.value)) return { finals: new Map(), invalid: true }; + if (!("value" in optionDescriptor) || !optionDescriptor.enumerable || types.isProxy(optionDescriptor.value)) return { finals: new Map(), invalid: true }; const hints = optionDescriptor.value; - if (hints === null || typeof hints !== "object" || Object.getPrototypeOf(hints) !== Object.prototype || Reflect.ownKeys(hints).some((key) => typeof key !== "string")) return { finals: new Map(), invalid: true }; + if (hints === null || typeof hints !== "object" || Object.getPrototypeOf(hints) !== Object.prototype) return { finals: new Map(), invalid: true }; + const hintKeys = Reflect.ownKeys(hints); + if (hintKeys.some((key) => typeof key !== "string")) return { finals: new Map(), invalid: true }; const finals = new Map(); - for (const key of Object.keys(hints)) { + for (const key of hintKeys) { + if (typeof key !== "string") return { finals: new Map(), invalid: true }; const descriptor = Object.getOwnPropertyDescriptor(hints, key); - if (!descriptor || !("value" in descriptor) || !Number.isSafeInteger(descriptor.value) || descriptor.value < 0) return { finals: new Map(), invalid: true }; + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable || !Number.isSafeInteger(descriptor.value) || descriptor.value < 0) return { finals: new Map(), invalid: true }; const tuple = JSON.parse(key); if (!Array.isArray(tuple) || tuple.length !== 3 || tuple.some((item) => typeof item !== "string") || tuple.some((item) => item.length === 0) || !systems.has(tuple[1]) || JSON.stringify(tuple) !== key) return { finals: new Map(), invalid: true }; finals.set(key, descriptor.value); diff --git a/src/reconcileGraph.test.ts b/src/reconcileGraph.test.ts index d9f2ead..d743861 100644 --- a/src/reconcileGraph.test.ts +++ b/src/reconcileGraph.test.ts @@ -110,7 +110,11 @@ describe("reconcileEvents graph hardening", () => { const invalidKey = { declaredFinalSeq: { legacy: 1 } }; const invalidValue = { declaredFinalSeq: { [streamKey("run-1", "moltnet", "s")]: -1 } }; const extraOption = { declaredFinalSeq: {}, extra: true }; - for (const options of [getterOptions, proxyOptions, finalGetter, finalProxy, invalidKey, invalidValue, extraOption]) { + const hiddenOptions = Object.defineProperty({}, "declaredFinalSeq", { value: {}, enumerable: false }); + const hiddenHints = Object.defineProperty({}, streamKey("run-1", "moltnet", "s"), { value: 1, enumerable: false }); + const hiddenHintsWithGetter = Object.defineProperty({}, "hidden", { enumerable: false, get: () => { finalGetterRead = true; throw new Error("nope"); } }); + const hiddenGetterOptions = Object.defineProperty({}, "declaredFinalSeq", { enumerable: false, get: () => { getterRead = true; throw new Error("nope"); } }); + for (const options of [getterOptions, proxyOptions, finalGetter, finalProxy, invalidKey, invalidValue, extraOption, hiddenOptions, { declaredFinalSeq: hiddenHints }, hiddenGetterOptions, { declaredFinalSeq: hiddenHintsWithGetter }]) { const result = reconcileEvents([event("moltnet:only")], options as never); expect(result.byEventId.get("moltnet:only")?.reasonCodes).toEqual(["invalid-declared-final-hints"]); } From 48aaf236fde6621fce81c994fdeccb67850b80de Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 18:36:22 +0200 Subject: [PATCH 06/11] fix: validate final hints before map population --- src/reconcile.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reconcile.ts b/src/reconcile.ts index 6506acc..ed06c8b 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -57,15 +57,17 @@ export const checkDeclaredFinalHints = (options: unknown): CheckedFinalHints => if (hints === null || typeof hints !== "object" || Object.getPrototypeOf(hints) !== Object.prototype) return { finals: new Map(), invalid: true }; const hintKeys = Reflect.ownKeys(hints); if (hintKeys.some((key) => typeof key !== "string")) return { finals: new Map(), invalid: true }; - const finals = new Map(); + const checkedEntries: Array<[string, number]> = []; for (const key of hintKeys) { if (typeof key !== "string") return { finals: new Map(), invalid: true }; const descriptor = Object.getOwnPropertyDescriptor(hints, key); if (!descriptor || !("value" in descriptor) || !descriptor.enumerable || !Number.isSafeInteger(descriptor.value) || descriptor.value < 0) return { finals: new Map(), invalid: true }; const tuple = JSON.parse(key); if (!Array.isArray(tuple) || tuple.length !== 3 || tuple.some((item) => typeof item !== "string") || tuple.some((item) => item.length === 0) || !systems.has(tuple[1]) || JSON.stringify(tuple) !== key) return { finals: new Map(), invalid: true }; - finals.set(key, descriptor.value); + checkedEntries.push([key, descriptor.value]); } + const finals = new Map(); + for (const [key, value] of checkedEntries) finals.set(key, value); return { finals, invalid: false }; } catch { return { finals: new Map(), invalid: true }; From e662c2ff1262282da52e4907e59cae8693ea072d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 18:44:31 +0200 Subject: [PATCH 07/11] feat(reconcile): add sealed causal bundle reconciliation --- README.md | 9 +++ src/AGENTS.md | 5 ++ src/bundleReconcile.test.ts | 116 ++++++++++++++++++++++++++++++++++ src/bundleReconcile.ts | 96 ++++++++++++++++++++++++++++ src/contracts/digestDomain.ts | 23 +++++++ src/index.ts | 1 + src/reconcileGraph.ts | 24 +++++-- 7 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 src/bundleReconcile.test.ts create mode 100644 src/bundleReconcile.ts diff --git a/README.md b/README.md index 986a525..7c3b360 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ npm install @noopolis/stele `ReconciliationState`. - **Seq** — `checkSeqContiguity` and `streamKey` detect gaps in a per-stream sequence, surfacing `SeqGap`s instead of silently stitching over them. +- **Sealed bundles** — `reconcileCausalBundle(input)` parses mixed raw JSONL + text or bytes, uses only `noopolis.causal-stream-final.v1` records as final + authority, and returns `invalid`, `incomplete`, or `valid` with deterministic + parser, stream, and graph diagnostics. `declaredFinalSeq` remains a + compatibility-only `reconcileEvents` option and never seals a bundle. +- **Digest comparison** — `compareCausalDigest(domain, subject, expectedHash)` + is a pure fail-closed SHA-256 comparison for a recognized domain. Exact UTF-8 + and byte subjects are hashed exactly as supplied; digest declarations do not + assign product-specific subjects or producers. ## Example diff --git a/src/AGENTS.md b/src/AGENTS.md index dc97a92..d986289 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -6,6 +6,7 @@ src/ ├── envelope.ts # Compatibility facade re-exporting contract exports ├── reconcile.ts # Pure reconciler + backward cause-chain tracing ├── reconcileGraph.ts # Iterative graph indexing, SCCs, and state propagation +├── bundleReconcile.ts # Raw bundle parsing, authoritative finals, and bundle verdict ├── seq.ts # Per-(run_id, system:stream_id) contiguity checker ├── AGENTS.md # This guide └── contracts/ # Contract-level canonical envelope/finality/digest/bundle parsers @@ -31,3 +32,7 @@ src/ Keep this package read/verify-only. Emit-side logic and fixture/conformance harnesses belong in Spawnfile's `src/ledger/`, not here. + +`reconcileCausalBundle` is the sealed raw-wire entry point. It must use only +the contract bundle parsers and parsed stream-final records as final authority; +compatibility `declaredFinalSeq` hints remain confined to `reconcileEvents`. diff --git a/src/bundleReconcile.test.ts b/src/bundleReconcile.test.ts new file mode 100644 index 0000000..97a1849 --- /dev/null +++ b/src/bundleReconcile.test.ts @@ -0,0 +1,116 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { reconcileCausalBundle } from "./bundleReconcile.js"; +import { compareCausalDigest, SUPPORTED_CAUSAL_DIGEST_DECLARATIONS } from "./contracts/digestDomain.js"; +import { canonicalJsonStringify, type CausalEvent } from "./envelope.js"; + +const event = (id: string, seq = 1, causes: string[] = [], stream = "s", run = "run"): CausalEvent => ({ + cause_event_ids: causes, emitter: { seq, stream_id: stream, system: "moltnet" }, event_id: id, + payload: { id }, principal_id: "agent:test", recorded_at: "2026-07-20T00:00:00.000Z", + run_id: run, type: "message.accepted", version: "noopolis.causal-event.v1" +}); +const final = (stream = "s", finalSeq = 1, run = "run") => ({ + emitter: { stream_id: stream, system: "moltnet" }, final_seq: finalSeq, run_id: run, version: "noopolis.causal-stream-final.v1" +}); +const lines = (...records: unknown[]) => records.map((record) => JSON.stringify(record)).join("\n"); +const domain = (label: "causal-event/canonical-json" | "content/exact-utf8" | "content/exact-bytes") => + SUPPORTED_CAUSAL_DIGEST_DECLARATIONS.find((candidate) => candidate.label === label)!; +const sha = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex"); + +describe("reconcileCausalBundle", () => { + it("seals disconnected and multi-stream graphs only with complete authoritative finals", () => { + const input = lines(event("moltnet:a", 1, [], "a"), event("moltnet:b", 1, ["moltnet:a"], "b"), final("b"), final("a")); + const result = reconcileCausalBundle(input); + expect(result.verdict).toBe("valid"); + expect(result.streams.map(({ streamId, status }) => [streamId, status])).toEqual([["a", "complete"], ["b", "complete"]]); + expect([...result.graph.byEventId.values()].every(({ state }) => state === "complete")).toBe(true); + }); + + it("retains accepted records while parser errors independently invalidate", () => { + const result = reconcileCausalBundle(`${lines(event("moltnet:a"), final())}\n{`); + expect(result.verdict).toBe("invalid"); + expect(result.diagnostics).toEqual([expect.objectContaining({ code: "parser-error", line: 3 })]); + expect(result.graph.byEventId.get("moltnet:a")?.state).toBe("complete"); + expect(reconcileCausalBundle("{").graph.byEventId.size).toBe(0); + }); + + it("makes missing finals partial and authoritative gaps or absent positive streams stale", () => { + const partial = reconcileCausalBundle(lines(event("moltnet:a"), event("moltnet:b", 1, ["moltnet:a"], "b"), final("b"))); + expect(partial.verdict).toBe("incomplete"); + expect(partial.graph.byEventId.get("moltnet:a")?.reasonCodes).toContain("missing-authoritative-stream-final"); + expect(partial.graph.byEventId.get("moltnet:b")?.state).toBe("partial"); + const stale = reconcileCausalBundle(lines(event("moltnet:a", 1, [], "g"), event("moltnet:b", 3, [], "g"), final("g", 3), final("empty", 2))); + expect(stale.streams.map(({ streamId, status, missing }) => [streamId, status, missing])).toEqual([ + ["empty", "stale", []], ["g", "stale", [{ from: 2, to: 2 }]] + ]); + expect(stale.graph.byEventId.get("moltnet:a")?.state).toBe("stale"); + expect(reconcileCausalBundle(lines(final("empty", 0))).verdict).toBe("valid"); + }); + + it("uses parser finality errors as invalid without compatibility final hints", () => { + const below = reconcileCausalBundle(lines(event("moltnet:a", 2), final("s", 1))); + expect(below.verdict).toBe("invalid"); + expect(below.diagnostics[0]?.message).toContain("below observed sequence"); + const duplicate = reconcileCausalBundle(lines(final(), final())); + expect(duplicate.verdict).toBe("invalid"); + }); + + it("keeps graph divergence and unknown authority distinct from parser validity", () => { + const cycle = reconcileCausalBundle(lines( + event("moltnet:a", 1, ["moltnet:b"], "a"), event("moltnet:b", 1, ["moltnet:a"], "b"), final("a"), final("b") + )); + expect(cycle.verdict).toBe("invalid"); + expect(cycle.graph.byEventId.get("moltnet:a")?.reasonCodes).toContain("cycle"); + const unknown = reconcileCausalBundle(lines(event("moltnet:a", 1, ["mneme:missing"]), final())); + expect(unknown.verdict).toBe("incomplete"); + expect(unknown.graph.byEventId.get("moltnet:a")?.state).toBe("unknown"); + }); + + it("rejects duplicate declarations but exposes each recognized declaration in isolation", () => { + const result = reconcileCausalBundle(lines(final("empty", 0), domain("content/exact-bytes"), domain("content/exact-bytes"))); + expect(result.verdict).toBe("invalid"); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "duplicate-digest-domain" })); + const all = reconcileCausalBundle(lines(final("empty", 0), ...SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)); + expect(all.verdict).toBe("valid"); + expect(all.digestDomains.map(({ label }) => label)).toEqual(SUPPORTED_CAUSAL_DIGEST_DECLARATIONS.map(({ label }) => label)); + }); + + it("is deterministic and does not retain result mutation", () => { + const records = [event("moltnet:a", 1, [], "a"), event("moltnet:b", 1, [], "b"), final("a"), final("b")]; + const left = reconcileCausalBundle(lines(...records)); + const right = reconcileCausalBundle(lines(...records.slice().reverse())); + expect(left.streams).toEqual(right.streams); + left.streams[0]!.missing.push({ from: 99, to: 99 }); + left.streamFinals[0]!.emitter.stream_id = "changed"; + expect(reconcileCausalBundle(lines(...records)).streams).toEqual(right.streams); + const source = new TextEncoder().encode(lines(...records)); + const fromBytes = reconcileCausalBundle(source); + source.fill(0); + expect(fromBytes.verdict).toBe("valid"); + expect(fromBytes.graph.byEventId.get("moltnet:a")?.event.payload).toEqual({ id: "moltnet:a" }); + expect(reconcileCausalBundle("").verdict).toBe("valid"); + }); +}); + +describe("compareCausalDigest", () => { + it("compares accepted indexed canonical events and exact original UTF-8 or bytes", () => { + const bundled = reconcileCausalBundle(lines(event("moltnet:a"), final())); + const accepted = bundled.graph.byEventId.get("moltnet:a")!.event; + const expectedEvent = sha(new TextEncoder().encode(canonicalJsonStringify(accepted))); + expect(compareCausalDigest(domain("causal-event/canonical-json"), accepted, expectedEvent)).toMatchObject({ matches: true }); + expect(compareCausalDigest(domain("causal-event/canonical-json"), { ...accepted, payload: { id: "altered" } }, expectedEvent)).toEqual({ matches: false, reason: "digest-mismatch" }); + const text = "café\u0000"; + expect(compareCausalDigest(domain("content/exact-utf8"), text, sha(text))).toMatchObject({ matches: true }); + const bytes = Uint8Array.from([0, 0xff, 0x61]); const copied = bytes.slice(); + expect(compareCausalDigest(domain("content/exact-bytes"), bytes, sha(bytes))).toMatchObject({ matches: true }); + copied[1] = 0; + expect(compareCausalDigest(domain("content/exact-bytes"), copied, sha(bytes))).toEqual({ matches: false, reason: "digest-mismatch" }); + }); + + it("fails closed for invalid labels, subject types, and expected hashes", () => { + expect(compareCausalDigest({ ...domain("content/exact-bytes"), label: "bad" }, new Uint8Array(), "a".repeat(64))).toEqual({ matches: false, reason: "invalid-domain" }); + expect(compareCausalDigest(domain("content/exact-bytes"), "text", "a".repeat(64))).toEqual({ matches: false, reason: "invalid-subject" }); + expect(compareCausalDigest(domain("content/exact-utf8"), "text", "A".repeat(64))).toEqual({ matches: false, reason: "invalid-expected-hash" }); + }); +}); diff --git a/src/bundleReconcile.ts b/src/bundleReconcile.ts new file mode 100644 index 0000000..69ebca0 --- /dev/null +++ b/src/bundleReconcile.ts @@ -0,0 +1,96 @@ +import { + parseCausalBundle, + parseCausalBundleBytes, + type BundleParseError, + type CausalDigestDomain, + type CausalStreamFinal +} from "./contracts/index.js"; +import { reconcileGraph } from "./reconcileGraph.js"; +import type { ReconcileResult } from "./reconcile.js"; +import { checkSeqContiguity, compareUtf16, streamKey, type SeqRange } from "./seq.js"; + +export type CausalBundleVerdict = "invalid" | "incomplete" | "valid"; +export type CausalBundleStreamStatus = "complete" | "partial" | "stale"; + +export interface CausalBundleDiagnostic { + code: "duplicate-digest-domain" | "parser-error"; + line?: number; + message: string; +} + +export interface CausalBundleStreamDiagnostic { + declaredFinalSeq?: number; + missing: SeqRange[]; + observedMaxSeq: number; + runId: string; + status: CausalBundleStreamStatus; + streamId: string; + system: string; +} + +export interface CausalBundleReconciliation { + diagnostics: CausalBundleDiagnostic[]; + digestDomains: CausalDigestDomain[]; + graph: ReconcileResult; + streamFinals: CausalStreamFinal[]; + streams: CausalBundleStreamDiagnostic[]; + verdict: CausalBundleVerdict; +} + +const compareStream = (left: CausalBundleStreamDiagnostic, right: CausalBundleStreamDiagnostic): number => + compareUtf16(left.runId, right.runId) || compareUtf16(left.system, right.system) || compareUtf16(left.streamId, right.streamId); + +const copyRanges = (ranges: SeqRange[]): SeqRange[] => ranges.map(({ from, to }) => ({ from, to })); +const copyFinal = (value: CausalStreamFinal): CausalStreamFinal => ({ + emitter: { ...value.emitter }, final_seq: value.final_seq, run_id: value.run_id, version: value.version +}); +const copyDomain = (value: CausalDigestDomain): CausalDigestDomain => ({ ...value }); +const parserDiagnostics = (errors: BundleParseError[]): CausalBundleDiagnostic[] => + errors.map(({ line, message }) => ({ code: "parser-error", line, message })); + +/** Reconciles a raw mixed causal bundle without assigning any product policy. */ +export const reconcileCausalBundle = (input: string | Uint8Array): CausalBundleReconciliation => { + const parsed = typeof input === "string" ? parseCausalBundle(input) : parseCausalBundleBytes(input); + const diagnostics = parserDiagnostics(parsed.errors); + const finalByKey = new Map(); + for (const final of parsed.streamFinals) { + const key = streamKey(final.run_id, final.emitter.system, final.emitter.stream_id); + if (!finalByKey.has(key)) finalByKey.set(key, final); + } + const domainsByLabel = new Map(); + for (const domain of parsed.digestDomains) domainsByLabel.set(domain.label, (domainsByLabel.get(domain.label) ?? 0) + 1); + for (const [label, count] of [...domainsByLabel].sort(([left], [right]) => compareUtf16(left, right))) { + if (count > 1) diagnostics.push({ code: "duplicate-digest-domain", message: `duplicate digest-domain declaration for ${label}` }); + } + + const contiguity = checkSeqContiguity(parsed.events); + const gapsByKey = new Map(contiguity.gaps.map((gap) => [streamKey(gap.runId, gap.system, gap.streamId), gap])); + const streamKeys = new Set([...contiguity.maxSeqByStream.keys(), ...finalByKey.keys()]); + const streams: CausalBundleStreamDiagnostic[] = []; + for (const key of streamKeys) { + const final = finalByKey.get(key); + const gap = gapsByKey.get(key); + const tuple = JSON.parse(key) as [string, string, string]; + const observedMaxSeq = contiguity.maxSeqByStream.get(key) ?? 0; + const status: CausalBundleStreamStatus = !final ? "partial" : + final.final_seq > observedMaxSeq || final.final_seq < observedMaxSeq || Boolean(gap) ? "stale" : "complete"; + streams.push({ + ...(final ? { declaredFinalSeq: final.final_seq } : {}), + missing: copyRanges(gap?.missing ?? []), observedMaxSeq, runId: tuple[0], status, streamId: tuple[2], system: tuple[1] + }); + } + streams.sort(compareStream); + + const graph = reconcileGraph(parsed.events, { finals: new Map(), invalid: false }, { + finals: new Map([...finalByKey].map(([key, value]) => [key, value.final_seq])), requireFinals: true + }); + const hasDivergence = [...graph.byEventId.values()].some((record) => record.state === "divergent"); + const hasIncompleteGraph = [...graph.byEventId.values()].some((record) => record.state !== "complete"); + const hasIncompleteStream = streams.some((stream) => stream.status !== "complete"); + const verdict: CausalBundleVerdict = diagnostics.length || hasDivergence ? "invalid" : + hasIncompleteGraph || hasIncompleteStream ? "incomplete" : "valid"; + return { + diagnostics, digestDomains: parsed.digestDomains.map(copyDomain), graph, + streamFinals: parsed.streamFinals.map(copyFinal), streams, verdict + }; +}; diff --git a/src/contracts/digestDomain.ts b/src/contracts/digestDomain.ts index 1c36310..2f6eab7 100644 --- a/src/contracts/digestDomain.ts +++ b/src/contracts/digestDomain.ts @@ -203,3 +203,26 @@ export const subjectHashBytesFromDigestDomain = (domain: CausalDigestDomain, sub export const subjectHashFromDigestDomain = (domain: CausalDigestDomain, subject: unknown): string => { return hashSha256(subjectBytesForDigestDomain(domain, subject)); }; + +export type CausalDigestComparison = + | { digest: string; matches: true } + | { matches: false; reason: "digest-mismatch" | "invalid-domain" | "invalid-expected-hash" | "invalid-subject" }; + +/** Pure, fail-closed comparison in one recognized digest domain. */ +export const compareCausalDigest = (domain: unknown, subject: unknown, expectedSha256: unknown): CausalDigestComparison => { + if (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256)) { + return { matches: false, reason: "invalid-expected-hash" }; + } + let parsed: CausalDigestDomain; + try { + parsed = parseCausalDigestDomain(domain); + } catch { + return { matches: false, reason: "invalid-domain" }; + } + try { + const digest = subjectHashFromDigestDomain(parsed, subject); + return digest === expectedSha256 ? { digest, matches: true } : { matches: false, reason: "digest-mismatch" }; + } catch { + return { matches: false, reason: "invalid-subject" }; + } +}; diff --git a/src/index.ts b/src/index.ts index a6bab1c..e78c7e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from "./contracts/index.js"; export * from "./envelope.js"; export * from "./reconcile.js"; +export * from "./bundleReconcile.js"; export * from "./seq.js"; diff --git a/src/reconcileGraph.ts b/src/reconcileGraph.ts index d34a81e..34a7682 100644 --- a/src/reconcileGraph.ts +++ b/src/reconcileGraph.ts @@ -3,6 +3,11 @@ import type { CheckedFinalHints, ReconcileResult, ReconciledRecord, Reconciliati import { checkSeqContiguity, compareUtf16, streamKey, streamSlotKey } from "./seq.js"; type Occurrence = { canonical: string; event: CausalEvent; hash: string }; +/** Internal sealed-bundle finality input. Compatibility hints never set this. */ +export interface AuthoritativeFinality { + finals: Map; + requireFinals: true; +} const rank: Record = { complete: 0, stale: 1, partial: 2, unknown: 3, divergent: 4 }; const copy = (event: CausalEvent): CausalEvent => JSON.parse(canonicalJsonStringify(event)) as CausalEvent; const compareOccurrence = (left: Occurrence, right: Occurrence): number => @@ -13,24 +18,27 @@ const reasonText: Record = { "self-cause": "event references itself as a cause", cycle: "event is a member of a causal cycle", "cross-run-cause": "cause is present only in another run", "missing-cause-unknown": "cause references a system with no events in this run", "missing-cause-partial": "cause event is not ingested in this run", "stream-behind-declared-final": "stream is behind its declared final seq", "cause-behind-declared-final": "cause stream is behind its declared final seq", + "missing-authoritative-stream-final": "observed stream has no authoritative final", "stream-authoritative-final-stale": "authoritative stream final exceeds observed sequence or the stream has a gap", + "cause-authoritative-final-stale": "missing cause may be beyond an authoritative stream final", "invalid-declared-final-hints": "declared final hints have an invalid compatibility shape", "ancestor-divergent": "an ancestor is divergent", "ancestor-unknown": "an ancestor is unknown", "ancestor-partial": "an ancestor is partial", "ancestor-stale": "an ancestor is stale" }; const reasonRank: Record = { "invalid-declared-final-hints": 0, "conflicting-event-id": 1, "conflicting-stream-slot": 2, "conflicting-cross-store-fact": 3, "cross-run-cause": 4, "cross-run-event-id": 5, "self-cause": 6, cycle: 7, - "missing-cause-unknown": 10, "missing-cause-partial": 20, "cause-behind-declared-final": 30, "stream-behind-declared-final": 31, + "missing-cause-unknown": 10, "missing-cause-partial": 20, "missing-authoritative-stream-final": 21, "cause-behind-declared-final": 30, "stream-behind-declared-final": 31, + "cause-authoritative-final-stale": 32, "stream-authoritative-final-stale": 33, "ancestor-divergent": 40, "ancestor-unknown": 41, "ancestor-partial": 42, "ancestor-stale": 43 }; const maxState = (left: ReconciliationState, right: ReconciliationState) => rank[left] >= rank[right] ? left : right; const localStateFor = (codes: Set): ReconciliationState => codes.has("invalid-declared-final-hints") || ["conflicting-event-id", "cross-run-event-id", "conflicting-stream-slot", "conflicting-cross-store-fact", "self-cause", "cycle", "cross-run-cause"].some((code) => codes.has(code)) ? "divergent" : - codes.has("missing-cause-unknown") ? "unknown" : codes.has("missing-cause-partial") ? "partial" : codes.size ? "stale" : "complete"; + codes.has("missing-cause-unknown") ? "unknown" : codes.has("missing-cause-partial") || codes.has("missing-authoritative-stream-final") ? "partial" : codes.size ? "stale" : "complete"; /** * Graph reconciliation uses one canonical O(V log V) occurrence/output pass; * SCC discovery and propagation are iterative O(V + E), with no traversal sort. */ -export const reconcileGraph = (events: CausalEvent[], hints: CheckedFinalHints): ReconcileResult => { +export const reconcileGraph = (events: CausalEvent[], hints: CheckedFinalHints, authoritative?: AuthoritativeFinality): ReconcileResult => { const occurrences = events.map((raw) => ({ canonical: canonicalJsonStringify(raw), event: copy(raw), hash: hashCausalEvent(raw) })); // exactly once per occurrence occurrences.sort(compareOccurrence); const byId = new Map(); const slots = new Map>(); const systemsByRun = new Map>(); @@ -51,19 +59,21 @@ export const reconcileGraph = (events: CausalEvent[], hints: CheckedFinalHints): const contiguity = checkSeqContiguity(occurrences.map(({ event }) => event)); const gapped = new Set(contiguity.gaps.map((gap) => streamKey(gap.runId, gap.system, gap.streamId))); const staleSystemsByRun = new Map>(); - for (const [key, finalSeq] of hints.finals) { + const finals = authoritative?.finals ?? hints.finals; + for (const [key, finalSeq] of finals) { const tuple = JSON.parse(key) as [string, string, string]; const max = contiguity.maxSeqByStream.get(key) ?? 0; if (finalSeq > max || (finalSeq === max && gapped.has(key))) (staleSystemsByRun.get(tuple[0]) ?? staleSystemsByRun.set(tuple[0], new Set()).get(tuple[0])!).add(tuple[1]); } for (const [id, { event }] of reps) { - const key = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); const finalSeq = hints.finals.get(key); const max = contiguity.maxSeqByStream.get(key) ?? 0; - if (finalSeq !== undefined && (finalSeq > max || (finalSeq === max && gapped.has(key)))) localCodes.get(id)!.add("stream-behind-declared-final"); + const key = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); const finalSeq = finals.get(key); const max = contiguity.maxSeqByStream.get(key) ?? 0; + if (authoritative?.requireFinals && finalSeq === undefined) localCodes.get(id)!.add("missing-authoritative-stream-final"); + else if (finalSeq !== undefined && (finalSeq > max || (finalSeq === max && gapped.has(key)))) localCodes.get(id)!.add(authoritative ? "stream-authoritative-final-stale" : "stream-behind-declared-final"); } const moltnet = new Map>(); for (const { event } of reps.values()) if (event.emitter.system === "moltnet" && event.type === "message.accepted") { const { message_id, content_sha256 } = event.payload; if (typeof message_id === "string" && typeof content_sha256 === "string") (moltnet.get(JSON.stringify([event.run_id, message_id])) ?? moltnet.set(JSON.stringify([event.run_id, message_id]), new Set()).get(JSON.stringify([event.run_id, message_id]))!).add(content_sha256); } for (const [id, { event }] of reps) if (event.emitter.system === "daimon" && event.type === "turn.input.submitted") { const { input_message_ids, input_content_sha256 } = event.payload; if (Array.isArray(input_message_ids) && typeof input_content_sha256 === "string" && input_message_ids.some((messageId) => typeof messageId === "string" && [...(moltnet.get(JSON.stringify([event.run_id, messageId])) ?? [])].some((value) => value !== input_content_sha256))) localCodes.get(id)!.add("conflicting-cross-store-fact"); } const causes = new Map(); const reverse = new Map(); for (const id of reps.keys()) reverse.set(id, []); - for (const [id, { event }] of reps) { const resolved: string[] = []; for (const cause of event.cause_event_ids) { const target = reps.get(cause); if (!target) { const system = cause.split(":", 1)[0] ?? ""; if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add("cause-behind-declared-final"); else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); else localCodes.get(id)!.add("missing-cause-unknown"); } else if (cause === id) localCodes.get(id)!.add("self-cause"); else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); else { resolved.push(cause); reverse.get(cause)!.push(id); } } causes.set(id, resolved); } + for (const [id, { event }] of reps) { const resolved: string[] = []; for (const cause of event.cause_event_ids) { const target = reps.get(cause); if (!target) { const system = cause.split(":", 1)[0] ?? ""; if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add(authoritative ? "cause-authoritative-final-stale" : "cause-behind-declared-final"); else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); else localCodes.get(id)!.add("missing-cause-unknown"); } else if (cause === id) localCodes.get(id)!.add("self-cause"); else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); else { resolved.push(cause); reverse.get(cause)!.push(id); } } causes.set(id, resolved); } const order: string[] = []; const seen = new Set(); for (const start of reps.keys()) if (!seen.has(start)) { const stack: Array<[string, number]> = [[start, 0]]; seen.add(start); while (stack.length) { const top = stack.at(-1)!; const next = causes.get(top[0])![top[1]]; if (next !== undefined) { top[1] += 1; if (!seen.has(next)) { seen.add(next); stack.push([next, 0]); } } else { order.push(top[0]); stack.pop(); } } } const assigned = new Set(); From 38416538a065de72f7b9b4ff7c949783ac260cd0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 18:55:32 +0200 Subject: [PATCH 08/11] fix(reconcile): stabilize bundle finality outputs --- src/bundleReconcile.test.ts | 51 ++++++++++++++++++++++++++++++++++--- src/bundleReconcile.ts | 20 +++++++++++++-- src/reconcileGraph.ts | 13 ++++++---- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/bundleReconcile.test.ts b/src/bundleReconcile.test.ts index 97a1849..62a2431 100644 --- a/src/bundleReconcile.test.ts +++ b/src/bundleReconcile.test.ts @@ -73,17 +73,25 @@ describe("reconcileCausalBundle", () => { expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "duplicate-digest-domain" })); const all = reconcileCausalBundle(lines(final("empty", 0), ...SUPPORTED_CAUSAL_DIGEST_DECLARATIONS)); expect(all.verdict).toBe("valid"); - expect(all.digestDomains.map(({ label }) => label)).toEqual(SUPPORTED_CAUSAL_DIGEST_DECLARATIONS.map(({ label }) => label)); + expect(all.digestDomains.map(({ label }) => label)).toEqual(["causal-event/canonical-json", "content/exact-bytes", "content/exact-utf8"]); }); - it("is deterministic and does not retain result mutation", () => { - const records = [event("moltnet:a", 1, [], "a"), event("moltnet:b", 1, [], "b"), final("a"), final("b")]; + it("canonically orders every valid result array and does not retain result mutation", () => { + const records = [ + event("moltnet:a", 1, [], "z"), final("z"), domain("content/exact-utf8"), + event("moltnet:b", 1, [], "a"), final("a"), domain("content/exact-bytes") + ]; const left = reconcileCausalBundle(lines(...records)); const right = reconcileCausalBundle(lines(...records.slice().reverse())); expect(left.streams).toEqual(right.streams); + expect(left.streamFinals).toEqual(right.streamFinals); + expect(left.digestDomains).toEqual(right.digestDomains); + expect(left.streamFinals.map(({ emitter }) => emitter.stream_id)).toEqual(["a", "z"]); + expect(left.digestDomains.map(({ label }) => label)).toEqual(["content/exact-bytes", "content/exact-utf8"]); left.streams[0]!.missing.push({ from: 99, to: 99 }); left.streamFinals[0]!.emitter.stream_id = "changed"; - expect(reconcileCausalBundle(lines(...records)).streams).toEqual(right.streams); + left.digestDomains[0]!.label = "content/exact-utf8"; + expect(reconcileCausalBundle(lines(...records))).toMatchObject({ digestDomains: right.digestDomains, streamFinals: right.streamFinals, streams: right.streams }); const source = new TextEncoder().encode(lines(...records)); const fromBytes = reconcileCausalBundle(source); source.fill(0); @@ -91,6 +99,41 @@ describe("reconcileCausalBundle", () => { expect(fromBytes.graph.byEventId.get("moltnet:a")?.event.payload).toEqual({ id: "moltnet:a" }); expect(reconcileCausalBundle("").verdict).toBe("valid"); }); + + it("keeps missing causes partial when authoritative finality knows only their system", () => { + const child = { ...event("daimon:child", 1, ["moltnet:absent"], "child"), emitter: { seq: 1, stream_id: "child", system: "daimon" as const } }; + const unrelatedStale = reconcileCausalBundle(lines( + event("moltnet:present", 1, [], "unrelated"), + child, + final("unrelated", 2), + { ...final("empty", 1), emitter: { stream_id: "empty", system: "mneme" } }, + { ...final("absent", 0), emitter: { stream_id: "absent", system: "simfile" } }, + { ...final("child", 1), emitter: { stream_id: "child", system: "daimon" } } + )); + expect(unrelatedStale.graph.byEventId.get("daimon:child")).toMatchObject({ localState: "partial", reasonCodes: ["missing-cause-partial"], state: "partial" }); + expect(unrelatedStale.graph.byEventId.get("moltnet:present")?.localState).toBe("stale"); + const descendant = { ...event("daimon:descendant", 1, ["moltnet:present"], "descendant"), emitter: { seq: 1, stream_id: "descendant", system: "daimon" as const } }; + const propagated = reconcileCausalBundle(lines(event("moltnet:present", 1, [], "own"), descendant, final("own", 2), { ...final("descendant"), emitter: { stream_id: "descendant", system: "daimon" } })); + expect(propagated.graph.byEventId.get("moltnet:present")?.localState).toBe("stale"); + expect(propagated.graph.byEventId.get("daimon:descendant")?.reasonCodes).toEqual(["ancestor-stale"]); + }); + + it("classifies empty and positive authoritative systems as partial, and absent systems as unknown", () => { + const emptyChild = { ...event("daimon:empty-child", 1, ["mneme:missing"], "empty-child"), emitter: { seq: 1, stream_id: "empty-child", system: "daimon" as const } }; + const positiveChild = { ...event("daimon:positive-child", 1, ["simfile:missing"], "positive-child"), emitter: { seq: 1, stream_id: "positive-child", system: "daimon" as const } }; + const unknownChild = { ...event("daimon:unknown-child", 1, ["moltnet:missing"], "unknown-child"), emitter: { seq: 1, stream_id: "unknown-child", system: "daimon" as const } }; + const result = reconcileCausalBundle(lines( + emptyChild, positiveChild, unknownChild, + { ...final("empty", 0), emitter: { stream_id: "empty", system: "mneme" } }, + { ...final("positive", 1), emitter: { stream_id: "positive", system: "simfile" } }, + { ...final("empty-child"), emitter: { stream_id: "empty-child", system: "daimon" } }, + { ...final("positive-child"), emitter: { stream_id: "positive-child", system: "daimon" } }, + { ...final("unknown-child"), emitter: { stream_id: "unknown-child", system: "daimon" } } + )); + expect(result.graph.byEventId.get("daimon:empty-child")).toMatchObject({ localState: "partial", reasonCodes: ["missing-cause-partial"] }); + expect(result.graph.byEventId.get("daimon:positive-child")).toMatchObject({ localState: "partial", reasonCodes: ["missing-cause-partial"] }); + expect(result.graph.byEventId.get("daimon:unknown-child")).toMatchObject({ localState: "unknown", reasonCodes: ["missing-cause-unknown"] }); + }); }); describe("compareCausalDigest", () => { diff --git a/src/bundleReconcile.ts b/src/bundleReconcile.ts index 69ebca0..62bc2a0 100644 --- a/src/bundleReconcile.ts +++ b/src/bundleReconcile.ts @@ -1,6 +1,7 @@ import { parseCausalBundle, parseCausalBundleBytes, + getCanonicalDigestDeclarationBytes, type BundleParseError, type CausalDigestDomain, type CausalStreamFinal @@ -40,6 +41,21 @@ export interface CausalBundleReconciliation { const compareStream = (left: CausalBundleStreamDiagnostic, right: CausalBundleStreamDiagnostic): number => compareUtf16(left.runId, right.runId) || compareUtf16(left.system, right.system) || compareUtf16(left.streamId, right.streamId); +const compareFinal = (left: CausalStreamFinal, right: CausalStreamFinal): number => + compareUtf16(left.run_id, right.run_id) || compareUtf16(left.emitter.system, right.emitter.system) || + compareUtf16(left.emitter.stream_id, right.emitter.stream_id) || (left.final_seq < right.final_seq ? -1 : left.final_seq > right.final_seq ? 1 : 0); + +const compareBytes = (left: Uint8Array, right: Uint8Array): number => { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + if (left[index] !== right[index]) return left[index]! < right[index]! ? -1 : 1; + } + return left.length < right.length ? -1 : left.length > right.length ? 1 : 0; +}; + +const compareDomain = (left: CausalDigestDomain, right: CausalDigestDomain): number => + compareBytes(getCanonicalDigestDeclarationBytes(left), getCanonicalDigestDeclarationBytes(right)); + const copyRanges = (ranges: SeqRange[]): SeqRange[] => ranges.map(({ from, to }) => ({ from, to })); const copyFinal = (value: CausalStreamFinal): CausalStreamFinal => ({ emitter: { ...value.emitter }, final_seq: value.final_seq, run_id: value.run_id, version: value.version @@ -90,7 +106,7 @@ export const reconcileCausalBundle = (input: string | Uint8Array): CausalBundleR const verdict: CausalBundleVerdict = diagnostics.length || hasDivergence ? "invalid" : hasIncompleteGraph || hasIncompleteStream ? "incomplete" : "valid"; return { - diagnostics, digestDomains: parsed.digestDomains.map(copyDomain), graph, - streamFinals: parsed.streamFinals.map(copyFinal), streams, verdict + diagnostics, digestDomains: parsed.digestDomains.map(copyDomain).sort(compareDomain), graph, + streamFinals: parsed.streamFinals.map(copyFinal).sort(compareFinal), streams, verdict }; }; diff --git a/src/reconcileGraph.ts b/src/reconcileGraph.ts index 34a7682..1ae1ed9 100644 --- a/src/reconcileGraph.ts +++ b/src/reconcileGraph.ts @@ -19,14 +19,13 @@ const reasonText: Record = { "missing-cause-unknown": "cause references a system with no events in this run", "missing-cause-partial": "cause event is not ingested in this run", "stream-behind-declared-final": "stream is behind its declared final seq", "cause-behind-declared-final": "cause stream is behind its declared final seq", "missing-authoritative-stream-final": "observed stream has no authoritative final", "stream-authoritative-final-stale": "authoritative stream final exceeds observed sequence or the stream has a gap", - "cause-authoritative-final-stale": "missing cause may be beyond an authoritative stream final", "invalid-declared-final-hints": "declared final hints have an invalid compatibility shape", "ancestor-divergent": "an ancestor is divergent", "ancestor-unknown": "an ancestor is unknown", "ancestor-partial": "an ancestor is partial", "ancestor-stale": "an ancestor is stale" }; const reasonRank: Record = { "invalid-declared-final-hints": 0, "conflicting-event-id": 1, "conflicting-stream-slot": 2, "conflicting-cross-store-fact": 3, "cross-run-cause": 4, "cross-run-event-id": 5, "self-cause": 6, cycle: 7, "missing-cause-unknown": 10, "missing-cause-partial": 20, "missing-authoritative-stream-final": 21, "cause-behind-declared-final": 30, "stream-behind-declared-final": 31, - "cause-authoritative-final-stale": 32, "stream-authoritative-final-stale": 33, + "stream-authoritative-final-stale": 33, "ancestor-divergent": 40, "ancestor-unknown": 41, "ancestor-partial": 42, "ancestor-stale": 43 }; const maxState = (left: ReconciliationState, right: ReconciliationState) => rank[left] >= rank[right] ? left : right; @@ -58,12 +57,16 @@ export const reconcileGraph = (events: CausalEvent[], hints: CheckedFinalHints, for (const ids of slots.values()) if (ids.size > 1) for (const id of ids) localCodes.get(id)!.add("conflicting-stream-slot"); const contiguity = checkSeqContiguity(occurrences.map(({ event }) => event)); const gapped = new Set(contiguity.gaps.map((gap) => streamKey(gap.runId, gap.system, gap.streamId))); - const staleSystemsByRun = new Map>(); const finals = authoritative?.finals ?? hints.finals; - for (const [key, finalSeq] of finals) { + const staleSystemsByRun = new Map>(); + if (!authoritative) for (const [key, finalSeq] of finals) { const tuple = JSON.parse(key) as [string, string, string]; const max = contiguity.maxSeqByStream.get(key) ?? 0; if (finalSeq > max || (finalSeq === max && gapped.has(key))) (staleSystemsByRun.get(tuple[0]) ?? staleSystemsByRun.set(tuple[0], new Set()).get(tuple[0])!).add(tuple[1]); } + if (authoritative) for (const key of finals.keys()) { + const [runId, system] = JSON.parse(key) as [string, CausalEventSystem, string]; + (systemsByRun.get(runId) ?? systemsByRun.set(runId, new Set()).get(runId)!).add(system); + } for (const [id, { event }] of reps) { const key = streamKey(event.run_id, event.emitter.system, event.emitter.stream_id); const finalSeq = finals.get(key); const max = contiguity.maxSeqByStream.get(key) ?? 0; if (authoritative?.requireFinals && finalSeq === undefined) localCodes.get(id)!.add("missing-authoritative-stream-final"); @@ -73,7 +76,7 @@ export const reconcileGraph = (events: CausalEvent[], hints: CheckedFinalHints, for (const { event } of reps.values()) if (event.emitter.system === "moltnet" && event.type === "message.accepted") { const { message_id, content_sha256 } = event.payload; if (typeof message_id === "string" && typeof content_sha256 === "string") (moltnet.get(JSON.stringify([event.run_id, message_id])) ?? moltnet.set(JSON.stringify([event.run_id, message_id]), new Set()).get(JSON.stringify([event.run_id, message_id]))!).add(content_sha256); } for (const [id, { event }] of reps) if (event.emitter.system === "daimon" && event.type === "turn.input.submitted") { const { input_message_ids, input_content_sha256 } = event.payload; if (Array.isArray(input_message_ids) && typeof input_content_sha256 === "string" && input_message_ids.some((messageId) => typeof messageId === "string" && [...(moltnet.get(JSON.stringify([event.run_id, messageId])) ?? [])].some((value) => value !== input_content_sha256))) localCodes.get(id)!.add("conflicting-cross-store-fact"); } const causes = new Map(); const reverse = new Map(); for (const id of reps.keys()) reverse.set(id, []); - for (const [id, { event }] of reps) { const resolved: string[] = []; for (const cause of event.cause_event_ids) { const target = reps.get(cause); if (!target) { const system = cause.split(":", 1)[0] ?? ""; if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add(authoritative ? "cause-authoritative-final-stale" : "cause-behind-declared-final"); else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); else localCodes.get(id)!.add("missing-cause-unknown"); } else if (cause === id) localCodes.get(id)!.add("self-cause"); else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); else { resolved.push(cause); reverse.get(cause)!.push(id); } } causes.set(id, resolved); } + for (const [id, { event }] of reps) { const resolved: string[] = []; for (const cause of event.cause_event_ids) { const target = reps.get(cause); if (!target) { const system = cause.split(":", 1)[0] ?? ""; if (staleSystemsByRun.get(event.run_id)?.has(system)) localCodes.get(id)!.add("cause-behind-declared-final"); else if (systemsByRun.get(event.run_id)?.has(system as CausalEventSystem)) localCodes.get(id)!.add("missing-cause-partial"); else localCodes.get(id)!.add("missing-cause-unknown"); } else if (cause === id) localCodes.get(id)!.add("self-cause"); else if (byId.get(cause)!.some(({ event: occurrence }) => occurrence.run_id !== event.run_id) || target.event.run_id !== event.run_id) localCodes.get(id)!.add("cross-run-cause"); else { resolved.push(cause); reverse.get(cause)!.push(id); } } causes.set(id, resolved); } const order: string[] = []; const seen = new Set(); for (const start of reps.keys()) if (!seen.has(start)) { const stack: Array<[string, number]> = [[start, 0]]; seen.add(start); while (stack.length) { const top = stack.at(-1)!; const next = causes.get(top[0])![top[1]]; if (next !== undefined) { top[1] += 1; if (!seen.has(next)) { seen.add(next); stack.push([next, 0]); } } else { order.push(top[0]); stack.pop(); } } } const assigned = new Set(); From f62c7803f4d5e12e52c4ec676436a7a6d8f16241 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 20 Jul 2026 19:07:50 +0200 Subject: [PATCH 09/11] docs: correct reconciliation API example --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7c3b360..59b57a8 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ npm install @noopolis/stele `parseCausalEvent` / `validateCausalEvent`, canonical JSON (`canonicalJsonStringify`), stable hashing (`hashCausalEvent`), JSONL parsing (`parseCausalJsonl`), the principal grammar, and `CAUSAL_EVENT_VERSION`. -- **Reconcile** — `reconcileEvents` groups events into complete vs incomplete - causal chains and never invents a missing link; `traceCausesBackward` walks an - event's causes. Returns `ReconciledRecord[]`, `CausalEdge[]`, and a - `ReconciliationState`. +- **Reconcile** — `reconcileEvents` returns a `ReconcileResult` with + `byEventId` records and `occurrencesByEventId`; each `ReconciledRecord` carries + `localState`, `reasonCodes`, `reasons`, and transitive `state`. It never invents + a missing link; use `traceCausesBackward` to walk an event's causes. - **Seq** — `checkSeqContiguity` and `streamKey` detect gaps in a per-stream sequence, surfacing `SeqGap`s instead of silently stitching over them. - **Sealed bundles** — `reconcileCausalBundle(input)` parses mixed raw JSONL @@ -47,13 +47,15 @@ npm install @noopolis/stele ## Example ```ts -import { parseCausalJsonl, reconcileEvents } from "@noopolis/stele"; +import { parseCausalJsonl, reconcileEvents, traceCausesBackward } from "@noopolis/stele"; const { events, errors } = parseCausalJsonl(await readFile("ledger.jsonl", "utf8")); -const { records, edges, incomplete } = reconcileEvents(events); +const reconciliation = reconcileEvents(events); +const record = reconciliation.byEventId.get("moltnet:m2"); +const edges = traceCausesBackward(reconciliation, "moltnet:m2"); -// records: complete causal chains, in causal (not wall-clock) order. -// incomplete: chains with a missing cause — reported, never stitched. +// record?.state includes transitive cause state; record?.reasonCodes explain it. +// edges lists { from, to } cause links; missing causes are reported, never stitched. ``` ## Design From f12232c579340faf29253cdcf5358fdb53e98562 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 1 Aug 2026 15:54:10 +0200 Subject: [PATCH 10/11] feat(contracts): open the cause-id namespace at ingest and keep it closed at seal Be liberal in what you accept from others, strict in what you seal yourself. parseCausalCauseId becomes a real open-namespace parser and leaves the parse-fatal admission path; parseCausalBundle gains its own sealing check layered on the liberal schema. Event ids stay closed. The golden corpus flips reject-unrecognized-cause-id to accept-foreign-cause-namespace with the same bytes, and adds reject-bare-cause-id for the sealing side. --- src/AGENTS.md | 6 ++-- src/contracts/AGENTS.md | 5 +++ src/contracts/bundle.test.ts | 34 ++++++++++++++++++- src/contracts/bundle.ts | 16 +++++++++ src/contracts/envelope.test.ts | 23 +++++++++++-- src/contracts/envelope.ts | 7 ++-- src/contracts/golden.test.ts | 5 +-- src/contracts/goldens/causal-contract.v1.json | 21 ++++++++---- src/contracts/ids.test.ts | 19 ++++++++++- src/contracts/ids.ts | 29 +++++++++++++++- 10 files changed, 144 insertions(+), 21 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index d986289..348bb85 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -14,9 +14,9 @@ src/ ├── CLAUDE.md # Compatibility guide symlink to AGENTS.md ├── canonicalJson.ts # Canonical JSON parser/stringifier/helpers ├── canonicalJson.test.ts - ├── ids.ts # Recognized systems and causal-id helpers + ├── ids.ts # Closed event-id and open cause-namespace helpers ├── ids.test.ts - ├── envelope.ts # noopolis.causal-event.v1 validation and JSONL parser + ├── envelope.ts # Admission validation; cause namespace shape is deferred ├── envelope.test.ts ├── streamFinal.ts # noopolis.causal-stream-final.v1 validation ├── streamFinal.test.ts @@ -27,7 +27,7 @@ src/ ├── index.ts # Contracts barrel export ├── golden.test.ts # Golden corpus coverage/invariant tests └── goldens/ - └── causal-contract.v1.json # Sole approved corpus manifest + └── causal-contract.v1.json # Sole approved corpus; foreign cause namespaces are accepted ``` Keep this package read/verify-only. Emit-side logic and fixture/conformance diff --git a/src/contracts/AGENTS.md b/src/contracts/AGENTS.md index 649ec5c..ad7e33f 100644 --- a/src/contracts/AGENTS.md +++ b/src/contracts/AGENTS.md @@ -34,6 +34,11 @@ Rules for all files in this folder: - Use named exports only. - Keep behavior deterministic and transport-neutral. - Preserve strict schema and parsing rules from previous implementation steps. +- Keep event ids on the closed recognized-system admission grammar. For B169 + D4 cause ids, `parseCausalJsonl` is INGEST and admits bare ids so repair can + retain the carrying event, while `parseCausalBundle` is SEALING and rejects + them; foreign namespaces remain legal on both sides. Be liberal in what you + accept from others, strict in what you seal yourself. - Keep public names non-conflicting at the folder barrel level. `src/contracts/CLAUDE.md` is a required exact symlink to this file and must diff --git a/src/contracts/bundle.test.ts b/src/contracts/bundle.test.ts index 2f9166b..1cd06fb 100644 --- a/src/contracts/bundle.test.ts +++ b/src/contracts/bundle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { CAUSAL_EVENT_VERSION } from "./envelope.js"; +import { CAUSAL_EVENT_VERSION, parseCausalJsonl } from "./envelope.js"; import { CAUSAL_STREAM_FINAL_VERSION } from "./streamFinal.js"; import { parseCausalBundle, parseCausalBundleBytes } from "./bundle.js"; @@ -192,6 +192,38 @@ describe("parseCausalBundle", () => { expect(errors).toEqual([]); }); + it("admits a bare cause id at ingest but rejects it at seal", () => { + const jsonl = `${JSON.stringify({ ...eventA, cause_event_ids: ["fixture-turn-1"] })}\n`; + + // Be liberal in what you accept from others: INGEST preserves evidence + // so stitching can repair the producer-local cause. + const ingest = parseCausalJsonl(jsonl); + expect(ingest.errors).toEqual([]); + expect(ingest.events).toHaveLength(1); + expect(ingest.events[0]?.cause_event_ids).toEqual(["fixture-turn-1"]); + + // Be strict in what you seal yourself: SEALING rejects the producer defect. + const sealed = parseCausalBundle(jsonl); + expect(sealed.errors.length).toBeGreaterThan(0); + expect(sealed.errors.some((error) => error.message.includes("fixture-turn-1"))).toBe(true); + }); + + it("accepts a foreign cause namespace at ingest and seal", () => { + const jsonl = `${JSON.stringify({ ...eventA, cause_event_ids: ["driver:turn:7"] })}\n`; + + // Be liberal in what you accept from others and strict in what you seal + // yourself; foreign namespaces conform on both sides of that rule. + const ingest = parseCausalJsonl(jsonl); + expect(ingest.errors).toEqual([]); + expect(ingest.events).toHaveLength(1); + expect(ingest.events[0]?.cause_event_ids).toEqual(["driver:turn:7"]); + + const sealed = parseCausalBundle(jsonl); + expect(sealed.errors).toEqual([]); + expect(sealed.events).toHaveLength(1); + expect(sealed.events[0]?.cause_event_ids).toEqual(["driver:turn:7"]); + }); + it("rejects direct causes that resolve to another run", () => { const otherRun = { ...eventA, event_id: "simfile:p1", run_id: "run-2", emitter: { ...eventA.emitter, system: "simfile" } }; const causeFromAnotherRun = { ...eventA, event_id: "moltnet:e4", cause_event_ids: ["simfile:p1"] }; diff --git a/src/contracts/bundle.ts b/src/contracts/bundle.ts index b82512f..d76d450 100644 --- a/src/contracts/bundle.ts +++ b/src/contracts/bundle.ts @@ -1,5 +1,6 @@ import { parseCausalEvent, type CausalEvent } from "./envelope.js"; import { parseCausalDigestDomain, type CausalDigestDomain } from "./digestDomain.js"; +import { parseCausalCauseId } from "./ids.js"; import { CAUSAL_STREAM_FINAL_VERSION, parseCausalStreamFinal, type CausalStreamFinal } from "./streamFinal.js"; import { parseCanonicalJson } from "./canonicalJson.js"; @@ -151,6 +152,21 @@ export const parseCausalBundle = (jsonl: string): BundleParseResult => { return; } + // INGEST vs SEALING: be liberal in what you accept from others, + // strict in what you seal yourself. + for (const causeId of parsed.cause_event_ids) { + try { + parseCausalCauseId(causeId); + } catch (error) { + addError(result.errors, { + line: lineNumber, + message: `invalid cause id ${causeId} for event ${parsed.event_id}: ${ + error instanceof Error ? error.message : String(error) + }` + }); + } + } + if (eventById.has(parsed.event_id)) { addError(result.errors, { line: lineNumber, diff --git a/src/contracts/envelope.test.ts b/src/contracts/envelope.test.ts index e110d39..55bf02c 100644 --- a/src/contracts/envelope.test.ts +++ b/src/contracts/envelope.test.ts @@ -65,12 +65,22 @@ describe("causalEventSchema", () => { expect(result.success).toBe(false); }); - it("rejects malformed cause ids", () => { + it("admits a bare cause id (reconciliation content, not an admission field)", () => { const result = validateCausalEvent({ ...goldenRecord, cause_event_ids: ["missing-colon"] }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + expect(result.success && result.data.cause_event_ids).toEqual(["missing-colon"]); + }); + + it("admits a foreign cause namespace unchanged", () => { + const result = validateCausalEvent({ + ...goldenRecord, + cause_event_ids: ["driver:turn:7"] + }); + expect(result.success).toBe(true); + expect(result.success && result.data.cause_event_ids).toEqual(["driver:turn:7"]); }); it("rejects duplicate cause ids while preserving order", () => { @@ -226,6 +236,15 @@ describe("parseCausalJsonl", () => { expect(events).toHaveLength(1); }); + it("admits a bare cause id through the JSONL ingest gate", () => { + const { errors, events } = parseCausalJsonl( + `${JSON.stringify({ ...goldenRecord, cause_event_ids: ["fixture-turn-1"] })}\n` + ); + expect(errors).toEqual([]); + expect(events).toHaveLength(1); + expect(events[0]?.cause_event_ids).toEqual(["fixture-turn-1"]); + }); + it("rejects impossible recorded_at dates in JSONL parsing", () => { const { errors } = parseCausalJsonl( `{"version":"${CAUSAL_EVENT_VERSION}","run_id":"run","event_id":"moltnet:m4","emitter":{"system":"moltnet","stream_id":"network:room-1","seq":1},"type":"message.accepted","principal_id":"agent:agent-1","recorded_at":"2026-04-31T00:00:00Z","cause_event_ids":[],"payload":{}}\n` diff --git a/src/contracts/envelope.ts b/src/contracts/envelope.ts index 13dd97b..1acd6b6 100644 --- a/src/contracts/envelope.ts +++ b/src/contracts/envelope.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { assertUniqueCauseEventIds, - parseCausalCauseId, parseCausalEventId, type CausalEventSystem } from "./ids.js"; @@ -88,13 +87,13 @@ const causalEventEmitterSchema = z.object({ export const causalEventSchema = z .object({ + // Admission checks only non-empty strings and uniqueness. Namespace + // shape is B169 D4 RECONCILIATION content and is deliberately not + // checked on this parse-fatal path. cause_event_ids: z .array(z.string().min(1)) .superRefine((causes, context) => { try { - for (const cause of causes) { - parseCausalCauseId(cause); - } assertUniqueCauseEventIds(causes); } catch (error) { context.addIssue({ diff --git a/src/contracts/golden.test.ts b/src/contracts/golden.test.ts index 2401c33..74d7c46 100644 --- a/src/contracts/golden.test.ts +++ b/src/contracts/golden.test.ts @@ -91,7 +91,8 @@ const expectedAccepted = [ "accept-final-seq-zero", "accept-non-empty-final", "accept-multi-stream", - "accept-nested-numeric-key-order" + "accept-nested-numeric-key-order", + "accept-foreign-cause-namespace" ]; const expectedRejected = [ @@ -106,7 +107,7 @@ const expectedRejected = [ "reject-malformed-event-id", "reject-unrecognized-event-id-system", "reject-mismatched-event-id-prefix", - "reject-unrecognized-cause-id", + "reject-bare-cause-id", "reject-repeated-causes", "reject-duplicate-event-id", "reject-duplicate-stream-slot", diff --git a/src/contracts/goldens/causal-contract.v1.json b/src/contracts/goldens/causal-contract.v1.json index b1ed419..ee2da10 100644 --- a/src/contracts/goldens/causal-contract.v1.json +++ b/src/contracts/goldens/causal-contract.v1.json @@ -132,6 +132,20 @@ "canonical_hex": "7b2263617573655f6576656e745f696473223a5b2273696d66696c653a726f6f74225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d3a6f72646572222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a6f726465722d31222c227061796c6f6164223a7b223130223a2274656e222c2232223a2274776f222c2261223a7b223130223a2274656e222c2232223a2274776f222c227a223a227a6967227d2c22656d6f6a69223a22736d696c65e299a5227d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3130305a222c2272756e5f6964223a2272756e2d6e65737465642d6e756d657269632d6b657973222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", "sha256": "10105a0f835ee611c46312811485cd450a02fdbf947da3c8fa804613c9b11449" }, + { + "name": "accept-foreign-cause-namespace", + "accept": true, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj4\",\"event_id\":\"moltnet:rj5\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[\"x:rj6\",\"moltnet:rj6\"],\"payload\":{}}", + "canonical_hex": "7b2263617573655f6576656e745f696473223a5b22783a726a36222c226d6f6c746e65743a726a36225d2c22656d6974746572223a7b22736571223a312c2273747265616d5f6964223a2273747265616d3a626164222c2273797374656d223a226d6f6c746e6574227d2c226576656e745f6964223a226d6f6c746e65743a726a35222c227061796c6f6164223a7b7d2c227072696e636970616c5f6964223a226167656e743a6167656e742d31222c227265636f726465645f6174223a22323032362d30372d30395430303a30303a30302e3030305a222c2272756e5f6964223a2272756e2d726a34222c2274797065223a226d6573736167652e6163636570746564222c2276657273696f6e223a226e6f6f706f6c69732e63617573616c2d6576656e742e7631227d", + "sha256": "62da8b529d17f0dcb78d8a2207f57d5f6bf79b0f8cc9777a13b77bfaf9d1053a" + }, + { + "name": "reject-bare-cause-id", + "accept": false, + "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-bare-cause-seal\",\"event_id\":\"moltnet:bare-cause-seal-1\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bare-cause\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[\"fixture-turn-1\"],\"payload\":{}}", + "canonical_hex": null, + "sha256": null + }, { "name": "reject-lone-high-surrogate-runtime-key", "accept": false, @@ -223,13 +237,6 @@ "canonical_hex": null, "sha256": null }, - { - "name": "reject-unrecognized-cause-id", - "accept": false, - "raw_jsonl": "{\"version\":\"noopolis.causal-event.v1\",\"run_id\":\"run-rj4\",\"event_id\":\"moltnet:rj5\",\"emitter\":{\"system\":\"moltnet\",\"stream_id\":\"stream:bad\",\"seq\":1},\"type\":\"message.accepted\",\"principal_id\":\"agent:agent-1\",\"recorded_at\":\"2026-07-09T00:00:00.000Z\",\"cause_event_ids\":[\"x:rj6\",\"moltnet:rj6\"],\"payload\":{}}", - "canonical_hex": null, - "sha256": null - }, { "name": "reject-repeated-causes", "accept": false, diff --git a/src/contracts/ids.test.ts b/src/contracts/ids.test.ts index 1d6b3bc..0431046 100644 --- a/src/contracts/ids.test.ts +++ b/src/contracts/ids.test.ts @@ -24,7 +24,24 @@ describe("causal event and cause ids", () => { it("rejects unknown systems", () => { expect(() => parseCausalEventId("unknown:local")).toThrow(/unrecognized causal system/); - expect(() => parseCausalCauseId("foo")).toThrow(/must be :/); + }); + + describe("cause ids", () => { + it("accepts foreign namespaces and splits only at the first colon", () => { + expect(parseCausalCauseId("driver:turn:7")).toEqual({ namespace: "driver", local: "turn:7" }); + }); + + it("accepts a recognized namespace", () => { + expect(parseCausalCauseId("moltnet:message-1")).toEqual({ namespace: "moltnet", local: "message-1" }); + }); + + it("rejects malformed values", () => { + expect(() => parseCausalCauseId("foo")).toThrow(/must be :/); + expect(() => parseCausalCauseId(":local")).toThrow(/must be :/); + expect(() => parseCausalCauseId("ns:")).toThrow(/local suffix/); + expect(() => parseCausalCauseId(42)).toThrow(/non-empty string/); + expect(() => parseCausalCauseId("")).toThrow(/non-empty string/); + }); }); it("rejects empty local suffix", () => { diff --git a/src/contracts/ids.ts b/src/contracts/ids.ts index 63732f9..ce70dbf 100644 --- a/src/contracts/ids.ts +++ b/src/contracts/ids.ts @@ -7,6 +7,11 @@ export interface CausalEventId { system: CausalEventSystem; } +export interface CausalCauseId { + local: string; + namespace: string; +} + export const isRecognizedCausalSystem = (value: string): value is CausalEventSystem => (RECOGNIZED_CAUSAL_SYSTEMS as readonly string[]).includes(value); @@ -32,7 +37,29 @@ export const parseCausalEventId = (value: unknown): CausalEventId => { return { local, system }; } -export const parseCausalCauseId = parseCausalEventId; +/** + * Parses RECONCILIATION content per B169 D4. Cause namespaces are open, so + * foreign namespaces are legal. Throwing here must never be wired into a + * parse-fatal admission path for the carrying event. + */ +export const parseCausalCauseId = (value: unknown): CausalCauseId => { + if (typeof value !== "string" || value.length === 0) { + throw new Error("causal cause id must be a non-empty string"); + } + + const separator = value.indexOf(":"); + if (separator <= 0) { + throw new Error("causal cause id must be :"); + } + + const namespace = value.slice(0, separator); + const local = value.slice(separator + 1); + if (local.length === 0) { + throw new Error("causal cause id local suffix must be non-empty"); + } + + return { local, namespace }; +}; export const assertUniqueCauseEventIds = (causeEventIds: readonly string[]): void => { const seen = new Set(); From 577afd9bdb1dbf4acf8c48abb5147b4fe34ab6e9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 22:03:59 +0200 Subject: [PATCH 11/11] chore: release stele 0.0.2 --- package-lock.json | 4 ++-- package.json | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07d30f6..dca206f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@noopolis/stele", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@noopolis/stele", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "zod": "^4.1.12" diff --git a/package.json b/package.json index ec5c709..721d320 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@noopolis/stele", - "version": "0.0.1", + "version": "0.0.2", "description": "Noopolis causal-event envelope schema, parsing, and reconciliation (read/verify side).", "license": "MIT", "type": "module", @@ -10,10 +10,12 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./contracts/goldens/causal-contract.v1.json": "./src/contracts/goldens/causal-contract.v1.json" }, "files": [ "dist", + "src/contracts/goldens/causal-contract.v1.json", "README.md" ], "repository": {