Files
stack/packages/conversation/src/records.mjs
T
jason.woltjeandClaude Opus 5.5 243e153c8b feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)
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]>
2026-10-04 15:47:53 -05:00

102 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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];
}