diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b7b34f..dde6984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,3 +70,10 @@ jobs: run: | pnpm generate git diff --exit-code -- src/data/dataset.json + + # The mechanism diagrams and their docs page are rendered from + # src/diagrams/ by the same rule: committed output must equal a fresh render. + - name: Verify public/diagrams and docs/mechanisms.md are regenerable byte-for-byte + run: | + pnpm diagrams + git diff --exit-code -- public/diagrams docs/mechanisms.md diff --git a/README.md b/README.md index 6c867b1..c48bee5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ resolves, every manifest claim is used, public evidence is revision-pinned, and private or operator-attested evidence is labeled without pretending it is publicly verifiable. +## Mechanism diagrams + +Six static diagrams of the mechanisms (closure, handoff lease, airlock, +freshness, spine rows, guard layers) live in each scene's "go deeper" panel and +in [`docs/mechanisms.md`](docs/mechanisms.md). They are rendered from +`src/diagrams/`, print only values computed from the dataset and vocabularies, +wear the manifest's assurance level, and are regenerated with `pnpm diagrams`; +CI fails if a committed file drifts from a fresh render. + ## Accessibility Always-visible Pause on the global transport (WCAG SC 2.2.2), keyboard @@ -58,6 +67,7 @@ pnpm install pnpm dev # local console pnpm test # unit + closure + property + contrast gates pnpm generate # re-emit src/data/dataset.json from the seeded generator +pnpm diagrams # re-emit public/diagrams/*.svg from src/diagrams/ pnpm guard # forbidden-pattern scan (add --git for history) pnpm build # production bundle (sourcemaps off) ``` diff --git a/docs/mechanisms.md b/docs/mechanisms.md new file mode 100644 index 0000000..f42d4ef --- /dev/null +++ b/docs/mechanisms.md @@ -0,0 +1,110 @@ +# Mechanism diagrams + + + +6 pictures of how the Operator OS works, drawn from the same closed data the +explainer replays. Each one lives in a scene's "go deeper" panel and here, as a +static file. This page and the files under `public/diagrams/` are generated from +`src/diagrams/` by `pnpm diagrams`; `src/diagrams/render.test.ts` and CI fail if a +committed file no longer matches a fresh render, and `src/diagrams/facts.test.ts` +fails if a printed figure no longer matches `src/data/dataset.json`. + +## What a diagram promises + +Every diagram wears the assurance level of the weakest architecture claim it +depicts, taken from +[`public/architecture-manifest-v1.json`](../public/architecture-manifest-v1.json): + +- **Explainer-local verified**: Evidence is in this public explainer source pinned to an immutable Git commit. +- **Operator attested**: The claim describes operator practice and is an attestation, not independently verifiable public-source evidence. +- **Private source verified**: Evidence was checked against private operator-controlled source; public readers cannot independently inspect it. +- **Public source verified**: Evidence points to public source pinned to an immutable Git commit. + +Every count and clock time on a diagram is computed from the dataset or the +vocabularies at build time; the in-app figure and the file here are rendered +from one model per diagram, so they cannot say different things. + +## 01 · How the console stays honest + + + + Closed vocabularies feed a pure seeded generator that writes one committed dataset; the app imports only that file, three gates fail the build on any escape, the bundle ships without source maps, and the release lineage is an orphan tree. The real stores have no wire into the app. + + +Every shipped value is a member of an audited finite set by construction: the generator can only emit vocabulary members, the closure test proves nothing escaped, and the app has no wire to the real stores. The scanner is a backstop for mistakes in the allowlists themselves. + +Verify: pnpm test (closure, property, contrast, manifest) · pnpm guard · pnpm build, then inspect dist for .map files. + +Claims: `public-release-closure`, `deterministic-synthetic-replay`. Assurance: explainer-local verified. Scene: Coda. + +## 02 · A handoff is a lease with a receipt + + + + Three lanes: claude.ai, the spine, and Claude Code. Dispatch creates a pending handoff row; Claude Code reads the latest snapshot, picks the handoff up and receives a one-time completion capability; 15.5 s of mission work pass; both sides acknowledge a receipt; clear consumes the capability and the row reads cleared. Below, the SHIPPED tag on the activity row creates a sync obligation to the external build log. + + +Pickup is the one dangerous transition, pending to active, so it hands the claimant a one-time completion capability that only clear can consume. The 15.5 s gap is real: the Spine scene and the Finale share handoff 1 and mission 1. + +Verify: stages and statuses are closed sets in src/data/vocab.ts · times from src/data/dataset.json · capability and retention in the bridge-db README, Tools and Retention, pinned at e0a9560. + +Claims: `bridge-sqlite-spine`. Assurance: public source verified. Scene: The Spine. + +## 03 · Nothing leaves without a token + + + + Three lanes, agent, hub, operator, and a boundary marked outside the machine. The agent creates a draft and raises an approval. Only the operator mints the one-use confirmation token and opens the timed send window. The send gate opens to the outside only when both hold. The agent lane never reaches the boundary. + + +draft, approval, send are the 3 closed stages. The token and the send window are not stages: they are two conditions on the last edge, and both are operator-only. Every externally visible action is a reviewed, token-gated event. + +Verify: HUBFLOW_STAGES is a closed set of 3 in src/data/vocab.ts · no send event precedes its approval in src/data/dataset.json · the token and window boundary is private control-plane source, checked but not publicly inspectable. + +Claims: `approval-airlock`. Assurance: private source verified. Scene: The Hub. + +## 04 · Alerts fire only from fresh data + + + + Left: 5 spokes (bridge, event-bus, overlay, auditor, evals-ledger), each its own system of record, read by the hub through an adapter; the hub never calls external services. Right: a 4-state machine (fresh, aging, stale, unavailable). A tick moves any state to fresh; age moves fresh to aging to stale. Only fresh has an exit labeled alert eligible. In the fixture the overlay spoke goes stale and never recovers. + + +Each spoke keeps its own system of record; the hub only reads. Because the alert edge leaves the fresh state and no other, an aging or stale spoke goes quiet instead of lying, which is why the overlay light stays dark at the end of the replay. + +Verify: SPOKES and FRESHNESS_STATES are closed sets in src/data/vocab.ts · ticks from src/data/dataset.json · the alert eligibility boundary is private control-plane source, checked but not publicly inspectable. + +Claims: `freshness-gated-alerts`. Assurance: private source verified. Scene: The Hub. + +## 05 · One store, five row shapes, fixed writers + + + + A horizontal bus labeled bridge-db, SQLite plus FTS5, with 5 taps: activity, handoffs, snapshots, cost, sections. Under each tap, the systems allowed to write it under their fixed caller identity. Below, the retention rule for activity: untagged rows keep only the newest per source, rows tagged SHIPPED or LEDGER are kept for good, and SHIPPED creates a sync obligation. Every row carries a source trust value. + + +Nothing moves between systems except through these five shapes, each written under a fixed identity. Retention is structural: a protected tag is a property of the row, so no pruning pass and no cascade can orphan its receipt. + +Verify: shapes and writer sets mirror src/types/data.ts (Caller, SnapSystem, CostSystem, SectionOwner, SourceTrust, ActivityTag) · the bridge-db README, Architecture, Tools, Trust and Retention, pinned at e0a9560. + +Claims: `bridge-sqlite-spine`. Assurance: public source verified. Scene: The Spine. + +## 06 · Guards fire, the agent adapts + + + + 7 guard layers stacked from outer intent to inner floor: permission-mode, deny-list, pretooluse-hook, hard-deny, confidence-gate, verify-gate, integrity-floor. 6 fabricated would-be actions enter from the left and each stops at the layer that catches it, then exits to the right as an adaptation. No line crosses to a privilege escalation. The integrity floor checks the other 6 are present. + + +Each layer works alone, so no single failure unlocks the system. A blocked action is a signal to adapt: open a branch, reword, reroute, verify first, or hand the decision to the operator. + +Verify: GUARD_LAYERS (7), RULE_CONCEPTS (6), ADAPTATIONS and GUARD_MAP in src/data/vocab.ts · the guard event at T+11.0 s in src/data/dataset.json · the layered practice itself is an operator attestation, not public source. + +Claims: `layered-operation-guards`. Assurance: operator attested. Scene: The Safety Layers. + +## Regenerating + +```sh +pnpm diagrams # rewrites public/diagrams/*.svg and this page from src/diagrams/ +pnpm test # fails if a committed file or a printed fact has drifted +``` diff --git a/e2e/product-quality.spec.ts b/e2e/product-quality.spec.ts index 6485e6a..bac817a 100644 --- a/e2e/product-quality.spec.ts +++ b/e2e/product-quality.spec.ts @@ -24,6 +24,17 @@ const viewports = [ const titleFor = (heading: string) => `${heading} — Anatomy of an AI Operator OS`; +/** Mechanism diagrams each scene's deep panel carries (src/diagrams placement). */ +const diagramsByRoute: Record = { + "/": 0, + "/fleet": 0, + "/spine": 2, + "/safety": 1, + "/finale": 0, + "/hub": 2, + "/coda": 1, +}; + async function openStable(page: Page, route: string) { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(route, { waitUntil: "domcontentloaded" }); @@ -428,12 +439,36 @@ test.describe("scene interaction dispositions", () => { await disclosure.press("Enter"); const region = page.getByRole("region", { name: /go deeper/i }); await expect(region).toBeVisible(); + const figures = region.locator("figure[data-diagram]"); + await expect(figures).toHaveCount(diagramsByRoute[route.path] ?? 0); + for (const figure of await figures.all()) { + const svg = figure.getByRole("img"); + await expect(svg).toBeVisible(); + expect((await svg.getAttribute("aria-label"))?.length ?? 0).toBeGreaterThan(40); + } const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(overflow, `${route.path} expanded panel overflow`).toBe(0); } }); + + for (const width of [320, 1440] as const) { + for (const route of routes.filter((r) => (diagramsByRoute[r.path] ?? 0) > 0)) { + test(`${route.slug} deep panel with its diagrams passes WCAG checks at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: width === 320 ? 720 : 900 }); + await openStable(page, route.path); + await page.getByRole("button", { name: /go deeper/i }).press("Enter"); + await expect(page.getByRole("region", { name: /go deeper/i })).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + expect(results.violations).toEqual([]); + }); + } + } }); test("all routes remain free of runtime console and page errors", async ({ page }) => { diff --git a/package.json b/package.json index 40d86de..c41f50c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:e2e": "playwright test", "typecheck": "tsc -b", "generate": "node scripts/generate-dataset.ts", + "diagrams": "node scripts/render-diagrams.ts", "guard": "node scripts/guard-scan.ts", "verify:live": "node scripts/check-live-parity.ts" }, diff --git a/public/diagrams/airlock-deck.svg b/public/diagrams/airlock-deck.svg new file mode 100644 index 0000000..5f2f614 --- /dev/null +++ b/public/diagrams/airlock-deck.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +T+40.0 s +draft +T+42.0 s +approval +T+44.0 s +send +OUTSIDE +THE MACHINE +AGENT +prepares everything +HUB +approval queue · durable +OPERATOR +releases, or does not +create draft +draft artifact draft-71 +draft +raise approval +raised for review +pending +agent prepares everything, releases nothing +mints a one-use +confirmation token +token accepted +opens the timed +send window +window open +opens only if +token AND window +sent +read-only views (status, inbox, worklist) +cross no boundary and need no token + diff --git a/public/diagrams/airlock-paper.svg b/public/diagrams/airlock-paper.svg new file mode 100644 index 0000000..0a77db5 --- /dev/null +++ b/public/diagrams/airlock-paper.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +T+40.0 s +draft +T+42.0 s +approval +T+44.0 s +send +OUTSIDE +THE MACHINE +AGENT +prepares everything +HUB +approval queue · durable +OPERATOR +releases, or does not +create draft +draft artifact draft-71 +draft +raise approval +raised for review +pending +agent prepares everything, releases nothing +mints a one-use +confirmation token +token accepted +opens the timed +send window +window open +opens only if +token AND window +sent +read-only views (status, inbox, worklist) +cross no boundary and need no token + diff --git a/public/diagrams/closure-deck.svg b/public/diagrams/closure-deck.svg new file mode 100644 index 0000000..493d23d --- /dev/null +++ b/public/diagrams/closure-deck.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +bridge-db · personal-ops +the real stores +no import · no fetch · no MCP +src/data/vocab.ts +closed vocabularies +24 codenames · 8 task classes +7 guard layers · 6 rules +src/data/generate.ts +pure: seed -> data +SEED 0x0fe7a123 · mulberry32 +no Date.now, no Math.random +src/data/dataset.json +committed artifact +46 events · 8 activity · 2 handoffs +the app's only data import +pnpm build +vite · sourcemap: false +no build-machine paths +in the shipped bundle +release lineage +orphan root · neutral author +no merge base with dev, ever +log -p scanned, not just tree +may only +emit members +written once, +at authoring +bundled +tree copied, +never merged +closure.test.ts +every value in its allowlist +cost deltas sum to session cost +byte-identical · plants must fail +property.test.ts +closure holds across +sampled seeds +scripts/guard-scan.ts +source + dataset + dist +self-test: planted violations first +forbidden names stored as hashes +GATES · any one failing fails the build +the load-bearing gate is closure, +not the scanner + diff --git a/public/diagrams/closure-paper.svg b/public/diagrams/closure-paper.svg new file mode 100644 index 0000000..f059b1c --- /dev/null +++ b/public/diagrams/closure-paper.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +bridge-db · personal-ops +the real stores +no import · no fetch · no MCP +src/data/vocab.ts +closed vocabularies +24 codenames · 8 task classes +7 guard layers · 6 rules +src/data/generate.ts +pure: seed -> data +SEED 0x0fe7a123 · mulberry32 +no Date.now, no Math.random +src/data/dataset.json +committed artifact +46 events · 8 activity · 2 handoffs +the app's only data import +pnpm build +vite · sourcemap: false +no build-machine paths +in the shipped bundle +release lineage +orphan root · neutral author +no merge base with dev, ever +log -p scanned, not just tree +may only +emit members +written once, +at authoring +bundled +tree copied, +never merged +closure.test.ts +every value in its allowlist +cost deltas sum to session cost +byte-identical · plants must fail +property.test.ts +closure holds across +sampled seeds +scripts/guard-scan.ts +source + dataset + dist +self-test: planted violations first +forbidden names stored as hashes +GATES · any one failing fails the build +the load-bearing gate is closure, +not the scanner + diff --git a/public/diagrams/freshness-deck.svg b/public/diagrams/freshness-deck.svg new file mode 100644 index 0000000..84df563 --- /dev/null +++ b/public/diagrams/freshness-deck.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +HUB AND SPOKE +bridge +own store +event-bus +own store +overlay +own store +auditor +own store +evals-ledger +own store +each read through +an adapter +HUB +reads spokes +classifies freshness +never calls an external service +its one cross-system write: +activity into the spine, fire and forget +per spoke +FRESHNESS STATE MACHINE · one per spoke +unavailable +fresh +aging +stale +tick arrives +age passes +age passes +new tick +new tick +ALERT eligible +the only exit +goes quiet +never lies +FIXTURE +bridge: fresh T+10.0 s +event-bus: fresh T+20.0 s +overlay: stale T+50.0 s · dark to T+90.0 s +auditor: aging T+35.0 s -> fresh T+70.0 s +evals-ledger: fresh T+65.0 s + diff --git a/public/diagrams/freshness-paper.svg b/public/diagrams/freshness-paper.svg new file mode 100644 index 0000000..71fb470 --- /dev/null +++ b/public/diagrams/freshness-paper.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +HUB AND SPOKE +bridge +own store +event-bus +own store +overlay +own store +auditor +own store +evals-ledger +own store +each read through +an adapter +HUB +reads spokes +classifies freshness +never calls an external service +its one cross-system write: +activity into the spine, fire and forget +per spoke +FRESHNESS STATE MACHINE · one per spoke +unavailable +fresh +aging +stale +tick arrives +age passes +age passes +new tick +new tick +ALERT eligible +the only exit +goes quiet +never lies +FIXTURE +bridge: fresh T+10.0 s +event-bus: fresh T+20.0 s +overlay: stale T+50.0 s · dark to T+90.0 s +auditor: aging T+35.0 s -> fresh T+70.0 s +evals-ledger: fresh T+65.0 s + diff --git a/public/diagrams/guards-deck.svg b/public/diagrams/guards-deck.svg new file mode 100644 index 0000000..ac68c9c --- /dev/null +++ b/public/diagrams/guards-deck.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WOULD-BE ACTION · closed set +LAYER · outer intent to inner floor +ADAPTATION · never an escalation +1 · permission-mode +no rule in the map · independent anyway +2 · deny-list +credential-read +escalated-to-operator +3 · pretooluse-hook +non-local-db-write +reworded +deep-home-delete +rerouted +4 · hard-deny +push-to-main +replay: T+11.0 s +BLOCKED +opened-a-branch +harness-self-mutate +escalated-to-operator +5 · confidence-gate +no rule in the map · independent anyway +6 · verify-gate +unverified-complete +ran-verify-first +7 · integrity-floor +guards the guards: checks layers 1 to 6 +are present and untampered +Every entering line stops at one layer; +nothing passes through to more permission. +Only push-to-main has a timeline event; +the other 5 resolve from the static map. + diff --git a/public/diagrams/guards-paper.svg b/public/diagrams/guards-paper.svg new file mode 100644 index 0000000..ba7f0bf --- /dev/null +++ b/public/diagrams/guards-paper.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WOULD-BE ACTION · closed set +LAYER · outer intent to inner floor +ADAPTATION · never an escalation +1 · permission-mode +no rule in the map · independent anyway +2 · deny-list +credential-read +escalated-to-operator +3 · pretooluse-hook +non-local-db-write +reworded +deep-home-delete +rerouted +4 · hard-deny +push-to-main +replay: T+11.0 s +BLOCKED +opened-a-branch +harness-self-mutate +escalated-to-operator +5 · confidence-gate +no rule in the map · independent anyway +6 · verify-gate +unverified-complete +ran-verify-first +7 · integrity-floor +guards the guards: checks layers 1 to 6 +are present and untampered +Every entering line stops at one layer; +nothing passes through to more permission. +Only push-to-main has a timeline event; +the other 5 resolve from the static map. + diff --git a/public/diagrams/lease-deck.svg b/public/diagrams/lease-deck.svg new file mode 100644 index 0000000..f158376 --- /dev/null +++ b/public/diagrams/lease-deck.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +T+1.5 s +dispatch +T+2.2 s +snapshot +T+3.0 s +pickup +T+18.5 s +receipt +T+19.0 s +clear +15.5 s of mission work +fan out · guard fires · verify · ship +see The Fleet in Motion +CLAUDE.AI +dispatches · no filesystem +SPINE +bridge-db · handoff row +CLAUDE CODE +claims · has the repo +create_handoff +phase implement · PLAN.md +pending +reads the latest snapshot +before touching anything +pick_up_handoff +completion capability +one-time · expires · bearer +active · cc +held in the claiming session · never logged +receipt +symmetric receipt: +both sides acknowledge +clear_handoff +consumes the capability +cleared +THE RECEIPT CHAIN · activity row 202 +shipped the export pipeline +T+17.4 s · cc · tag +SHIPPED +creates +sync obligation +reconciled exactly once +ship +external build log · buildlog/corveth +T+18.0 s · sync state kept on the row + diff --git a/public/diagrams/lease-paper.svg b/public/diagrams/lease-paper.svg new file mode 100644 index 0000000..e9e77c8 --- /dev/null +++ b/public/diagrams/lease-paper.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +T+1.5 s +dispatch +T+2.2 s +snapshot +T+3.0 s +pickup +T+18.5 s +receipt +T+19.0 s +clear +15.5 s of mission work +fan out · guard fires · verify · ship +see The Fleet in Motion +CLAUDE.AI +dispatches · no filesystem +SPINE +bridge-db · handoff row +CLAUDE CODE +claims · has the repo +create_handoff +phase implement · PLAN.md +pending +reads the latest snapshot +before touching anything +pick_up_handoff +completion capability +one-time · expires · bearer +active · cc +held in the claiming session · never logged +receipt +symmetric receipt: +both sides acknowledge +clear_handoff +consumes the capability +cleared +THE RECEIPT CHAIN · activity row 202 +shipped the export pipeline +T+17.4 s · cc · tag +SHIPPED +creates +sync obligation +reconciled exactly once +ship +external build log · buildlog/corveth +T+18.0 s · sync state kept on the row + diff --git a/public/diagrams/spine-rows-deck.svg b/public/diagrams/spine-rows-deck.svg new file mode 100644 index 0000000..b480386 --- /dev/null +++ b/public/diagrams/spine-rows-deck.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + +activity +session telemetry, searchable +cc · codex · claude_ai +notion_os · personal_ops +handoffs +work moving between systems +dispatched by claude_ai only +claimed by cc or codex +snapshots +state saved on completion +cc · codex +read first on takeover +cost +records and session costs +cc · codex +notion_os · personal_ops +sections +long-lived context, stewarded +owner claude_ai · cc · codex +the steward is recorded on read +BRIDGE-DB · SQLITE + FTS5 +recall searches every row +WHO MAY WRITE · fixed caller identity +RETENTION · activity only +untagged: only the newest 50 per source +SHIPPED · LEDGER +retained for good, with their receipts +SHIPPED alone obliges a build-log sync +TRUST · every row +source_trust: operator · agent · ingested +read back as stored data, not instructions +every gate decision lands in the audit log + diff --git a/public/diagrams/spine-rows-paper.svg b/public/diagrams/spine-rows-paper.svg new file mode 100644 index 0000000..9bce41f --- /dev/null +++ b/public/diagrams/spine-rows-paper.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + +activity +session telemetry, searchable +cc · codex · claude_ai +notion_os · personal_ops +handoffs +work moving between systems +dispatched by claude_ai only +claimed by cc or codex +snapshots +state saved on completion +cc · codex +read first on takeover +cost +records and session costs +cc · codex +notion_os · personal_ops +sections +long-lived context, stewarded +owner claude_ai · cc · codex +the steward is recorded on read +BRIDGE-DB · SQLITE + FTS5 +recall searches every row +WHO MAY WRITE · fixed caller identity +RETENTION · activity only +untagged: only the newest 50 per source +SHIPPED · LEDGER +retained for good, with their receipts +SHIPPED alone obliges a build-log sync +TRUST · every row +source_trust: operator · agent · ingested +read back as stored data, not instructions +every gate decision lands in the audit log + diff --git a/scripts/render-diagrams.ts b/scripts/render-diagrams.ts new file mode 100644 index 0000000..346d3c6 --- /dev/null +++ b/scripts/render-diagrams.ts @@ -0,0 +1,45 @@ +/** + * Emits public/diagrams/-.svg and docs/mechanisms.md from the + * diagram models. Run at authoring time (node scripts/render-diagrams.ts); + * src/diagrams/render.test.ts proves the committed files match a fresh + * render byte for byte. Asserts run-to-run determinism before writing, like + * the dataset generator. + */ +import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { DIAGRAMS } from "../src/diagrams/index.ts"; +import { renderDocs } from "../src/diagrams/render-docs.ts"; +import { renderSvgString } from "../src/diagrams/render-string.ts"; + +const OUT = "public/diagrams"; +mkdirSync(OUT, { recursive: true }); +mkdirSync("docs", { recursive: true }); + +// A renamed or removed diagram must not leave its old file behind, or the +// byte-for-byte check would bless an orphan the registry no longer knows. +for (const stale of readdirSync(OUT).filter((f) => f.endsWith(".svg"))) { + rmSync(`${OUT}/${stale}`); +} + +function deterministic(label: string, render: () => string): string { + const first = render(); + if (first !== render()) { + console.error(`FATAL: ${label} is not deterministic across two runs`); + process.exit(1); + } + return first; +} + +const written: string[] = []; +const models = Object.values(DIAGRAMS); +for (const model of models) { + for (const ground of ["deck", "paper"] as const) { + const path = `${OUT}/${model.id}-${ground}.svg`; + const svg = deterministic(path, () => renderSvgString(model, "wide", ground)); + writeFileSync(path, svg); + written.push(`${path} (${svg.length} bytes)`); + } +} +const docs = deterministic("docs/mechanisms.md", () => renderDocs(models)); +writeFileSync("docs/mechanisms.md", docs); +written.push(`docs/mechanisms.md (${docs.length} bytes)`); +console.log(written.join("\n")); diff --git a/src/components/shell/DeepPanel.test.tsx b/src/components/shell/DeepPanel.test.tsx index d6453ac..4a4adbd 100644 --- a/src/components/shell/DeepPanel.test.tsx +++ b/src/components/shell/DeepPanel.test.tsx @@ -13,4 +13,14 @@ describe("DeepPanel", () => { ).toBeInTheDocument(); expect(disclosure).toHaveAttribute("aria-expanded", "true"); }); + + it("keeps its figures inside the region and hidden until opened", () => { + render( + a mechanism} />, + ); + expect(screen.queryByText("a mechanism")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /the evidence/i })); + const region = screen.getByRole("region", { name: /the evidence/i }); + expect(region).toContainElement(screen.getByText("a mechanism")); + }); }); diff --git a/src/components/shell/DeepPanel.tsx b/src/components/shell/DeepPanel.tsx index 7961faf..8c448e4 100644 --- a/src/components/shell/DeepPanel.tsx +++ b/src/components/shell/DeepPanel.tsx @@ -10,10 +10,13 @@ export function DeepPanel({ title, body, children, + figures, }: { title: string; body: string; children?: ReactNode; + /** Full-width mechanism figures, stacked under the prose and the extra. */ + figures?: ReactNode; }) { const [open, setOpen] = useState(false); const regionId = useId(); @@ -34,16 +37,16 @@ export function DeepPanel({ {open ? ( -
-

