From 175c824ed2e6154eb05dfd2cabffccd6d9467012 Mon Sep 17 00:00:00 2001 From: CCC Date: Fri, 18 Sep 2026 12:53:50 +0800 Subject: [PATCH] =?UTF-8?q?quilt=20kernel=20P7:=20canon=20ledger=20?= =?UTF-8?q?=E2=80=94=20Layer=20H,=20the=20git=20log=20verified=20against?= =?UTF-8?q?=20the=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet canon doctrine: the repo's git log IS the ledger. verify(t) replays the log and verifies every packet against the Layer C claim (CANON.md). In hermit this was provable but unclaimed: the bench contract and quilt kernel commits exist, the claim didn't. Now the claim is load-bearing. - src/quilt/canon-ledger.ts - parseClaim/loadClaim: Layer C front matter -> typed claim (yaml) - buildPackets: git log replay over the claim's scope (canonical docs + CANON.md + src/quilt/), oldest first, each packet tagged with the claim fields it serves (feeds:tidepool, canonical_docs:, claim) - verify: kernel commits fail if the claim drops feeds:[tidepool]; a canonical doc's REMOVAL is attributed to the exact commit that deleted it (tree check against commit and parent, not the worktree) - replayHash: FNV-1a 64 over the canonical serialization — drift is one string, not a feeling (same algo as the fleet rate limiter) - CLI: bun run src/quilt/canon-ledger.ts [repo] — exit 1 on failure - Implicit mode when CANON.md is absent (this ref predates PR #7): kernel scope only, packets ok with a declared warning - tests/canon-ledger.test.ts: 11 hermetic fixtures (tmp git repos), no network. Includes replay determinism (rebuild -> identical hash) and ledger growth changing the hash. Suite: 411 pass / 4 pre-existing env fails (lobster artwork pipeline, verified failing on the clean base branch — magick/dwebp env). Ref: SuperInstance/SuperInstance PR #18 (fleet-canon doctrine), hermit PR #7 (Layer C claim) --- src/quilt/canon-ledger.ts | 232 +++++++++++++++++++++++++++++++++++++ tests/canon-ledger.test.ts | 171 +++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/quilt/canon-ledger.ts create mode 100644 tests/canon-ledger.test.ts diff --git a/src/quilt/canon-ledger.ts b/src/quilt/canon-ledger.ts new file mode 100644 index 0000000..a6416ad --- /dev/null +++ b/src/quilt/canon-ledger.ts @@ -0,0 +1,232 @@ +// Layer H — the repo ledger. +// +// Canon doctrine (SuperInstance/fleet-canon): the repo's git log IS the +// ledger. verify(t) replays the log and verifies every packet entry +// against the canon claim (Layer C, CANON.md at the repo root). Replay +// determinism is the invariant: rebuilding the packet log from the same +// commit must produce the same packets, or the ledger is forked. +// +// v1 scope (hermit-internal, per the p7 spec): +// - parse the Layer C claim (CANON.md front matter) +// - replay the git log over the claim's scope (canonical docs + kernel) +// - verify every packet: kernel commits must map to a claimed scope; +// canonical docs must never be removed by a packet +// - report a replay hash (FNV-1a 64, the fleet's standard) so drift is +// one string, not a feeling +// +// CLI: bun run src/quilt/canon-ledger.ts [repoPath] (exit 1 on failure) + +import { execFileSync } from "node:child_process" +import { existsSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { parse as parseYaml } from "yaml" + +export interface CanonClaim { + name: string + mission: string + feeds: string[] + owed_by: string[] + canonical_docs: string[] + verified: string +} + +export interface Packet { + commit: string + ts: number + subject: string + files: string[] + affects: string[] + ok: boolean + reason: string +} + +export interface LedgerReport { + claim: CanonClaim | null + implicit: boolean + packets: Packet[] + failures: Packet[] + replayHash: string +} + +const KERNEL_SCOPE = "src/quilt/" + +const git = (repoPath: string, args: string[]): string => + execFileSync("git", ["-C", repoPath, ...args], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }) + +/** Parse the Layer C claim out of CANON.md front matter. Null if absent/unparseable. */ +export function parseClaim(md: string): CanonClaim | null { + const m = md.match(/^---\n([\s\S]*?)\n---\n?/) + if (!m) return null + const doc = parseYaml(m[1]) + if (!doc || typeof doc !== "object") return null + const c = doc as Record + if (typeof c.name !== "string") return null + return { + name: c.name, + mission: typeof c.mission === "string" ? c.mission : "", + feeds: Array.isArray(c.feeds) ? c.feeds.filter((x): x is string => typeof x === "string") : [], + owed_by: Array.isArray(c.owed_by) ? c.owed_by.filter((x): x is string => typeof x === "string") : [], + canonical_docs: Array.isArray(c.canonical_docs) + ? c.canonical_docs.filter((x): x is string => typeof x === "string") + : [], + verified: typeof c.verified === "string" ? c.verified : "", + } +} + +export function loadClaim(repoPath: string): CanonClaim | null { + const path = join(repoPath, "CANON.md") + if (!existsSync(path)) return null + return parseClaim(readFileSync(path, "utf8")) +} + +const treeHas = (repoPath: string, commit: string, path: string): boolean => { + try { + execFileSync("git", ["-C", repoPath, "cat-file", "-e", `${commit}:${path}`], { stdio: "ignore" }) + return true + } catch { + return false + } +} + +interface RawCommit { + commit: string + ts: number + subject: string + files: string[] +} + +/** Replay the git log over the claim's scope. Newest first from git; we return oldest first. */ +function replayLog(repoPath: string, scope: string[]): RawCommit[] { + const args = [ + "log", + "--pretty=format:CANON\x1e%H\x1f%ct\x1f%s", + "--name-only", + "--no-merges", + "--", + ...scope, + ] + const out = git(repoPath, args) + const commits: RawCommit[] = [] + let current: RawCommit | null = null + for (const line of out.split("\n")) { + if (line.startsWith("CANON\x1e")) { + const [, rest] = line.split("\x1e") + const [commit, ct, subject = ""] = rest.split("\x1f") + current = { commit, ts: Number(ct), subject, files: [] } + commits.push(current) + } else if (current && line.trim()) { + current.files.push(line.trim()) + } + } + return commits.reverse() // oldest first: the ledger reads forward in time +} + +/** Which claim fields does this packet touch? */ +function affectsOf(files: string[], claim: CanonClaim | null): string[] { + const affects = new Set() + for (const f of files) { + if (f === "CANON.md") affects.add("claim") + if (f.startsWith(KERNEL_SCOPE)) affects.add("feeds:tidepool") + if (claim?.canonical_docs.includes(f)) affects.add(`canonical_docs:${f}`) + } + return [...affects] +} + +/** Build the packet log for a repo. Without a claim, scope = kernel only (implicit mode). */ +export function buildPackets(repoPath: string, claim: CanonClaim | null): Packet[] { + const scope = claim + ? [...new Set([...claim.canonical_docs, "CANON.md", KERNEL_SCOPE])] + : [KERNEL_SCOPE] + return replayLog(repoPath, scope).map((c) => { + const affects = affectsOf(c.files, claim) + let ok = true + let reason = "ok" + if (!claim) { + ok = true + reason = "implicit claim — kernel unscoped, CANON.md pending" + } else { + const kernelTouched = c.files.some((f) => f.startsWith(KERNEL_SCOPE)) + if (kernelTouched && !claim.feeds.includes("tidepool")) { + ok = false + reason = "kernel commit but claim no longer feeds: [tidepool]" + } + const removed = claim.canonical_docs.filter((d) => { + if (!c.files.includes(d)) return false + if (treeHas(repoPath, c.commit, d)) return false + return treeHas(repoPath, `${c.commit}^`, d) + }) + if (ok && removed.length > 0) { + ok = false + reason = `canonical doc removed: ${removed.join(", ")}` + } + } + return { commit: c.commit, ts: c.ts, subject: c.subject, files: c.files, affects, ok, reason } + }) +} + +/** FNV-1a 64 over the canonical packet serialization — the fleet's hash, same as the rate limiter. */ +export function replayHash(packets: Packet[]): string { + let h = 0xcbf29ce484222325n + const feed = (s: string) => { + for (let i = 0; i < s.length; i++) { + h ^= BigInt(s.charCodeAt(i)) + h = (h * 0x100000001b3n) & 0xffffffffffffffffn + } + h ^= 0xffn + h = (h * 0x100000001b3n) & 0xffffffffffffffffn + } + for (const p of packets) { + feed(p.commit) + feed(String(p.ts)) + feed(p.subject) + for (const f of [...p.files].sort()) feed(f) + for (const a of p.affects) feed(a) + feed(p.ok ? "1" : "0") + feed(p.reason) + } + return h.toString(16).padStart(16, "0") +} + +/** Layer H verify(t): replay the ledger and check it against the claim. */ +export function verify(repoPath = "."): LedgerReport { + const claim = loadClaim(repoPath) + const packets = buildPackets(repoPath, claim) + const failures = packets.filter((p) => !p.ok) + return { + claim, + implicit: claim === null, + packets, + failures, + replayHash: replayHash(packets), + } +} + +const fmt = (ts: number) => new Date(ts * 1000).toISOString().slice(0, 10) + +export function renderReport(report: LedgerReport): string { + const lines: string[] = [] + const claim = report.claim + lines.push( + claim + ? `claim: ${claim.name} (verified ${claim.verified || "?"})` + : "claim: IMPLICIT — CANON.md not on this ref (Layer C pending merge)", + ) + lines.push(`packets: ${report.packets.length} failures: ${report.failures.length}`) + lines.push(`replay: ${report.replayHash}`) + for (const p of report.packets) { + const mark = p.ok ? "ok " : "FAIL" + lines.push(`${mark} ${p.commit.slice(0, 8)} ${fmt(p.ts)} ${p.subject} [${p.affects.join(", ") || "unscoped"}]`) + if (!p.ok) lines.push(` ↳ ${p.reason}`) + } + return lines.join("\n") +} + +if (import.meta.main) { + const repoPath = process.argv[2] ?? "." + const report = verify(repoPath) + console.log(renderReport(report)) + process.exit(report.failures.length > 0 ? 1 : 0) +} diff --git a/tests/canon-ledger.test.ts b/tests/canon-ledger.test.ts new file mode 100644 index 0000000..44d5c58 --- /dev/null +++ b/tests/canon-ledger.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { execFileSync } from "node:child_process" +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { buildPackets, loadClaim, parseClaim, replayHash, verify } from "../src/quilt/canon-ledger" + +let repo: string + +const git = (...args: string[]) => + execFileSync("git", ["-C", repo, ...args], { + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "test-crab", + GIT_AUTHOR_EMAIL: "crab@fleet.test", + GIT_COMMITTER_NAME: "test-crab", + GIT_COMMITTER_EMAIL: "crab@fleet.test", + }, + }) + +const commit = (message: string) => { + git("add", "-A") + git("commit", "-m", message, "--quiet") + return git("rev-parse", "HEAD").trim() +} + +const write = (path: string, body: string) => { + const full = join(repo, path) + mkdirSync(join(full, ".."), { recursive: true }) + writeFileSync(full, body) +} + +const CANON = `--- +canon: 1 +name: fixture +mission: "test fixture" +state: active +family: applications +vessel: CCC +born_from: [] +feeds: [tidepool] +owed_by: [] +canonical_docs: [README.md] +ledger: git-log +verified: 2026-09-18 +--- +` + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "canon-ledger-")) + git("init", "--quiet", "-b", "main") +}) + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }) +}) + +describe("parseClaim", () => { + it("parses the 12-line Layer C front matter", () => { + const claim = parseClaim(CANON) + expect(claim).not.toBeNull() + expect(claim?.name).toBe("fixture") + expect(claim?.feeds).toEqual(["tidepool"]) + expect(claim?.canonical_docs).toEqual(["README.md"]) + }) + + it("returns null without front matter", () => { + expect(parseClaim("# just a readme\nno front matter here")).toBeNull() + }) +}) + +describe("verify — explicit claim", () => { + it("replays the ledger clean when the claim holds", () => { + write("README.md", "# fixture\n") + commit("docs: seed") + write("CANON.md", CANON) + commit("canon: claim the fixture") + write("src/quilt/kernel.mjs", "export const spine = []\n") + commit("quilt kernel: fixture spine") + const report = verify(repo) + expect(report.implicit).toBe(false) + expect(report.packets.length).toBe(3) + expect(report.failures).toEqual([]) + expect(report.claim?.feeds).toContain("tidepool") + }) + + it("fails when a canonical doc is removed", () => { + write("README.md", "# fixture\n") + write("CANON.md", CANON) + commit("seed with claim") + rmSync(join(repo, "README.md")) + commit("docs: drop the readme") + const report = verify(repo) + expect(report.failures.length).toBe(1) + expect(report.failures[0].reason).toContain("canonical doc removed: README.md") + }) + + it("fails when the claim drops the tidepool feed while kernel commits exist", () => { + write("README.md", "# fixture\n") + write("CANON.md", CANON) + write("src/quilt/kernel.mjs", "export const spine = []\n") + commit("seed with claim and kernel") + write("CANON.md", CANON.replace("feeds: [tidepool]", "feeds: []")) + commit("canon: mistakenly drop the feed ack") + const report = verify(repo) + expect(report.failures.length).toBeGreaterThan(0) + expect(report.failures.map((p) => p.reason).join("\n")).toContain( + "kernel commit but claim no longer feeds: [tidepool]", + ) + }) + + it("tags kernel packets with the claim field they serve", () => { + write("README.md", "# fixture\n") + write("CANON.md", CANON) + commit("seed") + write("src/quilt/kernel.mjs", "export const spine = []\n") + commit("quilt kernel: spine") + const report = verify(repo) + const kernelPacket = report.packets.find((p) => p.subject.includes("spine")) + expect(kernelPacket?.affects).toContain("feeds:tidepool") + }) +}) + +describe("verify — implicit claim", () => { + it("runs unscoped with a warning when CANON.md is absent", () => { + write("src/quilt/kernel.mjs", "export const spine = []\n") + commit("quilt kernel: spine before the claim") + const report = verify(repo) + expect(report.implicit).toBe(true) + expect(report.claim).toBeNull() + expect(report.failures).toEqual([]) + expect(report.packets.every((p) => p.reason.includes("implicit"))).toBe(true) + }) +}) + +describe("replay determinism", () => { + it("rebuilding the packet log yields the same replay hash", () => { + write("README.md", "# fixture\n") + write("CANON.md", CANON) + write("src/quilt/kernel.mjs", "export const spine = []\n") + commit("seed all") + const first = verify(repo) + const second = verify(repo) + expect(first.replayHash).toBe(second.replayHash) + expect(first.replayHash).toMatch(/^[0-9a-f]{16}$/) + }) + + it("any ledger growth changes the replay hash", () => { + write("README.md", "# fixture\n") + write("CANON.md", CANON) + commit("seed") + const before = verify(repo).replayHash + write("src/quilt/kernel.mjs", "export const spine = []\n") + commit("quilt kernel: spine") + const after = verify(repo).replayHash + expect(after).not.toBe(before) + }) +}) + +describe("loadClaim", () => { + it("reads CANON.md from the repo root", () => { + write("CANON.md", CANON) + commit("canon: claim") + expect(loadClaim(repo)?.name).toBe("fixture") + }) + + it("returns null when CANON.md is missing", () => { + expect(loadClaim(repo)).toBeNull() + }) +})