Controller, claim store, live-session guard, engine link and seal, turn tracker, cohort force stop and recovery, client library, transcript and mediated terminal, with the fake engine and tests. Fixtures only; no live cutover. Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694) approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files). Suites on an export: conversation 152/152, control-board 124, webui 14, seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green. Follow-ups for I3 are in DEFERRED. Gate E stays with Jason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
102 lines
4.5 KiB
JavaScript
102 lines
4.5 KiB
JavaScript
// CHAT-01 records for the live controller (#1507, CHAT-03).
|
||
//
|
||
// Every record the controller emits is a CHAT-01 v2 record (schema
|
||
// docs/plans/chat-01/contracts.schema.json). The digest rule is CHAT-01's:
|
||
// sha256 of JSON with sorted keys (check.mjs `hash`).
|
||
//
|
||
// The verifier is the fixture's trusted digest registry, as in CHAT-01
|
||
// (check.mjs `proof` and `stopped`). A proof the producer posts to it is
|
||
// self-posted, so no CHAT-03 proof is live authority (CHAT-01 lines 330–333).
|
||
// Without a verifier no proof verifies, and every stop ends `uncertain`.
|
||
|
||
import { createHash, randomBytes } from "node:crypto";
|
||
import { ID } from "./parts.mjs";
|
||
|
||
export const VERSION = 2;
|
||
|
||
const sortKeys = (v) =>
|
||
Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
|
||
export const canonical = (v) => JSON.stringify(sortKeys(v));
|
||
export const hash = (v) => createHash("sha256").update(canonical(v)).digest("hex");
|
||
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||
export const without = (v, key) => Object.fromEntries(Object.entries(v).filter(([k]) => k !== key));
|
||
export const equal = (a, b) => canonical(a) === canonical(b);
|
||
export const clone = (v) => structuredClone(v);
|
||
|
||
export function newId(prefix) {
|
||
const id = `${prefix}-${randomBytes(8).toString("hex")}`;
|
||
if (!ID.test(id)) throw new Error(`bad id prefix ${prefix}`);
|
||
return id;
|
||
}
|
||
|
||
export const record = (kind, fields) => ({ version: VERSION, kind, ...fields });
|
||
|
||
export function targetOf(binding) {
|
||
return {
|
||
conversation: binding.scope.conversation,
|
||
branch: binding.branch,
|
||
execution: binding.execution,
|
||
controllerGeneration: binding.controllerGeneration,
|
||
};
|
||
}
|
||
|
||
export const scopeMatch = (a, b) => a.conversation === b.conversation && a.branch === b.branch && a.execution === b.execution;
|
||
|
||
// Seals a proof: its verification digest covers every other field.
|
||
export function sealProof(p) {
|
||
const body = without(p, "verificationDigest");
|
||
return { ...body, verificationDigest: hash(body) };
|
||
}
|
||
|
||
export class FixtureVerifier {
|
||
constructor({ authorities = [] } = {}) {
|
||
this.authorities = new Set(authorities);
|
||
this.trusted = new Map();
|
||
}
|
||
|
||
// The fixture registry records a posted proof's digest (CHAT-01 fixtures'
|
||
// `trustedProofs`). Only proofs from a trusted authority are recorded.
|
||
post(p) {
|
||
if (!p || !this.authorities.has(p.authority)) return false;
|
||
const digest = hash(without(p, "verificationDigest"));
|
||
if (digest !== p.verificationDigest) return false;
|
||
this.trusted.set(p.id, digest);
|
||
return true;
|
||
}
|
||
|
||
// check.mjs `proof`: authority, scope, stop, time and digest.
|
||
verify(p, kind, { binding, stop, now }) {
|
||
if (!p || p.kind !== kind || !this.authorities.has(p.authority)) return null;
|
||
if (p.conversation !== binding.scope.conversation || p.execution !== binding.execution || p.cohortRef !== binding.cohortRef || p.stop !== stop) return null;
|
||
if (Date.parse(p.observedAt) > now.getTime()) return null;
|
||
const digest = hash(without(p, "verificationDigest"));
|
||
return digest === p.verificationDigest && this.trusted.get(p.id) === digest ? p : null;
|
||
}
|
||
|
||
// check.mjs `effects`.
|
||
effects(report, ctx) {
|
||
const p = this.verify(report, "effectReport", ctx);
|
||
return Boolean(p && p.invocations.every((i) => ["completed", "uncertain", "not-started"].includes(i.disposition) && (i.disposition === "not-started" || i.evidence)));
|
||
}
|
||
|
||
// check.mjs `stopped`, less the stop-record checks the controller makes.
|
||
cohort(proof, report, ctx) {
|
||
const p = this.verify(proof, "cohortProof", ctx);
|
||
if (!p || !p.membershipComplete || p.membershipEpoch !== ctx.epoch) return false;
|
||
const unique = new Set(p.members.map((m) => `${m.boot}:${m.pid}:${m.startTicks}`)).size === p.members.length;
|
||
const dead = p.members.every((m) => m.terminatedAt && Date.parse(m.terminatedAt) <= Date.parse(p.observedAt));
|
||
return unique && dead && this.effects(report, ctx);
|
||
}
|
||
}
|
||
|
||
// Receipt order (§3 rule 8): admitted < dispatched < acknowledged < working <
|
||
// finished | failed. dispatch-refused only before dispatched,
|
||
// delivery-unknown only before working.
|
||
const ORDER = { admitted: 0, dispatched: 1, acknowledged: 2, working: 3, finished: 4, failed: 4 };
|
||
export function receiptAllows(from, to) {
|
||
if (["finished", "failed", "dispatch-refused", "delivery-unknown"].includes(from)) return false;
|
||
if (to === "dispatch-refused") return from === "admitted";
|
||
if (to === "delivery-unknown") return ORDER[from] < ORDER.working;
|
||
return ORDER[to] > ORDER[from];
|
||
}
|