- {body} -

- {children ?
{children}
: null} +
+
+

+ {body} +

+ {children ?
{children}
: null} +
+ {figures ? ( +
{figures}
+ ) : null}
) : null}
diff --git a/src/components/shell/SceneFrame.tsx b/src/components/shell/SceneFrame.tsx index e9cd69f..e39fdc7 100644 --- a/src/components/shell/SceneFrame.tsx +++ b/src/components/shell/SceneFrame.tsx @@ -1,11 +1,14 @@ import type { ReactNode } from "react"; +import { diagramsFor } from "../../diagrams/index.ts"; +import { MechanismFigure } from "../../diagrams/MechanismFigure"; import type { SceneConfig } from "../../types/scene.ts"; import { DeepPanel } from "./DeepPanel"; /** * Shared scene chrome: eyebrow, display title, layout slots, deep panel. * Every scene renders inside this frame so the console reads as one - * instrument (SPEC 2.1). + * instrument (SPEC 2.1). The mechanism diagrams a scene carries are + * declared on the diagram models (src/diagrams), not chosen here. */ export function SceneFrame({ config, @@ -17,6 +20,7 @@ export function SceneFrame({ deepPanelExtra?: ReactNode; }) { const number = String(config.number).padStart(2, "0"); + const diagrams = diagramsFor(config.lens); return (
{/* decorative ghost numeral: pseudo-element content keeps it out of @@ -44,7 +48,15 @@ export function SceneFrame({
{children}
- + 0 + ? diagrams.map((model) => ) + : undefined + } + > {deepPanelExtra}
diff --git a/src/data/architecture-manifest.test.ts b/src/data/architecture-manifest.test.ts index 9ce493c..422da2f 100644 --- a/src/data/architecture-manifest.test.ts +++ b/src/data/architecture-manifest.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; +import { DIAGRAMS } from "../diagrams/index.ts"; import { SCENES } from "../scenes/index.ts"; import { ARCHITECTURE_CLAIM_IDS, @@ -90,4 +91,16 @@ describe("PublicArchitectureManifestV1", () => { expect([...referencedIds].sort()).toEqual([...ARCHITECTURE_CLAIM_IDS]); }); + + it("binds every mechanism diagram's claims", () => { + const manifestIds = new Set( + architectureManifest.claims.map((claim) => claim.id), + ); + for (const diagram of Object.values(DIAGRAMS)) { + expect(diagram.claims.length, `diagram ${diagram.id} needs provenance`).toBeGreaterThan(0); + for (const claimId of diagram.claims) { + expect(manifestIds.has(claimId), `diagram ${diagram.id} references ${claimId}`).toBe(true); + } + } + }); }); diff --git a/src/data/architecture-manifest.ts b/src/data/architecture-manifest.ts index 49ce8e5..baac614 100644 --- a/src/data/architecture-manifest.ts +++ b/src/data/architecture-manifest.ts @@ -1,4 +1,4 @@ -import rawManifest from "../../public/architecture-manifest-v1.json"; +import rawManifest from "../../public/architecture-manifest-v1.json" with { type: "json" }; import type { PublicArchitectureManifestV1 } from "../types/architecture.ts"; /** diff --git a/src/data/dataset.ts b/src/data/dataset.ts index 7c648ac..eef0270 100644 --- a/src/data/dataset.ts +++ b/src/data/dataset.ts @@ -5,6 +5,6 @@ * generate(SEED), whose return type is Dataset. */ import type { Dataset } from "../types/data.ts"; -import datasetJson from "./dataset.json"; +import datasetJson from "./dataset.json" with { type: "json" }; export const dataset = datasetJson as unknown as Dataset; diff --git a/src/diagrams/MechanismFigure.tsx b/src/diagrams/MechanismFigure.tsx new file mode 100644 index 0000000..30ec03f --- /dev/null +++ b/src/diagrams/MechanismFigure.tsx @@ -0,0 +1,138 @@ +import { useVizScale } from "../lib/useVizScale"; +import { ASSURANCE_LABEL, assuranceFor } from "./assurance.ts"; +import type { DiagramModel, Tone } from "./model.ts"; +import { arrowTones, markerId } from "./render-string.ts"; + +/** + * The in-app rendering of a mechanism diagram: the same model the docs + * export serialises, drawn with the deck tokens and the shared responsive + * scaling (wide or compact geometry, phone label floor). Static by design: + * no motion, so nothing to gate under reduced motion. + */ +const TONE_VAR: Record = { + bg: "var(--deck)", + fg: "var(--ink-deck)", + muted: "var(--ink-deck-muted)", + line: "var(--deck-line)", + raised: "var(--deck-raised)", + accent: "var(--accent-deck)", +}; +const paint = (t: Tone | "none") => (t === "none" ? "none" : TONE_VAR[t]); + +/** + * Below this container width the 1120-unit wide layout renders its 10-unit + * labels under 8.6 CSS px, so the phone layout takes over even on a desktop + * viewport. The phone layout is then capped so its labels stay a sane size. + */ +const WIDE_MIN_PX = 960; +const COMPACT_MAX_PX = 480; + +export function MechanismFigure({ model }: { model: DiagramModel }) { + const { + ref, + variant: g, + fs, + compact, + } = useVizScale({ wide: model.wide, compact: model.compact }, { minWideWidth: WIDE_MIN_PX }); + const badge = ASSURANCE_LABEL[assuranceFor(model.claims)]; + + return ( +
+
+

+ {model.eyebrow} +

+

{model.title}

+
+ + + {arrowTones(g).map((tone) => ( + + + + ))} + + {g.lines.map((l, i) => ( + + ))} + {g.paths.map((p, i) => ( + + ))} + {g.rects.map((r, i) => ( + + ))} + {g.circles.map((o, i) => ( + + ))} + {g.texts.map((t, i) => ( + + {t.text} + + ))} + +
+ {model.figcaption} +
+
+

+ Verify · {model.verify} +

+ + {badge} + +
+
+ ); +} diff --git a/src/diagrams/airlock.ts b/src/diagrams/airlock.ts new file mode 100644 index 0000000..f8f998d --- /dev/null +++ b/src/diagrams/airlock.ts @@ -0,0 +1,188 @@ +/** + * Mechanism 03: nothing leaves without a token. Draft, approval, send are + * the three closed stages; the one-use token and the timed send window are + * two operator-only conditions on the last edge, not stages. + */ +import { sketch } from "./draw.ts"; +import { fmtT, hubflowStages } from "./facts.ts"; +import type { DiagramModel } from "./model.ts"; + +const stages = hubflowStages(); +const stage = (name: (typeof stages)[number]["stage"]) => { + const e = stages.find((s) => s.stage === name); + if (!e) throw new Error(`no ${name} hubflow event in the dataset`); + return e; +}; +const draft = stage("draft"); +const approval = stage("approval"); +const send = stage("send"); + +const L = { + tDraft: fmtT(draft.at), + tApproval: fmtT(approval.at), + tSend: fmtT(send.at), + draft: draft.stage, + approval: approval.stage, + send: send.stage, + outA: "OUTSIDE", + outB: "THE MACHINE", + laneA: "AGENT", + laneASub: "prepares everything", + laneH: "HUB", + laneHSub: "approval queue · durable", + laneO: "OPERATOR", + laneOSub: "releases, or does not", + draftA: "create draft", + draftB: `draft artifact ${draft.artifactId}`, + chipDraft: "draft", + apprA: "raise approval", + apprB: "raised for review", + chipPending: "pending", + agentStops: "agent prepares everything, releases nothing", + tokA: "mints a one-use", + tokB: "confirmation token", + chipToken: "token accepted", + winA: "opens the timed", + winB: "send window", + chipWindow: "window open", + gateA: "opens only if", + gateB: "token AND window", + sent: "sent", + roA: "read-only views (status, inbox, worklist)", + roB: "cross no boundary and need no token", +}; + +function wide() { + const s = sketch("0 0 1120 350"); + s.text(260, 24, L.tDraft, 10, "muted", { anchor: "middle" }); + s.text(260, 40, L.draft, 11, "fg", { anchor: "middle", bold: true }); + s.text(470, 24, L.tApproval, 10, "muted", { anchor: "middle" }); + s.text(470, 40, L.approval, 11, "fg", { anchor: "middle", bold: true }); + s.text(850, 24, L.tSend, 10, "muted", { anchor: "middle" }); + s.text(850, 40, L.send, 11, "fg", { anchor: "middle", bold: true }); + // the boundary + s.line(1000, 50, 1000, 330, "fg", { sw: 2 }); + s.text(1012, 70, L.outA, 10, "muted"); + s.text(1012, 84, L.outB, 10, "muted"); + // lanes: the agent lane is solid until approval, dashed after it + s.line(120, 80, 476, 80, "line"); + s.line(476, 80, 996, 80, "line", { dash: "2 5" }); + s.text(0, 78, L.laneA, 11, "fg", { bold: true }); + s.text(0, 92, L.laneASub, 10, "muted"); + s.line(120, 190, 996, 190, "fg", { sw: 2 }); + s.text(0, 188, L.laneH, 11, "fg", { bold: true }); + s.text(0, 202, L.laneHSub, 10, "muted"); + s.line(120, 300, 996, 300, "line"); + s.text(0, 298, L.laneO, 11, "fg", { bold: true }); + s.text(0, 312, L.laneOSub, 10, "muted"); + // draft + s.dot(260, 80, 4); + s.line(260, 86, 260, 176, "fg", { arrow: true }); + s.text(268, 122, L.draftA, 10, "muted"); + s.text(268, 136, L.draftB, 10, "muted"); + s.chip(260, 190, 64, L.chipDraft); + // approval + s.dot(470, 80, 4); + s.line(470, 86, 470, 176, "fg", { arrow: true }); + s.text(478, 122, L.apprA, 10, "muted"); + s.text(478, 136, L.apprB, 10, "muted"); + s.chip(470, 190, 88, L.chipPending); + s.text(740, 70, L.agentStops, 10, "muted", { anchor: "middle" }); + // token: operator only + s.dot(620, 300, 4, "accent"); + s.line(620, 294, 620, 204, "accent", { arrow: true }); + s.text(628, 244, L.tokA, 10, "accent"); + s.text(628, 258, L.tokB, 10, "accent"); + s.chip(620, 190, 96, L.chipToken, "accent"); + // send window: operator only + s.dot(760, 300, 4); + s.line(760, 294, 760, 204, "fg", { arrow: true }); + s.text(768, 244, L.winA, 10, "muted"); + s.text(768, 258, L.winB, 10, "muted"); + s.chip(760, 190, 96, L.chipWindow); + // send gate + s.line(846, 170, 846, 210, "fg", { sw: 2 }); + s.line(856, 170, 856, 210, "fg", { sw: 2 }); + s.line(860, 190, 994, 190, "fg", { arrow: true }); + s.text(850, 150, L.gateA, 10, "muted", { anchor: "middle" }); + s.text(850, 164, L.gateB, 10, "muted", { anchor: "middle" }); + s.text(1012, 194, L.sent, 10, "fg"); + // the free path + s.text(120, 334, L.roA, 10, "muted"); + s.text(120, 348, L.roB, 10, "muted"); + return s.done(); +} + +/** Phone layout: rows stack down the page, the boundary is the bottom edge. */ +function compact() { + const s = sketch("0 0 340 522"); + const A = 64; + const H = 170; + const O = 284; + s.text(A, 22, L.laneA, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(H, 22, L.laneH, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(O, 22, L.laneO, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(20, 38, L.laneASub, 10, "muted"); + s.text(H, 52, L.laneHSub, 10, "muted", { anchor: "middle" }); + s.text(320, 66, L.laneOSub, 10, "muted", { anchor: "end" }); + s.line(20, 76, 320, 76, "line"); + const stageRow = (y: number, name: string, time: string) => { + s.text(H - 6, y, name, 10.5, "fg", { anchor: "end", bold: true }); + s.text(H + 6, y, time, 10, "muted"); + }; + // draft + stageRow(96, L.draft, L.tDraft); + s.dot(A, 112, 3.5); + s.line(A + 6, 112, H - 32, 112, "fg", { arrow: true }); + s.chip(H, 112, 60, L.chipDraft, "fg", 10); + s.text(20, 132, L.draftA, 10, "muted"); + s.text(20, 146, L.draftB, 10, "muted"); + // approval + stageRow(168, L.approval, L.tApproval); + s.dot(A, 184, 3.5); + s.line(A + 6, 184, H - 34, 184, "fg", { arrow: true }); + s.chip(H, 184, 66, L.chipPending, "fg", 10); + s.text(20, 204, L.apprA, 10, "muted"); + s.text(20, 218, L.apprB, 10, "muted"); + s.text(H, 242, L.agentStops, 10, "muted", { anchor: "middle" }); + // token + s.dot(O, 266, 3.5, "accent"); + s.line(O - 6, 266, H + 56, 266, "accent", { arrow: true }); + s.chip(H, 266, 108, L.chipToken, "accent", 10); + s.text(20, 286, L.tokA, 10, "accent"); + s.text(20, 300, L.tokB, 10, "accent"); + // window + s.dot(O, 322, 3.5); + s.line(O - 6, 322, H + 46, 322, "fg", { arrow: true }); + s.chip(H, 322, 88, L.chipWindow, "fg", 10); + s.text(20, 342, L.winA, 10, "muted"); + s.text(20, 356, L.winB, 10, "muted"); + // send gate and the boundary + stageRow(380, L.send, L.tSend); + s.line(H - 20, 392, H + 20, 392, "fg", { sw: 2 }); + s.line(H - 20, 400, H + 20, 400, "fg", { sw: 2 }); + s.text(20, 418, L.gateA, 10, "muted"); + s.text(20, 432, L.gateB, 10, "muted"); + s.line(H, 404, H, 452, "fg", { arrow: true }); + s.line(20, 458, 320, 458, "fg", { sw: 2 }); + s.text(20, 452, L.outA, 10, "muted"); + s.text(320, 452, L.outB, 10, "muted", { anchor: "end" }); + s.text(H + 8, 474, L.sent, 10, "fg"); + s.text(H, 496, L.roA, 10, "muted", { anchor: "middle" }); + s.text(H, 510, L.roB, 10, "muted", { anchor: "middle" }); + return s.done(); +} + +export const airlock: DiagramModel = { + id: "airlock", + eyebrow: `Mechanism 03 · approval-airlock · artifact ${draft.artifactId}`, + title: "Nothing leaves without a token", + ariaLabel: + "Three lanes, agent, hub, operator, and a boundary marked outside the machine. The agent creates a draft and raises an approval. Only the operator mints the one-use confirmation token and opens the timed send window. The send gate opens to the outside only when both hold. The agent lane never reaches the boundary.", + figcaption: `${L.draft}, ${L.approval}, ${L.send} are the ${stages.length} closed stages. The token and the send window are not stages: they are two conditions on the last edge, and both are operator-only. Every externally visible action is a reviewed, token-gated event.`, + verify: `HUBFLOW_STAGES is a closed set of ${stages.length} in src/data/vocab.ts · no send event precedes its approval in src/data/dataset.json · the token and window boundary is private control-plane source, checked but not publicly inspectable`, + claims: ["approval-airlock"], + scene: "hub", + wide: wide(), + compact: compact(), +}; diff --git a/src/diagrams/assurance.ts b/src/diagrams/assurance.ts new file mode 100644 index 0000000..5333bb4 --- /dev/null +++ b/src/diagrams/assurance.ts @@ -0,0 +1,41 @@ +/** + * A diagram's badge is the weakest assurance among the manifest claims it + * depicts, so a picture can never promise more than its evidence does. + */ +import { architectureManifest } from "../data/architecture-manifest.ts"; +import type { + ArchitectureAssurance, + ArchitectureClaimId, +} from "../types/architecture.ts"; + +/** Higher is stronger. */ +export const ASSURANCE_RANK: Record = { + operator_attested: 0, + private_source_verified: 1, + explainer_local_verified: 2, + public_source_verified: 3, +}; + +export const ASSURANCE_LABEL: Record = { + operator_attested: "Operator attested", + private_source_verified: "Private source verified", + explainer_local_verified: "Explainer-local verified", + public_source_verified: "Public source verified", +}; + +export function claimAssurance(id: ArchitectureClaimId): ArchitectureAssurance { + const claim = architectureManifest.claims.find((c) => c.id === id); + if (!claim) throw new Error(`claim ${id} is not in the architecture manifest`); + return claim.assurance; +} + +export function assuranceFor( + claims: readonly ArchitectureClaimId[], +): ArchitectureAssurance { + if (claims.length === 0) throw new Error("a diagram must depict at least one claim"); + return claims + .map(claimAssurance) + .reduce((weakest, a) => + ASSURANCE_RANK[a] < ASSURANCE_RANK[weakest] ? a : weakest, + ); +} diff --git a/src/diagrams/closure.ts b/src/diagrams/closure.ts new file mode 100644 index 0000000..0945e0c --- /dev/null +++ b/src/diagrams/closure.ts @@ -0,0 +1,206 @@ +/** + * Mechanism 01: how the console stays honest. Closed vocabularies feed a + * pure seeded generator, one committed dataset is the app's only import, + * three gates fail the build on any escape, the bundle ships without source + * maps, and the release lineage is an orphan tree. + */ +import { sketch } from "./draw.ts"; +import { datasetCounts, seedHex, vocabSizes } from "./facts.ts"; +import type { DiagramModel } from "./model.ts"; + +const v = vocabSizes(); +const n = datasetCounts(); + +const L = { + stores: "bridge-db · personal-ops", + storesSub: "the real stores", + noWire: "no import · no fetch · no MCP", + vocab: "src/data/vocab.ts", + vocabSub: "closed vocabularies", + vocabA: `${v.codenames} codenames · ${v.taskClasses} task classes`, + vocabB: `${v.guardLayers} guard layers · ${v.ruleConcepts} rules`, + gen: "src/data/generate.ts", + genSub: "pure: seed -> data", + genA: `SEED ${seedHex()} · mulberry32`, + genB: "no Date.now, no Math.random", + data: "src/data/dataset.json", + dataSub: "committed artifact", + dataA: `${n.events} events · ${n.activity} activity · ${n.handoffs} handoffs`, + dataB: "the app's only data import", + build: "pnpm build", + buildSub: "vite · sourcemap: false", + buildA: "no build-machine paths", + buildB: "in the shipped bundle", + rel: "release lineage", + relSub: "orphan root · neutral author", + relA: "no merge base with dev, ever", + relB: "log -p scanned, not just tree", + e1a: "may only", + e1b: "emit members", + e2a: "written once,", + e2b: "at authoring", + e3: "bundled", + e4a: "tree copied,", + e4b: "never merged", + gates: "GATES · any one failing fails the build", + gatesA: "the load-bearing gate is closure,", + gatesB: "not the scanner", + closure: "closure.test.ts", + closureA: "every value in its allowlist", + closureB: "cost deltas sum to session cost", + closureC: "byte-identical · plants must fail", + prop: "property.test.ts", + propA: "closure holds across", + propB: "sampled seeds", + scan: "scripts/guard-scan.ts", + scanA: "source + dataset + dist", + scanB: "self-test: planted violations first", + scanC: "forbidden names stored as hashes", +}; + +function wide() { + const s = sketch("0 0 1120 350"); + // the real stores: present, and not wired + s.rect(472, 12, 176, 46, { fill: "none", stroke: "muted", dash: "4 4" }); + s.text(560, 31, L.stores, 11, "muted", { anchor: "middle", bold: true }); + s.text(560, 47, L.storesSub, 10, "muted", { anchor: "middle" }); + s.line(560, 58, 560, 92, "muted", { dash: "3 4" }); + s.line(553, 100, 567, 114, "muted", { sw: 1.5 }); + s.line(567, 100, 553, 114, "muted", { sw: 1.5 }); + s.text(576, 111, L.noWire, 10, "muted"); + + const stations: [number, string, string, string, string][] = [ + [0, L.vocab, L.vocabSub, L.vocabA, L.vocabB], + [236, L.gen, L.genSub, L.genA, L.genB], + [472, L.data, L.dataSub, L.dataA, L.dataB], + [708, L.build, L.buildSub, L.buildA, L.buildB], + [944, L.rel, L.relSub, L.relA, L.relB], + ]; + for (const [x, title, sub, a, b] of stations) { + s.station(x, 140, 176, 64, title, sub); + s.text(x + 88, 224, a, 9.5, "muted", { anchor: "middle" }); + s.text(x + 88, 238, b, 9.5, "muted", { anchor: "middle" }); + } + const edges: [number, string, string | null][] = [ + [178, L.e1a, L.e1b], + [414, L.e2a, L.e2b], + [650, L.e3, null], + [886, L.e4a, L.e4b], + ]; + for (const [x, a, b] of edges) { + s.line(x, 172, x + 54, 172, "fg", { arrow: true }); + if (b) { + s.text(x + 28, 118, a, 10, "muted", { anchor: "middle" }); + s.text(x + 28, 130, b, 10, "muted", { anchor: "middle" }); + } else { + s.text(x + 28, 130, a, 10, "muted", { anchor: "middle" }); + } + } + + // gates hanging off the dataset and the build + s.line(560, 248, 560, 262, "line"); + s.line(440, 262, 650, 262, "line"); + s.line(440, 262, 440, 278, "line"); + s.line(650, 262, 650, 278, "line"); + s.line(796, 248, 796, 262, "line"); + s.line(796, 262, 870, 262, "line"); + s.line(870, 262, 870, 278, "line"); + + s.rect(330, 278, 220, 66, { stroke: "accent", sw: 1.5 }); + s.text(440, 298, L.closure, 11.5, "accent", { anchor: "middle", bold: true }); + s.text(440, 314, L.closureA, 10, "muted", { anchor: "middle" }); + s.text(440, 327, L.closureB, 10, "muted", { anchor: "middle" }); + s.text(440, 340, L.closureC, 10, "muted", { anchor: "middle" }); + s.rect(570, 278, 160, 66); + s.text(650, 298, L.prop, 11.5, "fg", { anchor: "middle", bold: true }); + s.text(650, 314, L.propA, 10, "muted", { anchor: "middle" }); + s.text(650, 327, L.propB, 10, "muted", { anchor: "middle" }); + s.rect(750, 278, 240, 66); + s.text(870, 298, L.scan, 11.5, "fg", { anchor: "middle", bold: true }); + s.text(870, 314, L.scanA, 10, "muted", { anchor: "middle" }); + s.text(870, 327, L.scanB, 10, "muted", { anchor: "middle" }); + s.text(870, 340, L.scanC, 10, "muted", { anchor: "middle" }); + s.text(0, 300, L.gates, 10, "muted"); + s.text(0, 314, L.gatesA, 10, "muted"); + s.text(0, 328, L.gatesB, 10, "muted"); + return s.done(); +} + +/** + * Phone layout. Drawn at the label floor useVizScale applies on a 320px + * viewport (about 11.8 units), so every line is 14 units apart and at most + * 44 glyphs wide. + */ +function compact() { + const s = sketch("0 0 340 910"); + s.rect(20, 6, 300, 40, { fill: "none", stroke: "muted", dash: "4 4" }); + s.text(170, 22, L.stores, 10.5, "muted", { anchor: "middle", bold: true }); + s.text(170, 37, L.storesSub, 10, "muted", { anchor: "middle" }); + s.line(170, 46, 170, 58, "muted", { dash: "3 4" }); + s.line(164, 62, 176, 74, "muted", { sw: 1.5 }); + s.line(176, 62, 164, 74, "muted", { sw: 1.5 }); + s.text(170, 90, L.noWire, 10, "muted", { anchor: "middle" }); + + const station = (y: number, title: string, sub: string, a: string, b: string) => { + s.rect(20, y, 300, 72); + s.text(170, y + 19, title, 11, "fg", { anchor: "middle", bold: true }); + s.text(170, y + 34, sub, 10, "muted", { anchor: "middle" }); + s.text(170, y + 48, a, 10, "muted", { anchor: "middle" }); + s.text(170, y + 62, b, 10, "muted", { anchor: "middle" }); + }; + const edge = (y: number, a: string, b: string | null) => { + s.line(170, y, 170, y + 32, "fg", { arrow: true }); + if (b) { + s.text(182, y + 13, a, 10, "muted"); + s.text(182, y + 27, b, 10, "muted"); + } else { + s.text(182, y + 20, a, 10, "muted"); + } + }; + station(102, L.vocab, L.vocabSub, L.vocabA, L.vocabB); + edge(176, L.e1a, L.e1b); + station(210, L.gen, L.genSub, L.genA, L.genB); + edge(284, L.e2a, L.e2b); + station(318, L.data, L.dataSub, L.dataA, L.dataB); + // gates sit on the way from the dataset to the build + s.line(170, 392, 170, 404, "line"); + s.text(20, 420, L.gates, 10, "muted"); + s.text(20, 434, L.gatesA, 10, "muted"); + s.text(20, 448, L.gatesB, 10, "muted"); + s.rect(20, 460, 300, 72, { stroke: "accent", sw: 1.5 }); + s.text(170, 479, L.closure, 11, "accent", { anchor: "middle", bold: true }); + s.text(170, 494, L.closureA, 10, "muted", { anchor: "middle" }); + s.text(170, 508, L.closureB, 10, "muted", { anchor: "middle" }); + s.text(170, 522, L.closureC, 10, "muted", { anchor: "middle" }); + s.rect(20, 542, 300, 58); + s.text(170, 561, L.prop, 11, "fg", { anchor: "middle", bold: true }); + s.text(170, 576, L.propA, 10, "muted", { anchor: "middle" }); + s.text(170, 590, L.propB, 10, "muted", { anchor: "middle" }); + s.rect(20, 610, 300, 72); + s.text(170, 629, L.scan, 11, "fg", { anchor: "middle", bold: true }); + s.text(170, 644, L.scanA, 10, "muted", { anchor: "middle" }); + s.text(170, 658, L.scanB, 10, "muted", { anchor: "middle" }); + s.text(170, 672, L.scanC, 10, "muted", { anchor: "middle" }); + s.line(170, 682, 170, 694, "line"); + edge(694, L.e3, null); + station(728, L.build, L.buildSub, L.buildA, L.buildB); + edge(802, L.e4a, L.e4b); + station(836, L.rel, L.relSub, L.relA, L.relB); + return s.done(); +} + +export const closure: DiagramModel = { + id: "closure", + eyebrow: "Mechanism 01 · public-release-closure · deterministic-synthetic-replay", + title: "How the console stays honest", + ariaLabel: + "Closed vocabularies feed a pure seeded generator that writes one committed dataset; the app imports only that file, three gates fail the build on any escape, the bundle ships without source maps, and the release lineage is an orphan tree. The real stores have no wire into the app.", + figcaption: + "Every shipped value is a member of an audited finite set by construction: the generator can only emit vocabulary members, the closure test proves nothing escaped, and the app has no wire to the real stores. The scanner is a backstop for mistakes in the allowlists themselves.", + verify: + "pnpm test (closure, property, contrast, manifest) · pnpm guard · pnpm build, then inspect dist for .map files", + claims: ["public-release-closure", "deterministic-synthetic-replay"], + scene: "coda", + wide: wide(), + compact: compact(), +}; diff --git a/src/diagrams/draw.ts b/src/diagrams/draw.ts new file mode 100644 index 0000000..2c01266 --- /dev/null +++ b/src/diagrams/draw.ts @@ -0,0 +1,127 @@ +/** + * A tiny builder so a diagram model reads as a drawing, not as a pile of + * object literals. Within each kind, primitives paint in call order; across + * kinds both renderers paint lines, paths, rects, circles, then texts. + */ +import type { Anchor, Geometry, Tone } from "./model.ts"; + +export interface Sketch { + rect( + x: number, + y: number, + w: number, + h: number, + opts?: { fill?: Tone | "none"; stroke?: Tone | "none"; sw?: number; dash?: string }, + ): void; + line( + x1: number, + y1: number, + x2: number, + y2: number, + tone?: Tone, + opts?: { arrow?: boolean; dash?: string; sw?: number }, + ): void; + path(d: string, tone?: Tone, opts?: { arrow?: boolean; dash?: string }): void; + dot(cx: number, cy: number, r: number, tone?: Tone): void; + text( + x: number, + y: number, + text: string, + size: number, + tone?: Tone, + opts?: { anchor?: Anchor; bold?: boolean; tracking?: number }, + ): void; + /** A titled box: title centred in bold, an optional muted subtitle below. */ + station( + x: number, + y: number, + w: number, + h: number, + title: string, + sub?: string, + opts?: { accent?: boolean; titleSize?: number; subSize?: number }, + ): void; + /** A small status chip sitting on a lane. */ + chip(cx: number, cy: number, w: number, label: string, tone?: Tone, size?: number): void; + done(): Geometry; +} + +export function sketch(viewBox: string): Sketch { + const g: Geometry = { viewBox, rects: [], lines: [], paths: [], circles: [], texts: [] }; + const s: Sketch = { + rect(x, y, w, h, opts = {}) { + g.rects.push({ + x, + y, + w, + h, + fill: opts.fill ?? "raised", + stroke: opts.stroke ?? "line", + ...(opts.sw !== undefined ? { strokeWidth: opts.sw } : {}), + ...(opts.dash ? { dash: opts.dash } : {}), + }); + }, + line(x1, y1, x2, y2, tone = "fg", opts = {}) { + g.lines.push({ + x1, + y1, + x2, + y2, + stroke: tone, + ...(opts.sw !== undefined ? { strokeWidth: opts.sw } : {}), + ...(opts.dash ? { dash: opts.dash } : {}), + ...(opts.arrow ? { arrow: true } : {}), + }); + }, + path(d, tone = "fg", opts = {}) { + g.paths.push({ + d, + stroke: tone, + ...(opts.dash ? { dash: opts.dash } : {}), + ...(opts.arrow ? { arrow: true } : {}), + }); + }, + dot(cx, cy, r, tone = "fg") { + g.circles.push({ cx, cy, r, fill: tone }); + }, + text(x, y, text, size, tone = "fg", opts = {}) { + g.texts.push({ + x, + y, + text, + size, + tone, + ...(opts.anchor ? { anchor: opts.anchor } : {}), + ...(opts.bold ? { bold: true } : {}), + ...(opts.tracking !== undefined ? { tracking: opts.tracking } : {}), + }); + }, + station(x, y, w, h, title, sub, opts = {}) { + const accent = opts.accent ?? false; + s.rect(x, y, w, h, accent ? { stroke: "accent", sw: 1.5 } : {}); + const cx = x + w / 2; + const titleSize = opts.titleSize ?? 12; + const subSize = opts.subSize ?? 10; + if (sub) { + s.text(cx, y + h / 2 - 4, title, titleSize, accent ? "accent" : "fg", { + anchor: "middle", + bold: true, + }); + s.text(cx, y + h / 2 + 14, sub, subSize, "muted", { anchor: "middle" }); + } else { + s.text(cx, y + h / 2 + 4, title, titleSize, accent ? "accent" : "fg", { + anchor: "middle", + bold: true, + }); + } + }, + chip(cx, cy, w, label, tone = "fg", size = 10) { + s.rect(cx - w / 2, cy - 10, w, 20, { fill: "bg", stroke: tone }); + s.text(cx, cy + 4, label, size, tone, { anchor: "middle" }); + }, + done() { + return g; + }, + }; + return s; +} diff --git a/src/diagrams/facts.test.ts b/src/diagrams/facts.test.ts new file mode 100644 index 0000000..3bb7e21 --- /dev/null +++ b/src/diagrams/facts.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { SEED } from "../data/generate.ts"; +import { ASSURANCE_RANK, assuranceFor } from "./assurance.ts"; +import { + datasetCounts, + fmtGap, + fmtT, + freshnessTicks, + guardEvents, + guardRows, + handoffStageTimes, + heroHandoff, + hubflowStages, + seedHex, + shippedActivity, + sourceRevision, + vocabSizes, +} from "./facts.ts"; + +/** + * Golden facts the diagrams print. These are pinned on purpose: regenerating + * the dataset with a different story must fail here, loudly and by name, + * rather than let a diagram drift away from the replay it describes. + */ +describe("diagram facts", () => { + it("formats clock times the way the console does", () => { + expect(fmtT(1500)).toBe("T+1.5 s"); + expect(fmtT(40000)).toBe("T+40.0 s"); + expect(fmtGap(3000, 18500)).toBe("15.5 s"); + }); + + it("pins the hero handoff's five stages and its row", () => { + expect(handoffStageTimes(1)).toEqual({ + dispatch: 1500, + snapshot: 2200, + pickup: 3000, + receipt: 18500, + clear: 19000, + }); + const row = heroHandoff(); + expect(row.projectName).toBe("Corveth"); + expect(row.phase).toBe("implement"); + expect(row.status).toBe("cleared"); + expect(row.claimedBy).toBe("cc"); + expect(row.dispatchedFrom).toBe("claude_ai"); + }); + + it("pins the receipt chain: SHIPPED activity, ship event, downstream ref", () => { + const { ship, activity, revealAt } = shippedActivity(); + expect(activity.summary).toBe("shipped the export pipeline"); + expect(activity.tags).toEqual(["SHIPPED"]); + expect(revealAt).toBe(17400); + expect(ship.at).toBe(18000); + expect(ship.downstreamRef).toBe("buildlog/corveth"); + }); + + it("pins the one guard event and the six-row guard map", () => { + expect(guardEvents()).toHaveLength(1); + expect(guardEvents()[0]).toMatchObject({ + at: 11000, + layer: "hard-deny", + ruleConcept: "push-to-main", + adaptation: "opened-a-branch", + }); + expect(guardRows()).toEqual([ + { rule: "push-to-main", layer: "hard-deny", adaptation: "opened-a-branch" }, + { rule: "credential-read", layer: "deny-list", adaptation: "escalated-to-operator" }, + { rule: "non-local-db-write", layer: "pretooluse-hook", adaptation: "reworded" }, + { rule: "harness-self-mutate", layer: "hard-deny", adaptation: "escalated-to-operator" }, + { rule: "deep-home-delete", layer: "pretooluse-hook", adaptation: "rerouted" }, + { rule: "unverified-complete", layer: "verify-gate", adaptation: "ran-verify-first" }, + ]); + }); + + it("pins the freshness ticks and the three airlock stages", () => { + expect(freshnessTicks().map((e) => [e.at, e.spoke, e.state])).toEqual([ + [10000, "bridge", "fresh"], + [20000, "event-bus", "fresh"], + [35000, "auditor", "aging"], + [50000, "overlay", "stale"], + [65000, "evals-ledger", "fresh"], + [70000, "auditor", "fresh"], + ]); + expect(hubflowStages().map((e) => [e.at, e.stage, e.artifactId])).toEqual([ + [40000, "draft", "draft-71"], + [42000, "approval", "draft-71"], + [44000, "send", "draft-71"], + ]); + }); + + it("pins the counts, the vocabulary sizes, and the seed", () => { + expect(datasetCounts()).toEqual({ events: 46, activity: 8, handoffs: 2 }); + expect(vocabSizes()).toEqual({ + codenames: 24, + taskClasses: 8, + guardLayers: 7, + ruleConcepts: 6, + adaptations: 5, + spokes: 5, + }); + expect(seedHex()).toBe("0x0fe7a123"); + expect(Number(seedHex())).toBe(SEED); + }); + + it("only claims sourcemaps are off because vite.config.ts says so", () => { + const config = readFileSync("vite.config.ts", "utf8"); + expect(config).toMatch(/sourcemap:\s*false/); + }); + + it("shortens a pinned public source revision for a verify line", () => { + expect(sourceRevision("bridge-db-public-source")).toMatch(/^[0-9a-f]{7}$/); + expect(() => sourceRevision("operator-practice-attestation")).toThrow(); + }); + + it("badges a diagram with the weakest claim it depicts", () => { + expect(assuranceFor(["bridge-sqlite-spine"])).toBe("public_source_verified"); + expect( + assuranceFor(["public-release-closure", "deterministic-synthetic-replay"]), + ).toBe("explainer_local_verified"); + expect( + assuranceFor(["bridge-sqlite-spine", "isolated-worktree-fanout"]), + ).toBe("operator_attested"); + expect(ASSURANCE_RANK.operator_attested).toBeLessThan( + ASSURANCE_RANK.private_source_verified, + ); + }); +}); diff --git a/src/diagrams/facts.ts b/src/diagrams/facts.ts new file mode 100644 index 0000000..6bf995d --- /dev/null +++ b/src/diagrams/facts.ts @@ -0,0 +1,115 @@ +/** + * The only source of numbers for the diagrams. Everything here derives from + * the shipped dataset, the closed vocabularies, and the manifest, so a + * diagram cannot print a figure the artifact does not carry. + */ +import { architectureManifest } from "../data/architecture-manifest.ts"; +import { dataset } from "../data/dataset.ts"; +import { + ADAPTATIONS, + CODENAMES, + GUARD_LAYERS, + GUARD_MAP, + RULE_CONCEPTS, + SPOKES, + TASK_CLASSES, +} from "../data/vocab.ts"; +import type { SyntheticEvent } from "../types/data.ts"; + +type HandoffEvent = Extract; +type GuardEvent = Extract; +type FreshnessEvent = Extract; +type HubflowEvent = Extract; +type ShipEvent = Extract; + +/** `1500` -> `T+1.5 s`; `40000` -> `T+40.0 s`. */ +export function fmtT(ms: number): string { + return `T+${(ms / 1000).toFixed(1)} s`; +} + +/** Whole seconds between two clock times, one decimal: `15.5 s`. */ +export function fmtGap(fromMs: number, toMs: number): string { + return `${((toMs - fromMs) / 1000).toFixed(1)} s`; +} + +export function handoffEvents(handoffId: number): HandoffEvent[] { + return dataset.events.filter( + (e): e is HandoffEvent => e.kind === "handoff" && e.handoffId === handoffId, + ); +} + +/** Stage -> clock ms for one handoff, in stage order. */ +export function handoffStageTimes( + handoffId: number, +): Record { + const out = {} as Record; + for (const e of handoffEvents(handoffId)) out[e.stage] = e.at; + return out; +} + +export function heroHandoff() { + const id = 1; + const row = dataset.handoffs.find((h) => h.id === id); + if (!row) throw new Error("handoff 1 missing from the dataset"); + return row; +} + +export function shippedActivity() { + const ship = dataset.events.find((e): e is ShipEvent => e.kind === "ship"); + if (!ship) throw new Error("no ship event in the dataset"); + const activity = dataset.activity.find((a) => a.id === ship.activityId); + if (!activity) throw new Error("ship event points at a missing activity row"); + const reveal = dataset.events.find( + (e) => e.kind === "activity" && e.activityId === ship.activityId, + ); + if (!reveal) throw new Error("shipped activity has no reveal event"); + return { ship, activity, revealAt: reveal.at }; +} + +export function guardEvents(): GuardEvent[] { + return dataset.events.filter((e): e is GuardEvent => e.kind === "guard"); +} + +export function freshnessTicks(): FreshnessEvent[] { + return dataset.events.filter((e): e is FreshnessEvent => e.kind === "freshness"); +} + +export function hubflowStages(): HubflowEvent[] { + return dataset.events.filter((e): e is HubflowEvent => e.kind === "hubflow"); +} + +export function datasetCounts() { + return { + events: dataset.events.length, + activity: dataset.activity.length, + handoffs: dataset.handoffs.length, + }; +} + +export function vocabSizes() { + return { + codenames: CODENAMES.length, + taskClasses: TASK_CLASSES.length, + guardLayers: GUARD_LAYERS.length, + ruleConcepts: RULE_CONCEPTS.length, + adaptations: ADAPTATIONS.length, + spokes: SPOKES.length, + }; +} + +/** The generator seed as the dataset carries it, printed the way generate.ts spells it. */ +export function seedHex(): string { + return `0x${dataset.meta.seed.toString(16).padStart(8, "0")}`; +} + +/** Rule concept -> the layer that catches it and the adaptation that follows, in vocab order. */ +export function guardRows() { + return RULE_CONCEPTS.map((rule) => ({ rule, ...GUARD_MAP[rule] })); +} + +/** Short revision of a public manifest source, for a verify line. */ +export function sourceRevision(sourceId: string): string { + const source = architectureManifest.sources.find((s) => s.id === sourceId); + if (!source?.revision) throw new Error(`source ${sourceId} has no pinned revision`); + return source.revision.slice(0, 7); +} diff --git a/src/diagrams/freshness.ts b/src/diagrams/freshness.ts new file mode 100644 index 0000000..1d525c6 --- /dev/null +++ b/src/diagrams/freshness.ts @@ -0,0 +1,177 @@ +/** + * Mechanism 04: alerts fire only from fresh data. Each spoke keeps its own + * system of record; the hub reads it through an adapter and classifies + * freshness. The alert edge leaves the fresh state and no other. + */ +import { dataset } from "../data/dataset.ts"; +import { FRESHNESS_STATES, SPOKES } from "../data/vocab.ts"; +import { sketch } from "./draw.ts"; +import { fmtT, freshnessTicks } from "./facts.ts"; +import type { DiagramModel } from "./model.ts"; + +const ticks = freshnessTicks(); + +/** One fixture line per spoke, derived from its ticks in clock order. */ +function fixtureLines(): string[] { + return SPOKES.map((spoke) => { + const own = ticks.filter((t) => t.spoke === spoke); + const path = own.map((t) => `${t.state} ${fmtT(t.at)}`).join(" -> "); + const last = own.at(-1); + const dark = + last && last.state !== "fresh" + ? ` · dark to ${fmtT(dataset.meta.sessionLengthMs)}` + : ""; + return `${spoke}: ${path}${dark}`; + }); +} + +const L = { + hubSpoke: "HUB AND SPOKE", + ownStore: "own store", + adapterA: "each read through", + adapterB: "an adapter", + hub: "HUB", + hubA: "reads spokes", + hubB: "classifies freshness", + neverA: "never calls an external service", + neverB: "its one cross-system write:", + neverC: "activity into the spine, fire and forget", + perSpoke: "per spoke", + machine: "FRESHNESS STATE MACHINE · one per spoke", + tick: "tick arrives", + age: "age passes", + newTick: "new tick", + alert: "ALERT eligible", + onlyExit: "the only exit", + quietA: "goes quiet", + quietB: "never lies", + fixture: "FIXTURE", + lines: fixtureLines(), +}; + +const state = (name: (typeof FRESHNESS_STATES)[number]) => { + if (!FRESHNESS_STATES.includes(name)) throw new Error(`unknown state ${name}`); + return name; +}; + +function wide() { + const s = sketch("0 0 1120 400"); + s.text(0, 22, L.hubSpoke, 10, "muted", { tracking: 2 }); + SPOKES.forEach((spoke, i) => { + const y = 40 + i * 64; + s.rect(0, y, 150, 40); + s.text(12, y + 17, spoke, 11, "fg", { bold: true }); + s.text(12, y + 31, L.ownStore, 10, "muted"); + }); + const hubY = [170, 180, 188, 196, 206]; + SPOKES.forEach((_, i) => { + s.line(152, 60 + i * 64, 326, hubY[i] ?? 188, "fg", { arrow: true }); + }); + s.text(200, 96, L.adapterA, 10, "muted"); + s.text(200, 110, L.adapterB, 10, "muted"); + s.rect(330, 150, 150, 76, { stroke: "fg" }); + s.text(405, 180, L.hub, 12, "fg", { anchor: "middle", bold: true }); + s.text(405, 198, L.hubA, 10, "muted", { anchor: "middle" }); + s.text(405, 212, L.hubB, 10, "muted", { anchor: "middle" }); + s.text(330, 252, L.neverA, 10, "muted"); + s.text(330, 266, L.neverB, 10, "muted"); + s.text(330, 280, L.neverC, 10, "muted"); + s.line(482, 188, 560, 188, "fg", { arrow: true }); + s.text(521, 178, L.perSpoke, 10, "muted", { anchor: "middle" }); + // state machine + s.text(600, 22, L.machine, 10, "muted", { tracking: 2 }); + s.rect(600, 60, 130, 40); + s.text(665, 85, state("unavailable"), 12, "fg", { anchor: "middle" }); + s.rect(860, 60, 130, 40, { stroke: "accent", sw: 1.5 }); + s.text(925, 85, state("fresh"), 12, "accent", { anchor: "middle", bold: true }); + s.rect(860, 220, 130, 40); + s.text(925, 245, state("aging"), 12, "fg", { anchor: "middle" }); + s.rect(600, 220, 130, 40); + s.text(665, 245, state("stale"), 12, "fg", { anchor: "middle" }); + s.line(732, 80, 856, 80, "fg", { arrow: true }); + s.text(794, 70, L.tick, 10, "muted", { anchor: "middle" }); + s.line(925, 102, 925, 216, "fg", { arrow: true }); + s.text(934, 164, L.age, 10, "muted"); + s.line(858, 240, 734, 240, "fg", { arrow: true }); + s.text(796, 230, L.age, 10, "muted", { anchor: "middle" }); + s.line(905, 218, 905, 104, "fg", { dash: "3 3", arrow: true }); + s.text(898, 164, L.newTick, 10, "muted", { anchor: "end" }); + s.path("M 665 218 L 665 130 L 856 90", "fg", { dash: "3 3", arrow: true }); + s.text(672, 150, L.newTick, 10, "muted"); + s.line(992, 80, 1110, 80, "accent", { sw: 1.5, arrow: true }); + s.text(1050, 70, L.alert, 10, "accent", { anchor: "middle", bold: true }); + s.text(1050, 100, L.onlyExit, 10, "muted", { anchor: "middle" }); + s.text(665, 284, L.quietA, 10, "muted", { anchor: "middle" }); + s.text(665, 298, L.quietB, 10, "muted", { anchor: "middle" }); + // fixture + s.text(600, 322, L.fixture, 10, "muted", { tracking: 2 }); + L.lines.forEach((line, i) => s.text(600, 338 + i * 13, line, 10, "muted")); + return s.done(); +} + +/** Phone layout: spokes and hub on top, the state cycle beneath, fixture last. */ +function compact() { + const s = sketch("0 0 340 668"); + s.text(20, 20, L.hubSpoke, 10, "muted"); + SPOKES.forEach((spoke, i) => { + const y = 34 + i * 40; + s.rect(20, y, 130, 32); + s.text(28, y + 13, spoke, 10, "fg", { bold: true }); + s.text(28, y + 27, L.ownStore, 10, "muted"); + s.line(152, y + 16, 176, 132, "fg", { arrow: true }); + }); + s.rect(180, 100, 150, 64, { stroke: "fg" }); + s.text(255, 122, L.hub, 11, "fg", { anchor: "middle", bold: true }); + s.text(255, 137, L.hubA, 10, "muted", { anchor: "middle" }); + s.text(255, 151, L.hubB, 10, "muted", { anchor: "middle" }); + s.line(255, 166, 255, 216, "fg", { arrow: true }); + s.text(262, 194, L.perSpoke, 10, "muted"); + s.text(180, 240, L.adapterA, 10, "muted"); + s.text(180, 254, L.adapterB, 10, "muted"); + s.text(20, 276, L.neverA, 10, "muted"); + s.text(20, 290, L.neverB, 10, "muted"); + s.text(20, 304, L.neverC, 10, "muted"); + // the cycle: fresh top-left so its exit has room above it + s.text(20, 322, L.machine, 10, "muted"); + s.line(85, 378, 85, 338, "accent", { sw: 1.5, arrow: true }); + s.text(92, 346, L.alert, 10, "accent", { bold: true }); + s.text(92, 360, L.onlyExit, 10, "muted"); + s.text(255, 376, L.tick, 10, "muted", { anchor: "middle" }); + s.rect(20, 382, 130, 36, { stroke: "accent", sw: 1.5 }); + s.text(85, 405, state("fresh"), 11, "accent", { anchor: "middle", bold: true }); + s.rect(190, 382, 130, 36); + s.text(255, 405, state("unavailable"), 11, "fg", { anchor: "middle" }); + s.rect(20, 482, 130, 36); + s.text(85, 505, state("aging"), 11, "fg", { anchor: "middle" }); + s.rect(190, 482, 130, 36); + s.text(255, 505, state("stale"), 11, "fg", { anchor: "middle" }); + s.line(188, 400, 154, 400, "fg", { arrow: true }); + s.line(118, 420, 118, 478, "fg", { arrow: true }); + s.text(124, 453, L.age, 10, "muted"); + s.line(44, 480, 44, 422, "fg", { dash: "3 3", arrow: true }); + s.text(50, 453, L.newTick, 10, "muted"); + s.line(152, 500, 186, 500, "fg", { arrow: true }); + s.text(169, 536, L.age, 10, "muted", { anchor: "middle" }); + s.path("M 255 480 L 255 442 L 156 408", "fg", { dash: "3 3", arrow: true }); + s.text(262, 460, L.newTick, 10, "muted"); + s.text(255, 536, L.quietA, 10, "muted", { anchor: "middle" }); + s.text(255, 550, L.quietB, 10, "muted", { anchor: "middle" }); + s.text(20, 580, L.fixture, 10, "muted"); + L.lines.forEach((line, i) => s.text(20, 596 + i * 14, line, 10, "muted")); + return s.done(); +} + +export const freshness: DiagramModel = { + id: "freshness", + eyebrow: `Mechanism 04 · freshness-gated-alerts · ${SPOKES.length} spokes`, + title: "Alerts fire only from fresh data", + ariaLabel: `Left: ${SPOKES.length} spokes (${SPOKES.join(", ")}), each its own system of record, read by the hub through an adapter; the hub never calls external services. Right: a ${FRESHNESS_STATES.length}-state machine (${FRESHNESS_STATES.join(", ")}). A tick moves any state to fresh; age moves fresh to aging to stale. Only fresh has an exit labeled alert eligible. In the fixture the overlay spoke goes stale and never recovers.`, + figcaption: + "Each spoke keeps its own system of record; the hub only reads. Because the alert edge leaves the fresh state and no other, an aging or stale spoke goes quiet instead of lying, which is why the overlay light stays dark at the end of the replay.", + verify: + "SPOKES and FRESHNESS_STATES are closed sets in src/data/vocab.ts · ticks from src/data/dataset.json · the alert eligibility boundary is private control-plane source, checked but not publicly inspectable", + claims: ["freshness-gated-alerts"], + scene: "hub", + wide: wide(), + compact: compact(), +}; diff --git a/src/diagrams/guards.ts b/src/diagrams/guards.ts new file mode 100644 index 0000000..cb878a2 --- /dev/null +++ b/src/diagrams/guards.ts @@ -0,0 +1,131 @@ +/** + * Mechanism 06: guards fire, the agent adapts. Seven independent layers from + * outer intent to inner floor; each fabricated would-be action stops at the + * layer that catches it and exits as an adaptation, never as an escalation. + */ +import { GUARD_LAYERS } from "../data/vocab.ts"; +import { sketch } from "./draw.ts"; +import { fmtT, guardEvents, guardRows } from "./facts.ts"; +import type { DiagramModel } from "./model.ts"; + +const rows = guardRows(); +const fixture = guardEvents()[0]; +if (!fixture) throw new Error("the guards diagram needs one guard event in the dataset"); + +const L = { + hAction: "WOULD-BE ACTION · closed set", + hLayer: "LAYER · outer intent to inner floor", + hAdapt: "ADAPTATION · never an escalation", + layer: GUARD_LAYERS.map((layer, i) => `${i + 1} · ${layer}`), + blocked: "BLOCKED", + replay: `replay: ${fmtT(fixture.at)}`, + noRule: "no rule in the map · independent anyway", + floorA: `guards the guards: checks layers 1 to ${GUARD_LAYERS.length - 1}`, + floorB: "are present and untampered", + footA: "Every entering line stops at one layer;", + footB: "nothing passes through to more permission.", + footC: `Only ${fixture.ruleConcept} has a timeline event;`, + footD: `the other ${rows.length - 1} resolve from the static map.`, +}; + +const rulesFor = (layer: (typeof GUARD_LAYERS)[number]) => rows.filter((r) => r.layer === layer); +const isFixture = (rule: string) => rule === fixture.ruleConcept; + +function wide() { + const s = sketch("0 0 1120 400"); + s.text(0, 22, L.hAction, 10, "muted", { tracking: 2 }); + s.text(560, 22, L.hLayer, 10, "muted", { anchor: "middle", tracking: 2 }); + s.text(1120, 22, L.hAdapt, 10, "muted", { anchor: "end", tracking: 2 }); + GUARD_LAYERS.forEach((layer, i) => { + const y = 40 + i * 48; + const cy = y + 15; + const caught = rulesFor(layer); + const isFloor = i === GUARD_LAYERS.length - 1; + const hero = caught.some((r) => isFixture(r.rule)); + s.rect(400, y, 320, 30, hero ? { stroke: "fg" } : {}); + s.text(560, cy + 4, L.layer[i] ?? layer, 11, "fg", { anchor: "middle", bold: hero }); + if (isFloor) { + s.text(756, cy, L.floorA, 10, "muted"); + s.text(756, cy + 14, L.floorB, 10, "muted"); + } else if (caught.length === 0) { + s.text(756, cy + 4, L.noRule, 10, "muted"); + } + caught.forEach((r, j) => { + const offset = caught.length === 1 ? 0 : j === 0 ? -8 : 8; + const ly = cy + offset; + const heroRule = isFixture(r.rule); + const labelY = caught.length === 2 && j === 0 ? ly - 4 : ly + 4; + s.text(0, labelY, r.rule, 11, "fg", { bold: true }); + if (heroRule) s.text(0, ly + 8, L.replay, 9, "accent"); + s.line(150, ly, 392, ly, "fg", heroRule ? { sw: 1.5 } : {}); + s.dot(400, ly, heroRule ? 5 : 4, "accent"); + if (heroRule) s.text(386, ly - 10, L.blocked, 9, "accent", { anchor: "end" }); + s.line(722, ly, 900, ly, "fg", { arrow: true, ...(heroRule ? { sw: 1.5 } : {}) }); + s.text(908, ly + 4, r.adaptation, 11, "fg", { bold: heroRule }); + }); + }); + s.text(0, 384, L.footA, 10, "muted"); + s.text(0, 398, L.footB, 10, "muted"); + s.text(560, 384, L.footC, 10, "muted"); + s.text(560, 398, L.footD, 10, "muted"); + return s.done(); +} + +/** Phone layout: each layer is a bar; the rules it catches are listed beneath it. */ +function compact() { + const s = sketch("0 0 340 690"); + s.text(20, 16, L.hAction, 10, "muted"); + s.text(20, 30, L.hLayer, 10, "muted"); + s.text(20, 44, L.hAdapt, 10, "muted"); + let y = 60; + GUARD_LAYERS.forEach((layer, i) => { + const caught = rulesFor(layer); + const isFloor = i === GUARD_LAYERS.length - 1; + const hero = caught.some((r) => isFixture(r.rule)); + s.rect(20, y, 300, 26, hero ? { stroke: "fg" } : {}); + s.text(170, y + 17, L.layer[i] ?? layer, 10.5, "fg", { anchor: "middle", bold: hero }); + let ly = y + 26 + 18; + if (isFloor) { + s.text(28, ly, L.floorA, 10, "muted"); + s.text(28, ly + 14, L.floorB, 10, "muted"); + ly += 28; + } else if (caught.length === 0) { + s.text(28, ly, L.noRule, 10, "muted"); + ly += 14; + } + for (const r of caught) { + const heroRule = isFixture(r.rule); + s.dot(28, ly - 4, heroRule ? 4 : 3, "accent"); + s.text(38, ly, r.rule, 10.5, "fg", { bold: true }); + ly += 14; + s.line(38, ly - 4, 58, ly - 4, "fg", { arrow: true }); + s.text(66, ly, r.adaptation, 10, "fg", { bold: heroRule }); + ly += 14; + if (heroRule) { + s.text(38, ly, L.blocked, 10, "accent", { bold: true }); + s.text(106, ly, L.replay, 10, "accent"); + ly += 14; + } + } + y = ly + 4; + }); + s.text(20, y + 8, L.footA, 10, "muted"); + s.text(20, y + 22, L.footB, 10, "muted"); + s.text(20, y + 36, L.footC, 10, "muted"); + s.text(20, y + 50, L.footD, 10, "muted"); + return s.done(); +} + +export const guards: DiagramModel = { + id: "guards", + eyebrow: `Mechanism 06 · layered-operation-guards · ${GUARD_LAYERS.length} layers, ${rows.length} rules`, + title: "Guards fire, the agent adapts", + ariaLabel: `${GUARD_LAYERS.length} guard layers stacked from outer intent to inner floor: ${GUARD_LAYERS.join(", ")}. ${rows.length} fabricated would-be actions enter from the left and each stops at the layer that catches it, then exits to the right as an adaptation. No line crosses to a privilege escalation. The integrity floor checks the other ${GUARD_LAYERS.length - 1} are present.`, + figcaption: + "Each layer works alone, so no single failure unlocks the system. A blocked action is a signal to adapt: open a branch, reword, reroute, verify first, or hand the decision to the operator.", + verify: `GUARD_LAYERS (${GUARD_LAYERS.length}), RULE_CONCEPTS (${rows.length}), ADAPTATIONS and GUARD_MAP in src/data/vocab.ts · the guard event at ${fmtT(fixture.at)} in src/data/dataset.json · the layered practice itself is an operator attestation, not public source`, + claims: ["layered-operation-guards"], + scene: "safety", + wide: wide(), + compact: compact(), +}; diff --git a/src/diagrams/index.ts b/src/diagrams/index.ts new file mode 100644 index 0000000..3cf85db --- /dev/null +++ b/src/diagrams/index.ts @@ -0,0 +1,26 @@ +/** + * Registry of the mechanism diagrams. Each model names the scene whose deep + * panel carries it; registry.test.ts binds every claim to the manifest and + * every placement to a scene that declares those claims. + */ +import { airlock } from "./airlock.ts"; +import { closure } from "./closure.ts"; +import { freshness } from "./freshness.ts"; +import { guards } from "./guards.ts"; +import { lease } from "./lease.ts"; +import type { DiagramId, DiagramModel } from "./model.ts"; +import { spineRows } from "./spine-rows.ts"; + +export const DIAGRAMS: Record = { + closure, + lease, + airlock, + freshness, + "spine-rows": spineRows, + guards, +}; + +/** Diagrams in presentation order for a scene's deep panel. */ +export function diagramsFor(scene: DiagramModel["scene"]): DiagramModel[] { + return Object.values(DIAGRAMS).filter((d) => d.scene === scene); +} diff --git a/src/diagrams/lease.ts b/src/diagrams/lease.ts new file mode 100644 index 0000000..7f34b9c --- /dev/null +++ b/src/diagrams/lease.ts @@ -0,0 +1,243 @@ +/** + * Mechanism 02: a handoff is a lease with a receipt. Pickup is the one + * dangerous transition (pending to active), so it hands the claimant a + * one-time completion capability that only clear can consume. The SHIPPED + * tag on the resulting activity row creates a sync obligation downstream. + */ +import { HANDOFF_STAGES } from "../data/vocab.ts"; +import { sketch } from "./draw.ts"; +import { + fmtGap, + fmtT, + handoffStageTimes, + heroHandoff, + shippedActivity, + sourceRevision, +} from "./facts.ts"; +import type { DiagramModel } from "./model.ts"; + +type Stage = (typeof HANDOFF_STAGES)[number]; + +const row = heroHandoff(); +const at = handoffStageTimes(row.id); +const chain = shippedActivity(); + +const L = { + laneA: "CLAUDE.AI", + laneASub: "dispatches · no filesystem", + laneS: "SPINE", + laneSSub: "bridge-db · handoff row", + laneC: "CLAUDE CODE", + laneCSub: "claims · has the repo", + stage: Object.fromEntries(HANDOFF_STAGES.map((st) => [st, st])) as Record, + time: Object.fromEntries(HANDOFF_STAGES.map((st) => [st, fmtT(at[st])])) as Record< + Stage, + string + >, + gap: `${fmtGap(at.pickup, at.receipt)} of mission work`, + gapA: "fan out · guard fires · verify · ship", + gapB: "see The Fleet in Motion", + dispatch: "create_handoff", + dispatchSub: `phase ${row.phase} · ${row.roadmapFile}`, + pending: "pending", + snapA: "reads the latest snapshot", + snapB: "before touching anything", + pickup: "pick_up_handoff", + capA: "completion capability", + capB: "one-time · expires · bearer", + active: `active · ${row.claimedBy}`, + held: "held in the claiming session · never logged", + receipt: "receipt", + symA: "symmetric receipt:", + symB: "both sides acknowledge", + clear: "clear_handoff", + clearSub: "consumes the capability", + cleared: "cleared", + chain: `THE RECEIPT CHAIN · activity row ${chain.activity.id}`, + shipped: chain.activity.summary, + shippedSub: `${fmtT(chain.revealAt)} · ${chain.activity.source} · tag`, + tag: chain.activity.tags[0] ?? "", + creates: "creates", + sync: "sync obligation", + syncSub: "reconciled exactly once", + ship: "ship", + log: `external build log · ${chain.ship.downstreamRef}`, + logSub: `${fmtT(chain.ship.at)} · sync state kept on the row`, +}; + +function wide() { + const s = sketch("0 0 1120 470"); + const col: Record = { + dispatch: 200, + snapshot: 340, + pickup: 480, + receipt: 840, + clear: 980, + }; + for (const st of HANDOFF_STAGES) { + s.text(col[st], 24, L.time[st], 10, "muted", { anchor: "middle" }); + s.text(col[st], 40, L.stage[st], 11, "fg", { anchor: "middle", bold: true }); + } + // the mission gap + s.rect(580, 52, 180, 290, { dash: "4 4" }); + s.text(670, 176, L.gap, 11, "fg", { anchor: "middle", bold: true }); + s.text(670, 194, L.gapA, 10, "muted", { anchor: "middle" }); + s.text(670, 208, L.gapB, 10, "muted", { anchor: "middle" }); + // lanes + s.line(120, 80, 1120, 80, "line"); + s.text(0, 78, L.laneA, 11, "fg", { bold: true }); + s.text(0, 92, L.laneASub, 10, "muted"); + s.line(120, 200, 1120, 200, "fg", { sw: 2 }); + s.text(0, 198, L.laneS, 11, "fg", { bold: true }); + s.text(0, 212, L.laneSSub, 10, "muted"); + s.line(120, 320, 1120, 320, "line"); + s.text(0, 318, L.laneC, 11, "fg", { bold: true }); + s.text(0, 332, L.laneCSub, 10, "muted"); + // dispatch + s.dot(200, 80, 4); + s.line(200, 86, 200, 186, "fg", { arrow: true }); + s.text(208, 122, L.dispatch, 10, "muted"); + s.text(208, 136, L.dispatchSub, 10, "muted"); + s.chip(200, 200, 64, L.pending); + // snapshot + s.dot(340, 200, 4); + s.line(340, 206, 340, 306, "fg", { arrow: true }); + s.text(332, 250, L.snapA, 10, "muted", { anchor: "end" }); + s.text(332, 264, L.snapB, 10, "muted", { anchor: "end" }); + // pickup + s.line(472, 314, 472, 214, "fg", { arrow: true }); + s.text(464, 250, L.pickup, 10, "muted", { anchor: "end" }); + s.line(496, 214, 496, 306, "accent", { arrow: true }); + s.text(504, 236, L.capA, 10, "accent"); + s.text(504, 250, L.capB, 10, "accent"); + s.chip(480, 200, 80, L.active); + // the capability travels with the claimant + s.line(496, 336, 972, 336, "accent", { dash: "2 4" }); + s.text(734, 356, L.held, 10, "accent", { anchor: "middle" }); + // receipt + s.line(840, 314, 840, 214, "fg", { arrow: true }); + s.line(840, 186, 840, 94, "fg", { arrow: true }); + s.dot(840, 80, 4); + s.text(848, 290, L.receipt, 10, "muted"); + s.text(848, 122, L.symA, 10, "muted"); + s.text(848, 136, L.symB, 10, "muted"); + // clear + s.line(980, 314, 980, 214, "accent", { arrow: true }); + s.text(972, 250, L.clear, 10, "accent", { anchor: "end" }); + s.text(972, 264, L.clearSub, 10, "accent", { anchor: "end" }); + s.chip(980, 200, 64, L.cleared); + // the receipt chain + s.text(0, 396, L.chain, 10, "muted", { tracking: 2 }); + s.rect(120, 408, 300, 46); + s.text(132, 426, L.shipped, 11, "fg", { bold: true }); + s.text(132, 442, L.shippedSub, 10, "muted"); + s.rect(252, 432, 60, 14, { fill: "none", stroke: "accent" }); + s.text(282, 443, L.tag, 9, "accent", { anchor: "middle" }); + s.line(422, 431, 486, 431, "fg", { arrow: true }); + s.text(454, 420, L.creates, 9, "muted", { anchor: "middle" }); + s.rect(490, 408, 190, 46); + s.text(585, 426, L.sync, 11, "fg", { anchor: "middle", bold: true }); + s.text(585, 442, L.syncSub, 10, "muted", { anchor: "middle" }); + s.line(682, 431, 746, 431, "fg", { arrow: true }); + s.text(714, 420, L.ship, 9, "muted", { anchor: "middle" }); + s.rect(750, 408, 300, 46); + s.text(762, 426, L.log, 11, "fg", { bold: true }); + s.text(762, 442, L.logSub, 10, "muted"); + return s.done(); +} + +/** + * Phone layout: the three lanes become three positions on each row (left, + * centre, right); the stages stack down the page as blocks with their notes + * full width beneath, so no lane line ever crosses a label. + */ +function compact() { + const s = sketch("0 0 340 800"); + const A = 64; + const S = 170; + const C = 284; + s.text(A, 22, L.laneA, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(S, 22, L.laneS, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(C, 22, L.laneC, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(20, 38, L.laneASub, 10, "muted"); + s.text(S, 52, L.laneSSub, 10, "muted", { anchor: "middle" }); + s.text(320, 66, L.laneCSub, 10, "muted", { anchor: "end" }); + s.line(20, 76, 320, 76, "line"); + const stageRow = (y: number, st: Stage) => { + s.text(S - 6, y, L.stage[st], 10.5, "fg", { anchor: "end", bold: true }); + s.text(S + 6, y, L.time[st], 10, "muted"); + }; + // dispatch: claude.ai -> spine + stageRow(96, "dispatch"); + s.dot(A, 112, 3.5); + s.line(A + 6, 112, S - 32, 112, "fg", { arrow: true }); + s.chip(S, 112, 60, L.pending, "fg", 10); + s.text(20, 132, L.dispatch, 10, "muted"); + s.text(20, 146, L.dispatchSub, 10, "muted"); + // snapshot: spine -> claude code + stageRow(168, "snapshot"); + s.dot(S, 184, 3.5); + s.line(S + 6, 184, C - 6, 184, "fg", { arrow: true }); + s.text(20, 204, L.snapA, 10, "muted"); + s.text(20, 218, L.snapB, 10, "muted"); + // pickup: claude code -> spine, the capability back + stageRow(240, "pickup"); + s.line(C - 6, 256, S + 44, 256, "fg", { arrow: true }); + s.chip(S, 256, 86, L.active, "fg", 10); + s.text(20, 276, L.pickup, 10, "muted"); + s.line(S + 6, 292, C - 6, 292, "accent", { arrow: true }); + s.text(20, 312, L.capA, 10, "accent"); + s.text(20, 326, L.capB, 10, "accent"); + // the mission gap + s.rect(20, 340, 300, 58, { dash: "4 4" }); + s.text(S, 360, L.gap, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(S, 375, L.gapA, 10, "muted", { anchor: "middle" }); + s.text(S, 389, L.gapB, 10, "muted", { anchor: "middle" }); + s.line(328, 292, 328, 516, "accent", { dash: "2 4" }); + // receipt: both sides + stageRow(420, "receipt"); + s.line(C - 6, 436, S + 6, 436, "fg", { arrow: true }); + s.line(S - 6, 436, A + 6, 436, "fg", { arrow: true }); + s.dot(A, 436, 3.5); + s.text(320, 456, L.receipt, 10, "muted", { anchor: "end" }); + s.text(20, 456, L.symA, 10, "muted"); + s.text(20, 470, L.symB, 10, "muted"); + // clear + stageRow(500, "clear"); + s.line(C - 6, 516, S + 32, 516, "accent", { arrow: true }); + s.chip(S, 516, 60, L.cleared, "fg", 10); + s.text(20, 536, L.clear, 10, "accent"); + s.text(20, 550, L.clearSub, 10, "accent"); + s.text(S, 572, L.held, 10, "accent", { anchor: "middle" }); + // the receipt chain + s.text(20, 600, L.chain, 10, "muted"); + s.rect(20, 612, 300, 46); + s.text(30, 630, L.shipped, 10.5, "fg", { bold: true }); + s.text(30, 646, L.shippedSub, 10, "muted"); + s.rect(246, 634, 64, 16, { fill: "none", stroke: "accent" }); + s.text(278, 646, L.tag, 9.5, "accent", { anchor: "middle" }); + s.line(S, 660, S, 680, "fg", { arrow: true }); + s.text(S + 8, 674, L.creates, 10, "muted"); + s.rect(20, 684, 300, 46); + s.text(S, 702, L.sync, 10.5, "fg", { anchor: "middle", bold: true }); + s.text(S, 718, L.syncSub, 10, "muted", { anchor: "middle" }); + s.line(S, 732, S, 752, "fg", { arrow: true }); + s.text(S + 8, 746, L.ship, 10, "muted"); + s.rect(20, 756, 300, 46); + s.text(30, 774, L.log, 10.5, "fg", { bold: true }); + s.text(30, 790, L.logSub, 10, "muted"); + return s.done(); +} + +export const lease: DiagramModel = { + id: "lease", + eyebrow: `Mechanism 02 · bridge-sqlite-spine · handoff ${row.id}, ${row.projectName}`, + title: "A handoff is a lease with a receipt", + ariaLabel: `Three lanes: claude.ai, the spine, and Claude Code. Dispatch creates a pending handoff row; Claude Code reads the latest snapshot, picks the handoff up and receives a one-time completion capability; ${fmtGap(at.pickup, at.receipt)} of mission work pass; both sides acknowledge a receipt; clear consumes the capability and the row reads cleared. Below, the ${chain.activity.tags[0]} tag on the activity row creates a sync obligation to the external build log.`, + figcaption: `Pickup is the one dangerous transition, pending to active, so it hands the claimant a one-time completion capability that only clear can consume. The ${fmtGap(at.pickup, at.receipt)} gap is real: the Spine scene and the Finale share handoff ${row.id} and mission ${chain.activity.missionId}.`, + verify: `stages and statuses are closed sets in src/data/vocab.ts · times from src/data/dataset.json · capability and retention in the bridge-db README, Tools and Retention, pinned at ${sourceRevision("bridge-db-public-source")}`, + claims: ["bridge-sqlite-spine"], + scene: "spine", + wide: wide(), + compact: compact(), +}; diff --git a/src/diagrams/model.ts b/src/diagrams/model.ts new file mode 100644 index 0000000..51fc044 --- /dev/null +++ b/src/diagrams/model.ts @@ -0,0 +1,102 @@ +/** + * Mechanism diagrams: the data model shared by the in-app figure and the + * docs export. A diagram is pure data (no JSX, so scripts/render-diagrams.ts + * can import it under plain Node). Every number a diagram prints is computed + * in facts.ts from the shipped dataset, the closed vocabularies, and the + * manifest, never typed into a model by hand. + */ +import type { ArchitectureClaimId } from "../types/architecture.ts"; +import type { Lens } from "../types/scene.ts"; + +/** Colour roles; each renderer maps them to its own palette. */ +export type Tone = "fg" | "muted" | "line" | "raised" | "accent" | "bg"; +export type Anchor = "start" | "middle" | "end"; + +export interface Rect { + x: number; + y: number; + w: number; + h: number; + fill: Tone | "none"; + stroke: Tone | "none"; + strokeWidth?: number; + dash?: string; +} +export interface Line { + x1: number; + y1: number; + x2: number; + y2: number; + stroke: Tone; + strokeWidth?: number; + dash?: string; + arrow?: boolean; +} +export interface Path { + d: string; + stroke: Tone; + dash?: string; + arrow?: boolean; +} +export interface Circle { + cx: number; + cy: number; + r: number; + fill: Tone; +} +export interface Text { + x: number; + y: number; + text: string; + size: number; + tone: Tone; + anchor?: Anchor; + bold?: boolean; + /** letter-spacing in user units, for eyebrow-style labels */ + tracking?: number; +} + +/** One layout of a diagram. Paint order: lines, paths, rects, circles, texts, so chips and boxes sit above lanes. */ +export interface Geometry { + viewBox: string; + rects: Rect[]; + lines: Line[]; + paths: Path[]; + circles: Circle[]; + texts: Text[]; +} + +export const DIAGRAM_IDS = [ + "closure", + "lease", + "airlock", + "freshness", + "spine-rows", + "guards", +] as const; +export type DiagramId = (typeof DIAGRAM_IDS)[number]; + +export interface DiagramModel { + id: DiagramId; + /** Mono eyebrow above the title, e.g. "Mechanism 01 · public-release-closure". */ + eyebrow: string; + title: string; + /** The claim the picture makes, for readers who cannot see it. */ + ariaLabel: string; + figcaption: string; + /** What a stranger runs or reads to check the picture. */ + verify: string; + claims: readonly ArchitectureClaimId[]; + /** The scene whose deep panel carries this diagram. */ + scene: Lens; + wide: Geometry; + compact: Geometry; +} + +export type Ground = "deck" | "paper"; +export type Variant = "wide" | "compact"; + +/** Every distinct label a geometry prints, for cross-renderer and cross-variant parity. */ +export function textSet(g: Geometry): Set { + return new Set(g.texts.map((t) => t.text)); +} diff --git a/src/diagrams/placement.test.tsx b/src/diagrams/placement.test.tsx new file mode 100644 index 0000000..da16185 --- /dev/null +++ b/src/diagrams/placement.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createMemoryRouter } from "react-router"; +import { RouterProvider } from "react-router/dom"; +import { describe, expect, it } from "vitest"; +import { SessionClockProvider } from "../clock/SessionClockProvider.tsx"; +import { CodaScene } from "../scenes/CodaScene"; +import { FleetScene } from "../scenes/FleetScene"; +import { HubScene } from "../scenes/HubScene"; +import { SafetyScene } from "../scenes/SafetyScene"; +import { SpineScene } from "../scenes/SpineScene"; +import { DIAGRAMS, diagramsFor } from "./index.ts"; + +function renderScene(path: string, element: React.ReactNode) { + const router = createMemoryRouter( + [{ path, element: {element} }], + { initialEntries: [path] }, + ); + return render(); +} + +const scenes = [ + ["/spine", "spine", ], + ["/hub", "hub", ], + ["/safety", "safety", ], + ["/coda", "coda", ], +] as const; + +describe("mechanism diagram placement", () => { + it.each(scenes)("%s carries its diagrams inside the deep panel", (path, lens, element) => { + renderScene(path, element); + const expected = diagramsFor(lens); + expect(expected.length).toBeGreaterThan(0); + for (const model of expected) { + expect(screen.queryByRole("img", { name: model.ariaLabel })).not.toBeInTheDocument(); + } + fireEvent.click(screen.getByRole("button", { name: /go deeper/i })); + const region = screen.getByRole("region", { name: /go deeper/i }); + for (const model of expected) { + const figure = screen.getByRole("img", { name: model.ariaLabel }); + expect(region).toContainElement(figure); + expect(region).toHaveTextContent(model.title); + expect(region).toHaveTextContent(model.figcaption); + } + }); + + it("leaves the fleet panel without a figure", () => { + renderScene("/fleet", ); + fireEvent.click(screen.getByRole("button", { name: /go deeper/i })); + for (const model of Object.values(DIAGRAMS)) { + expect(screen.queryByRole("img", { name: model.ariaLabel })).not.toBeInTheDocument(); + } + }); +}); diff --git a/src/diagrams/registry.test.ts b/src/diagrams/registry.test.ts new file mode 100644 index 0000000..456e5fd --- /dev/null +++ b/src/diagrams/registry.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { architectureManifest } from "../data/architecture-manifest.ts"; +import { COMPACT_MAX_VIEWPORT, MIN_LABEL_PX } from "../lib/useVizScale.ts"; +import { SCENES } from "../scenes/index.ts"; +import { ASSURANCE_RANK, assuranceFor, claimAssurance } from "./assurance.ts"; +import { DIAGRAMS, diagramsFor } from "./index.ts"; +import { DIAGRAM_IDS, type Geometry, type Text, textSet } from "./model.ts"; + +const NO_EM_DASH = /^[^\u2014]*$/; +const NO_MONEY = /^[^$]*$/; +const NO_HOME_PATH = /^(?!.*(?:\/Users\/|~\/|[A-Za-z]:\\)).*$/s; + +/** + * The narrowest phone the e2e suite exercises is 320px; the scene frame + * leaves the figure about 288px. useVizScale floors every compact label so + * it renders at MIN_LABEL_PX there, and that floored size is what the + * geometry has to survive. + */ +const NARROWEST_FIGURE_PX = 288; +const GLYPH_EM = 0.63; // Space Mono advance width is 0.612em; bold and hinting add a little + +function renderedSize(g: Geometry, t: Text, variant: "wide" | "compact"): number { + if (variant === "wide") return t.size; + const k = NARROWEST_FIGURE_PX / Number(g.viewBox.split(/\s+/)[2]); + const floor = MIN_LABEL_PX / k; + return t.size < floor ? floor : t.size; +} + +function box(g: Geometry, t: Text, variant: "wide" | "compact") { + const size = renderedSize(g, t, variant); + const run = t.text.length * size * GLYPH_EM + (t.tracking ?? 0) * t.text.length; + const left = t.anchor === "end" ? t.x - run : t.anchor === "middle" ? t.x - run / 2 : t.x; + return { left, right: left + run, top: t.y - size * 0.8, bottom: t.y + size * 0.2 }; +} + +const variants = (m: (typeof DIAGRAMS)[keyof typeof DIAGRAMS]) => + [ + ["wide", m.wide], + ["compact", m.compact], + ] as const; + +describe("diagram registry", () => { + it("registers every diagram id exactly once, keyed by its own id", () => { + expect(Object.keys(DIAGRAMS).sort()).toEqual([...DIAGRAM_IDS].sort()); + for (const [key, model] of Object.entries(DIAGRAMS)) expect(model.id).toBe(key); + }); + + it("depicts only manifest claims and wears the weakest one as its badge", () => { + const manifestIds = new Set(architectureManifest.claims.map((c) => c.id)); + for (const model of Object.values(DIAGRAMS)) { + expect(model.claims.length, `${model.id} needs a claim`).toBeGreaterThan(0); + for (const id of model.claims) expect(manifestIds.has(id), `${model.id} -> ${id}`).toBe(true); + const badge = assuranceFor(model.claims); + for (const id of model.claims) { + expect(ASSURANCE_RANK[badge]).toBeLessThanOrEqual(ASSURANCE_RANK[claimAssurance(id)]); + } + } + }); + + it("is placed on a scene that declares the same claims", () => { + for (const model of Object.values(DIAGRAMS)) { + const scene = Object.values(SCENES).find((s) => s.lens === model.scene); + expect(scene, `${model.id} names an unknown scene`).toBeDefined(); + for (const id of model.claims) { + expect(scene?.architectureClaims, `${model.id} on ${model.scene} lacks ${id}`).toContain(id); + } + } + expect(diagramsFor("spine").map((d) => d.id)).toEqual(["lease", "spine-rows"]); + expect(diagramsFor("hub").map((d) => d.id)).toEqual(["airlock", "freshness"]); + expect(diagramsFor("safety").map((d) => d.id)).toEqual(["guards"]); + expect(diagramsFor("coda").map((d) => d.id)).toEqual(["closure"]); + expect(diagramsFor("fleet")).toEqual([]); + expect(diagramsFor("finale")).toEqual([]); + expect(diagramsFor("cold-open")).toEqual([]); + }); + + it("prints exactly the same labels on the phone layout as on the wide one", () => { + for (const model of Object.values(DIAGRAMS)) { + expect([...textSet(model.compact)].sort(), model.id).toEqual([...textSet(model.wide)].sort()); + } + }); + + it("keeps its copy inside the public-safe rules", () => { + for (const model of Object.values(DIAGRAMS)) { + const copy = [ + model.eyebrow, + model.title, + model.ariaLabel, + model.figcaption, + model.verify, + ...model.wide.texts.map((t) => t.text), + ...model.compact.texts.map((t) => t.text), + ]; + for (const s of copy) { + expect(s, `${model.id}: em dash`).toMatch(NO_EM_DASH); + expect(s, `${model.id}: money`).toMatch(NO_MONEY); + expect(s, `${model.id}: home path`).toMatch(NO_HOME_PATH); + expect(s.trim().length, `${model.id}: empty label`).toBeGreaterThan(0); + } + expect(model.wide.viewBox.startsWith("0 0 1120 ")).toBe(true); + expect(model.compact.viewBox.startsWith("0 0 340 ")).toBe(true); + } + }); + + it("keeps every label inside its viewBox at the size it will render", () => { + expect(COMPACT_MAX_VIEWPORT).toBeGreaterThan(NARROWEST_FIGURE_PX); + for (const model of Object.values(DIAGRAMS)) { + for (const [name, g] of variants(model)) { + const [, , w, h] = g.viewBox.split(/\s+/).map(Number); + for (const t of g.texts) { + const b = box(g, t, name); + expect(b.left, `${model.id} ${name}: "${t.text}" runs off the left`).toBeGreaterThanOrEqual(-1); + expect(b.right, `${model.id} ${name}: "${t.text}" runs off the right`).toBeLessThanOrEqual(w + 1); + expect(b.bottom, `${model.id} ${name}: "${t.text}" runs off the bottom`).toBeLessThanOrEqual(h); + expect(b.top, `${model.id} ${name}: "${t.text}" runs off the top`).toBeGreaterThanOrEqual(0); + } + } + } + }); + + it("never prints two labels over each other", () => { + for (const model of Object.values(DIAGRAMS)) { + for (const [name, g] of variants(model)) { + const boxes = g.texts.map((t) => ({ t, b: box(g, t, name) })); + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i]; + const c = boxes[j]; + if (!a || !c) continue; + const overlap = + a.b.left < c.b.right && c.b.left < a.b.right && a.b.top < c.b.bottom && c.b.top < a.b.bottom; + expect(overlap, `${model.id} ${name}: "${a.t.text}" collides with "${c.t.text}"`).toBe(false); + } + } + } + } + }); +}); diff --git a/src/diagrams/render-docs.ts b/src/diagrams/render-docs.ts new file mode 100644 index 0000000..5364d15 --- /dev/null +++ b/src/diagrams/render-docs.ts @@ -0,0 +1,82 @@ +/** + * Generates docs/mechanisms.md from the diagram models so the prose beside + * each picture (caption, verify line, claims, assurance) cannot drift from + * what the app prints. JSX-free so scripts/render-diagrams.ts can run it. + */ +import { architectureManifest } from "../data/architecture-manifest.ts"; +import { ASSURANCE_LABEL, assuranceFor } from "./assurance.ts"; +import type { DiagramModel } from "./model.ts"; + +const SCENE_TITLE: Record = { + "cold-open": "the cold open", + fleet: "The Fleet", + spine: "The Spine", + safety: "The Safety Layers", + finale: "The Fleet in Motion", + hub: "The Hub", + coda: "Coda", +}; + +function section(model: DiagramModel, index: number): string { + const number = String(index + 1).padStart(2, "0"); + return [ + `## ${number} · ${model.title}`, + "", + "", + ` `, + ` ${model.ariaLabel.replaceAll('`, + "", + "", + model.figcaption, + "", + `Verify: ${model.verify}.`, + "", + `Claims: ${model.claims.map((c) => `\`${c}\``).join(", ")}. Assurance: ${ASSURANCE_LABEL[assuranceFor(model.claims)].toLowerCase()}. Scene: ${SCENE_TITLE[model.scene]}.`, + "", + ].join("\n"); +} + +export function renderDocs(models: readonly DiagramModel[]): string { + const policy = Object.entries(architectureManifest.claim_policy) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([assurance, text]) => + `- **${ASSURANCE_LABEL[assurance as keyof typeof ASSURANCE_LABEL]}**: ${text}`, + ) + .join("\n"); + const head = [ + "# Mechanism diagrams", + "", + "", + "", + `${models.length} pictures of how the Operator OS works, drawn from the same closed data the`, + "explainer replays. Each one lives in a scene's \"go deeper\" panel and here, as a", + "static file. This page and the files under `public/diagrams/` are generated from", + "`src/diagrams/` by `pnpm diagrams`; `src/diagrams/render.test.ts` and CI fail if a", + "committed file no longer matches a fresh render, and `src/diagrams/facts.test.ts`", + "fails if a printed figure no longer matches `src/data/dataset.json`.", + "", + "## What a diagram promises", + "", + "Every diagram wears the assurance level of the weakest architecture claim it", + "depicts, taken from", + "[`public/architecture-manifest-v1.json`](../public/architecture-manifest-v1.json):", + "", + policy, + "", + "Every count and clock time on a diagram is computed from the dataset or the", + "vocabularies at build time; the in-app figure and the file here are rendered", + "from one model per diagram, so they cannot say different things.", + "", + ].join("\n"); + const tail = [ + "## Regenerating", + "", + "```sh", + "pnpm diagrams # rewrites public/diagrams/*.svg and this page from src/diagrams/", + "pnpm test # fails if a committed file or a printed fact has drifted", + "```", + "", + ].join("\n"); + return `${head}\n${models.map(section).join("\n")}\n${tail}`; +} diff --git a/src/diagrams/render-string.ts b/src/diagrams/render-string.ts new file mode 100644 index 0000000..ea433f9 --- /dev/null +++ b/src/diagrams/render-string.ts @@ -0,0 +1,120 @@ +/** + * Standalone SVG serializer for the docs export. JSX-free on purpose so + * scripts/render-diagrams.ts runs under Node's type-stripping. Output is + * byte-deterministic: fixed attribute order, no incidental whitespace. + */ +import type { + DiagramModel, + Geometry, + Ground, + Tone, + Variant, +} from "./model.ts"; + +/** Literal colours for the standalone files; render.test.ts pins them to src/index.css. */ +export const PALETTE: Record> = { + deck: { + bg: "#15191e", + fg: "#e9e7df", + muted: "#9a9488", + line: "#2a2f36", + raised: "#1b2027", + accent: "#ff7a4d", + }, + paper: { + bg: "#f4efe4", + fg: "#1a1c20", + muted: "#6b6457", + line: "rgba(26,28,32,0.22)", + raised: "rgba(26,28,32,0.05)", + accent: "#b0451d", + }, +}; + +export const FONT_STACK = "Space Mono, ui-monospace, monospace"; + +export function escapeXml(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** Marker ids are namespaced by diagram so several inline figures can share a page. */ +export function markerId(diagramId: string, tone: Tone): string { + return `${diagramId}-arrow-${tone}`; +} + +/** Tones that need an arrowhead marker, in a stable order. */ +export function arrowTones(g: Geometry): Tone[] { + const tones = new Set(); + for (const l of g.lines) if (l.arrow) tones.add(l.stroke); + for (const p of g.paths) if (p.arrow) tones.add(p.stroke); + return [...tones].sort(); +} + +function num(n: number): string { + return Number.isInteger(n) ? String(n) : String(Number(n.toFixed(2))); +} + +export function renderSvgString( + model: DiagramModel, + variant: Variant, + ground: Ground, +): string { + const g = model[variant]; + const c = PALETTE[ground]; + const paint = (t: Tone | "none") => (t === "none" ? "none" : c[t]); + const [, , vbW, vbH] = g.viewBox.split(/\s+/); + const parts: string[] = []; + + parts.push( + ``, + ); + const markers = arrowTones(g); + if (markers.length > 0) { + parts.push(""); + for (const tone of markers) { + parts.push( + ``, + ); + } + parts.push(""); + } + parts.push(``); + + for (const l of g.lines) { + let s = ``); + } + for (const p of g.paths) { + let s = ``); + } + for (const r of g.rects) { + let s = ``); + } + for (const o of g.circles) { + parts.push( + ``, + ); + } + for (const t of g.texts) { + let s = `${escapeXml(t.text)}`); + } + parts.push(""); + return `${parts.join("\n")}\n`; +} diff --git a/src/diagrams/render.test.ts b/src/diagrams/render.test.ts new file mode 100644 index 0000000..b99dff8 --- /dev/null +++ b/src/diagrams/render.test.ts @@ -0,0 +1,176 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { DIAGRAMS } from "./index.ts"; +import { MechanismFigure } from "./MechanismFigure.tsx"; +import { type DiagramModel, type Geometry, textSet } from "./model.ts"; +import { renderDocs } from "./render-docs.ts"; +import { PALETTE, arrowTones, escapeXml, renderSvgString } from "./render-string.ts"; + +const geometry: Geometry = { + viewBox: "0 0 200 100", + rects: [{ x: 10, y: 10, w: 60, h: 30, fill: "raised", stroke: "line" }], + lines: [ + { x1: 70, y1: 25, x2: 120, y2: 25, stroke: "fg", arrow: true }, + { x1: 70, y1: 60, x2: 120, y2: 60, stroke: "accent", arrow: true, dash: "2 4" }, + ], + paths: [], + circles: [{ cx: 150, cy: 25, r: 4, fill: "accent" }], + texts: [ + { x: 40, y: 30, text: "a & b ", size: 12, tone: "fg", anchor: "middle", bold: true }, + { x: 10, y: 90, text: "footnote", size: 10, tone: "muted" }, + ], +}; + +const fixture: DiagramModel = { + id: "closure", + eyebrow: "Fixture", + title: "Fixture", + ariaLabel: "A fixture diagram", + figcaption: "Caption", + verify: "run the tests", + claims: ["public-release-closure"], + scene: "coda", + wide: geometry, + compact: geometry, +}; + +const ENTITY: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; + +/** `label` contents of any SVG markup, decoded in one pass. */ +export function textsIn(markup: string): Set { + const out = new Set(); + for (const m of markup.matchAll(/]*>([^<]*)<\/text>/g)) { + out.add((m[1] ?? "").replace(/&(?:amp|lt|gt|quot|#x27);/g, (entity) => ENTITY[entity] ?? entity)); + } + return out; +} + +describe("renderSvgString", () => { + it("is byte-stable and self-contained", () => { + const a = renderSvgString(fixture, "wide", "deck"); + const b = renderSvgString(fixture, "wide", "deck"); + expect(a).toBe(b); + expect(a.startsWith(' { + const deck = renderSvgString(fixture, "wide", "deck"); + const paper = renderSvgString(fixture, "wide", "paper"); + expect(deck).toContain('fill="#15191e"'); + expect(deck).toContain('fill="#ff7a4d"'); + expect(paper).toContain('fill="#f4efe4"'); + expect(paper).toContain('fill="#b0451d"'); + expect(deck).not.toBe(paper); + }); + + it("declares an arrow marker only for tones that carry an arrow", () => { + expect(arrowTones(geometry)).toEqual(["accent", "fg"]); + const svg = renderSvgString(fixture, "wide", "deck"); + expect(svg).toContain('id="closure-arrow-fg"'); + expect(svg).toContain('id="closure-arrow-accent"'); + expect(svg).not.toContain('id="closure-arrow-muted"'); + }); + + it("escapes text", () => { + expect(escapeXml('a & b "d"')).toBe("a & b <c> "d""); + expect(textsIn(renderSvgString(fixture, "wide", "deck"))).toEqual( + textSet(geometry), + ); + }); +}); + +describe("MechanismFigure", () => { + it("prints the same labels the docs export prints", () => { + const app = renderToStaticMarkup(createElement(MechanismFigure, { model: fixture })); + expect(textsIn(app)).toEqual(textsIn(renderSvgString(fixture, "wide", "deck"))); + expect(app).toContain('role="img"'); + expect(app).toContain('aria-label="A fixture diagram"'); + expect(app).toContain("Explainer-local verified"); + expect(app).toContain("run the tests"); + }); +}); + +describe("every registered diagram", () => { + it.each(Object.values(DIAGRAMS).map((m) => [m.id, m] as const))( + "%s: the app and the docs export print the same labels, on both grounds", + (_id, model) => { + const app = textsIn(renderToStaticMarkup(createElement(MechanismFigure, { model }))); + expect(app).toEqual(textSet(model.wide)); + for (const ground of ["deck", "paper"] as const) { + expect(textsIn(renderSvgString(model, "wide", ground))).toEqual(textSet(model.wide)); + expect(textsIn(renderSvgString(model, "compact", ground))).toEqual( + textSet(model.compact), + ); + } + }, + ); + + it.each(Object.values(DIAGRAMS).map((m) => [m.id, m] as const))( + "%s: the committed docs files match a fresh render byte for byte", + (id, model) => { + for (const ground of ["deck", "paper"] as const) { + const file = `public/diagrams/${id}-${ground}.svg`; + expect(existsSync(file), `${file} missing: run pnpm diagrams`).toBe(true); + expect(readFileSync(file, "utf8"), `${file} stale: run pnpm diagrams`).toBe( + renderSvgString(model, "wide", ground), + ); + } + }, + ); + + it("ships exactly one file per diagram and ground, nothing orphaned", () => { + const expected = Object.keys(DIAGRAMS) + .flatMap((id) => [`${id}-deck.svg`, `${id}-paper.svg`]) + .sort(); + expect(readdirSync("public/diagrams").sort()).toEqual(expected); + }); + + it("the committed docs page matches a fresh render byte for byte", () => { + const fresh = renderDocs(Object.values(DIAGRAMS)); + expect(readFileSync("docs/mechanisms.md", "utf8"), "run pnpm diagrams").toBe(fresh); + for (const model of Object.values(DIAGRAMS)) { + expect(fresh).toContain(model.figcaption); + expect(fresh).toContain(model.verify); + expect(fresh).toContain(`public/diagrams/${model.id}-deck.svg`); + } + expect(fresh).not.toMatch(/\u2014/); + }); +}); + +describe("the docs palette", () => { + // The contrast gate reads the real stylesheet; the SVG files carry literal + // colours, so they must be the same bytes the stylesheet declares. + const css = readFileSync("src/index.css", "utf8"); + const token = (name: string) => { + const m = new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`).exec(css); + if (!m) throw new Error(`token ${name} not found in src/index.css`); + return m[1]; + }; + it("matches the stylesheet tokens", () => { + expect(PALETTE.deck).toEqual({ + bg: token("--deck"), + fg: token("--ink-deck"), + muted: token("--ink-deck-muted"), + line: token("--deck-line"), + raised: token("--deck-raised"), + accent: token("--accent-deck"), + }); + expect(PALETTE.paper.bg).toBe(token("--paper")); + expect(PALETTE.paper.fg).toBe(token("--ink")); + expect(PALETTE.paper.muted).toBe(token("--ink-muted")); + expect(PALETTE.paper.accent).toBe(token("--accent")); + }); +}); diff --git a/src/diagrams/spine-rows.ts b/src/diagrams/spine-rows.ts new file mode 100644 index 0000000..6ff364f --- /dev/null +++ b/src/diagrams/spine-rows.ts @@ -0,0 +1,144 @@ +/** + * Mechanism 05: one store, five row shapes, fixed writers. Nothing moves + * between systems except through these shapes, each written under a fixed + * caller identity; retention protects tagged rows structurally. + */ +import { dataset } from "../data/dataset.ts"; +import { + ACTIVITY_TAGS, + CALLERS, + COST_SYSTEMS, + SECTION_OWNERS, + SNAP_SYSTEMS, + SOURCE_TRUSTS, +} from "../data/vocab.ts"; +import { sketch } from "./draw.ts"; +import { sourceRevision } from "./facts.ts"; +import type { DiagramModel } from "./model.ts"; + +const dispatchers = [...new Set(dataset.handoffs.map((h) => h.dispatchedFrom))]; +const claimants = [...new Set(dataset.handoffs.map((h) => h.claimedBy).filter(Boolean))]; + +const L = { + taps: [ + { + id: "activity", + sub: "session telemetry, searchable", + w: [CALLERS.slice(0, 3).join(" · "), CALLERS.slice(3).join(" · ")], + }, + { + id: "handoffs", + sub: "work moving between systems", + w: [`dispatched by ${dispatchers.join(", ")} only`, `claimed by ${claimants.join(" or ")}`], + }, + { + id: "snapshots", + sub: "state saved on completion", + w: [SNAP_SYSTEMS.join(" · "), "read first on takeover"], + }, + { + id: "cost", + sub: "records and session costs", + w: [COST_SYSTEMS.slice(0, 2).join(" · "), COST_SYSTEMS.slice(2).join(" · ")], + }, + { + id: "sections", + sub: "long-lived context, stewarded", + w: [`owner ${SECTION_OWNERS.join(" · ")}`, "the steward is recorded on read"], + }, + ], + bus: "BRIDGE-DB · SQLITE + FTS5", + busB: "recall searches every row", + who: "WHO MAY WRITE · fixed caller identity", + retention: "RETENTION · activity only", + retA: "untagged: only the newest 50 per source", + tags: ACTIVITY_TAGS.join(" · "), + retB: "retained for good, with their receipts", + retC: `${ACTIVITY_TAGS[0]} alone obliges a build-log sync`, + trust: "TRUST · every row", + trustA: `source_trust: ${SOURCE_TRUSTS.join(" · ")}`, + trustB: "read back as stored data, not instructions", + trustC: "every gate decision lands in the audit log", +}; + +/** Second lines that describe a reader rather than a writer. */ +const MUTED_WRITER_LINES = new Set(["read first on takeover", "the steward is recorded on read"]); + +function wide() { + const s = sketch("0 0 1120 430"); + L.taps.forEach((tap, i) => { + const x = 30 + i * 210; + const cx = x + 90; + s.rect(x, 60, 180, 60); + s.text(cx, 84, tap.id, 12, "fg", { anchor: "middle", bold: true }); + s.text(cx, 102, tap.sub, 10, "muted", { anchor: "middle" }); + s.line(cx, 122, cx, 180, "fg"); + s.line(cx, 208, cx, 250, "line"); + tap.w.forEach((line, j) => { + s.text(cx, 266 + j * 14, line, 10, MUTED_WRITER_LINES.has(line) ? "muted" : "fg", { + anchor: "middle", + }); + }); + }); + s.line(0, 180, 1120, 180, "fg", { sw: 3 }); + s.text(0, 200, L.bus, 10, "muted", { tracking: 2 }); + s.text(230, 200, L.busB, 10, "muted", { tracking: 2 }); + s.text(0, 238, L.who, 10, "muted", { tracking: 2 }); + s.rect(30, 316, 500, 90); + s.text(44, 336, L.retention, 10, "muted", { tracking: 2 }); + s.text(44, 356, L.retA, 10, "fg"); + s.text(44, 374, L.tags, 10, "accent", { bold: true }); + s.text(160, 374, L.retB, 10, "fg"); + s.text(44, 392, L.retC, 10, "fg"); + s.rect(560, 316, 490, 90); + s.text(574, 336, L.trust, 10, "muted", { tracking: 2 }); + s.text(574, 356, L.trustA, 10, "fg"); + s.text(574, 374, L.trustB, 10, "fg"); + s.text(574, 392, L.trustC, 10, "fg"); + return s.done(); +} + +/** Phone layout: the bus runs down the left edge; taps hang off it as rows. */ +function compact() { + const s = sketch("0 0 340 736"); + s.text(20, 20, L.bus, 10, "muted"); + s.text(20, 34, L.busB, 10, "muted"); + s.text(20, 48, L.who, 10, "muted"); + s.line(36, 60, 36, 530, "fg", { sw: 3 }); + L.taps.forEach((tap, i) => { + const y = 64 + i * 94; + s.line(36, y + 42, 56, y + 42, "fg"); + s.rect(56, y, 264, 84); + s.text(66, y + 18, tap.id, 11, "fg", { bold: true }); + s.text(66, y + 33, tap.sub, 10, "muted"); + tap.w.forEach((line, j) => { + s.text(66, y + 49 + j * 14, line, 10, MUTED_WRITER_LINES.has(line) ? "muted" : "fg"); + }); + }); + s.rect(10, 548, 320, 88); + s.text(20, 564, L.retention, 10, "muted"); + s.text(20, 580, L.retA, 10, "fg"); + s.text(20, 594, L.tags, 10, "accent", { bold: true }); + s.text(20, 608, L.retB, 10, "fg"); + s.text(20, 622, L.retC, 10, "fg"); + s.rect(10, 648, 320, 74); + s.text(20, 664, L.trust, 10, "muted"); + s.text(20, 680, L.trustA, 10, "fg"); + s.text(20, 694, L.trustB, 10, "fg"); + s.text(20, 708, L.trustC, 10, "fg"); + return s.done(); +} + +export const spineRows: DiagramModel = { + id: "spine-rows", + eyebrow: `Mechanism 05 · bridge-sqlite-spine · ${L.taps.length} row shapes`, + title: "One store, five row shapes, fixed writers", + ariaLabel: `A horizontal bus labeled bridge-db, SQLite plus FTS5, with ${L.taps.length} taps: ${L.taps.map((t) => t.id).join(", ")}. Under each tap, the systems allowed to write it under their fixed caller identity. Below, the retention rule for activity: untagged rows keep only the newest per source, rows tagged ${ACTIVITY_TAGS.join(" or ")} are kept for good, and ${ACTIVITY_TAGS[0]} creates a sync obligation. Every row carries a source trust value.`, + figcaption: + "Nothing moves between systems except through these five shapes, each written under a fixed identity. Retention is structural: a protected tag is a property of the row, so no pruning pass and no cascade can orphan its receipt.", + verify: `shapes and writer sets mirror src/types/data.ts (Caller, SnapSystem, CostSystem, SectionOwner, SourceTrust, ActivityTag) · the bridge-db README, Architecture, Tools, Trust and Retention, pinned at ${sourceRevision("bridge-db-public-source")}`, + claims: ["bridge-sqlite-spine"], + scene: "spine", + wide: wide(), + compact: compact(), +}; diff --git a/src/lib/contrast.test.ts b/src/lib/contrast.test.ts index 190db82..0252685 100644 --- a/src/lib/contrast.test.ts +++ b/src/lib/contrast.test.ts @@ -49,4 +49,13 @@ describe("contrast ledger (WCAG 2.1 AA)", () => { contrastRatio(token("--ink-deck-muted"), deck), ).toBeGreaterThanOrEqual(4.5); }); + + // The mechanism diagrams set text on raised panels (src/diagrams/). + it.each([ + [token("--ink-deck"), token("--deck-raised")], + [token("--ink-deck-muted"), token("--deck-raised")], + [token("--accent-deck"), token("--deck-raised")], + ])("%s on the raised panel %s clears AA", (fg, bg) => { + expect(contrastRatio(fg, bg)).toBeGreaterThanOrEqual(4.5); + }); }); diff --git a/src/lib/useVizScale.test.ts b/src/lib/useVizScale.test.ts index ad83825..873348b 100644 --- a/src/lib/useVizScale.test.ts +++ b/src/lib/useVizScale.test.ts @@ -44,6 +44,22 @@ afterEach(() => { }); describe("useVizScale", () => { + it("falls back to compact in a narrow column when minWideWidth asks for it", () => { + setViewport(false); + const { resizeTo } = installResizeObserver(); + const { result } = renderHook(() => + useVizScale({ wide: WIDE, compact: COMPACT }, { minWideWidth: 960 }), + ); + act(() => result.current.ref(document.createElement("div"))); + resizeTo(720); + expect(result.current.compact).toBe(true); + expect(result.current.variant).toBe(COMPACT); + // wide again once the column is wide enough + resizeTo(1000); + expect(result.current.compact).toBe(false); + expect(result.current.variant).toBe(WIDE); + }); + it("keeps the wide variant and untouched label sizes above the breakpoint", () => { setViewport(false); const { resizeTo } = installResizeObserver(); diff --git a/src/lib/useVizScale.ts b/src/lib/useVizScale.ts index 957d15c..e941c74 100644 --- a/src/lib/useVizScale.ts +++ b/src/lib/useVizScale.ts @@ -52,26 +52,44 @@ function prefersCompact(): boolean { * a clock tick: `fs` keeps a stable identity across frames and the scrub path * allocates nothing extra. */ -export function useVizScale(variants: { - wide: T; - compact: T; -}): VizScale { +export function useVizScale( + variants: { + wide: T; + compact: T; + }, + opts: { + /** + * Container width in CSS px below which the compact variant is used even + * on a desktop viewport, for vizes whose wide labels would otherwise + * render under MIN_LABEL_PX in a narrow column. Off by default. + */ + minWideWidth?: number; + } = {}, +): VizScale { + const { minWideWidth } = opts; const [width, setWidth] = useState(0); const [compact, setCompact] = useState(prefersCompact); const observer = useRef(null); - const ref = useCallback((el: Element | null) => { - observer.current?.disconnect(); - observer.current = null; - // jsdom implements neither; unmeasured falls through to the wide variant. - if (!el || typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver((entries) => { - setWidth(entries[0]?.contentRect.width ?? 0); - setCompact(prefersCompact()); - }); - ro.observe(el); - observer.current = ro; - }, []); + const ref = useCallback( + (el: Element | null) => { + observer.current?.disconnect(); + observer.current = null; + // jsdom implements neither; unmeasured falls through to the wide variant. + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver((entries) => { + const measured = entries[0]?.contentRect.width ?? 0; + setWidth(measured); + setCompact( + prefersCompact() || + (minWideWidth !== undefined && measured > 0 && measured < minWideWidth), + ); + }); + ro.observe(el); + observer.current = ro; + }, + [minWideWidth], + ); const variant = compact ? variants.compact : variants.wide; diff --git a/src/scenes/SpineScene.tsx b/src/scenes/SpineScene.tsx index 4abeb9c..e377706 100644 --- a/src/scenes/SpineScene.tsx +++ b/src/scenes/SpineScene.tsx @@ -64,7 +64,13 @@ export function SpineScene() {

{label}

-
+							{/* scrollable, so it must be reachable by keyboard (WCAG 2.1.1) */}
+							
 								{JSON.stringify(sample, null, 2)}