Skip to the content

Capsid menu

src/canon.ts

395 lines and 29 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of canon.ts
FunctionLinesComplexityCoveredCRAPMutation
isCanonPath41 to 431100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:42>42 to 423100%OkCRAP 3No data: mutation testing has not run on this repo yet
canonGuarded46 to 482100%OkCRAP 2No data: mutation testing has not run on this repo yet
servedCanonPaths51 to 531100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:52>52 to 521100%OkCRAP 1No data: mutation testing has not run on this repo yet
canonRefusal56 to 62214%OkCRAP 4.5No data: mutation testing has not run on this repo yet
stripLead93 to 1001100%OkCRAP 1No data: mutation testing has not run on this repo yet
isDirectiveLine103 to 1094100%OkCRAP 4No data: mutation testing has not run on this repo yet
<anonymous:106>106 to 1061100%OkCRAP 1No data: mutation testing has not run on this repo yet
lineChanges113 to 13211100%WatchCRAP 11No data: mutation testing has not run on this repo yet
content130 to 1301100%OkCRAP 1No data: mutation testing has not run on this repo yet
directiveLines135 to 1371100%OkCRAP 1No data: mutation testing has not run on this repo yet
proposeCanon164 to 18046%WatchCRAP 17.3No data: mutation testing has not run on this repo yet
readProposal202 to 2041100%OkCRAP 1No data: mutation testing has not run on this repo yet
pendingProposals229 to 2582100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:240>240 to 256424%WatchCRAP 11.2No data: mutation testing has not run on this repo yet
parseLines260 to 269356%OkCRAP 3.8No data: mutation testing has not run on this repo yet
<anonymous:263>263 to 2631100%OkCRAP 1No data: mutation testing has not run on this repo yet
checkProposal277 to 2961360%WatchCRAP 23.8No data: mutation testing has not run on this repo yet
requirePending303 to 31110%OkCRAP 2No data: mutation testing has not run on this repo yet
approveProposal317 to 348123%CriticalCRAP 143No data: mutation testing has not run on this repo yet
rejectProposal351 to 36846%WatchCRAP 17.5No data: mutation testing has not run on this repo yet
docKey385 to 3851100%OkCRAP 1No data: mutation testing has not run on this repo yet
planSweep391 to 4083100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:395>395 to 3951100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:396>396 to 4074100%OkCRAP 4No data: mutation testing has not run on this repo yet
sweepKey412 to 4141100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:413>413 to 4131100%OkCRAP 1No data: mutation testing has not run on this repo yet
readSweep419 to 432364%OkCRAP 3.4No data: mutation testing has not run on this repo yet

Imports

It imports src/agents.ts, src/auth.ts, src/jobs-transition.ts, src/scope.ts, src/store-guards.ts. Imported by src/controls-base.ts, src/controls-perform.ts, src/controls-plan.ts, src/ops-feed.ts, src/stale-canon.ts, src/status.ts, src/supersede.ts, src/tools/docs-lifecycle.ts, src/tools/docs-write.ts.

Source

import type { Agent } from "./agents";
import { sha256Hex } from "./auth";
import { callerIsSeat } from "./jobs-transition";
import { TOOL_GRANTS } from "./scope";
import { auditStatement, documentUpsert, isMissingRowAbort, requireBodyUnchanged, requireMissing, snapshotLive } from "./store-guards";

// CANON WRITE-PROTECTION (OWASP hardening item 2; design:
// capsid/research/design-owasp-hardening.md, "Item 2").
//
// Canon is what every agent is told to trust: the portfolio rules, each namespace's
// core.md and its decisions log. A driver's `capsid_write_doc` to one is not applied. It becomes a row in
// canon_proposals (migrations/0034) and the write answers pending_review. The admin
// approves it, applied as an ordinary write guarded on the body it was written against,
// or rejects it, through the `canon_approve` and `canon_reject` controls (src/controls.ts,
// the Portal and capsid_admin_run_controls). restore, delete and move on a canon path are
// refused for such a caller: a proposal carries a body, and those three do not.
//
// The admin's and the seat's own writes are not queued (callerIsSeat: admin or
// can_merge, the same test every seat-only job transition uses).
//
// Lines a proposal adds that read as instructions to agents are flagged for the
// reviewer, never auto-rejected: the rulings in a decisions log are themselves
// instructions to agents, and an automatic reject would refuse the seat's own canon.

export interface CanonRule {
  // "*" is every namespace.
  namespace: string;
  pattern: RegExp;
  label: string;
}

// test/canon.test.ts pins this list.
export const CANON_RULES: readonly CanonRule[] = [
  { namespace: "capsid", pattern: /^conventions(-[a-z0-9]+)*\.md$/, label: "capsid/conventions.md and capsid/conventions-<name>.md" },
  { namespace: "*", pattern: /^core\.md$/, label: "<namespace>/core.md" },
  { namespace: "*", pattern: /^(decisions|archive\/decisions(-vol-[1-9][0-9]{0,3})?)\.md$/, label: "<namespace>/decisions.md and its archived volumes" },
];

/** Whether a document is canon. Exact strings: documents are keyed by the exact path,
 *  so "Core.md" or "./core.md" is another document, which no reader takes for canon. */
export function isCanonPath(namespace: string, path: string): boolean {
  return CANON_RULES.some((r) => (r.namespace === "*" || r.namespace === namespace) && r.pattern.test(path));
}

/** Whether this caller's change to this document goes to review instead of the store. */
export function canonGuarded(agent: Agent, namespace: string, path: string): boolean {
  return !callerIsSeat(agent) && isCanonPath(namespace, path);
}

/** The list as capsid_run_improve action status serves it, beside protected_paths. */
export function servedCanonPaths(): Array<{ namespace: string; source: string; label: string }> {
  return CANON_RULES.map((r) => ({ namespace: r.namespace, source: r.pattern.source, label: r.label }));
}

/** The refusal restore, delete and move give a guarded caller on a canon path. */
export function canonRefusal(tool: "restore" | "delete" | "move", namespace: string, path: string): string {
  return (
    `${namespace}/${path} is canon (src/canon.ts), and a ${tool} of it by a driver is refused. Nothing changed. ` +
    `Canon changes by proposal: write the body you want with \`capsid_write_doc\` and it is queued for the seat or Dustin to approve` +
    (tool === "restore" ? " (read the version with `capsid_read_doc` action `versions` to get its body)." : ".")
  );
}

// ---------------------------------------------------------------------------
// Instruction-shaped lines.

// A line is compared with these after its markdown lead (list marker, quote, heading,
// bold) is stripped and it is lowercased. Published in docs/schema.md.
const DIRECTIVE_OPENERS: readonly string[] = [
  "you must",
  "you should",
  "you will",
  "you are to",
  "always ",
  "never ",
  "ignore ",
  "disregard ",
  "forget ",
  "do not ",
  "don't ",
  "from now on",
  "agents must",
  "agents should",
  "the agent must",
  "drivers must",
  "a driver must",
  "every session must",
  "sessions must",
];
// Or an imperative naming a served tool: "call capsid_write_doc ...", "run `capsid_work_jobs` ...".
const TOOL_IMPERATIVE = /^(call|run|use|invoke)\s+`?([a-z_]+)`?(\s|$)/;

function stripLead(line: string): string {
  return line
    .trim()
    .replace(/^(?:[-*+]\s+|\d+[.)]\s+|>\s*|#{1,6}\s+)+/, "")
    .replace(/\*\*|__/g, "")
    .trim()
    .toLowerCase();
}

/** Whether one line reads as an instruction addressed to agents. */
export function isDirectiveLine(line: string): boolean {
  const text = stripLead(line);
  if (!text) return false;
  if (DIRECTIVE_OPENERS.some((opener) => text.startsWith(opener))) return true;
  const tool = TOOL_IMPERATIVE.exec(text)?.[2];
  return tool !== undefined && Object.prototype.hasOwnProperty.call(TOOL_GRANTS, tool);
}

/** The lines `after` adds to `before` and the lines it drops, as multisets: a line
 *  moved within the document is neither. Order follows the document. */
export function lineChanges(before: string | null, after: string): { added: string[]; removed: string[] } {
  const counts = new Map<string, number>();
  for (const line of (before ?? "").split("\n")) counts.set(line, (counts.get(line) ?? 0) + 1);
  const added: string[] = [];
  for (const line of after.split("\n")) {
    const n = counts.get(line) ?? 0;
    if (n > 0) counts.set(line, n - 1);
    else added.push(line);
  }
  const removed: string[] = [];
  for (const line of (before ?? "").split("\n")) {
    const n = counts.get(line) ?? 0;
    if (n > 0) {
      removed.push(line);
      counts.set(line, n - 1);
    }
  }
  const content = (l: string) => l.trim() !== "";
  return { added: added.filter(content), removed: removed.filter(content) };
}

/** The added lines that read as instructions to agents. */
export function directiveLines(before: string | null, after: string): string[] {
  return lineChanges(before, after).added.filter(isDirectiveLine);
}

// ---------------------------------------------------------------------------
// The queue.

export interface ProposalInput {
  namespace: string;
  path: string;
  title: string | null;
  body: string;
  type: string | null;
  tags: string | null;
  status: string | null;
  mode: string;
  // The body the write read; null when the document did not exist.
  priorBody: string | null;
  priorExists: boolean;
}

export interface Proposed {
  id: number;
  base_sha: string | null;
  directive_lines: string[];
}

/** Stores a guarded caller's write as a pending proposal, with its audit row, in one
 *  batch. Nothing in documents changes. */
export async function proposeCanon(db: D1Database, actor: string, now: Date, input: ProposalInput): Promise<Proposed> {
  const baseSha = input.priorExists ? await sha256Hex(input.priorBody ?? "") : null;
  const flagged = directiveLines(input.priorBody, input.body);
  const [inserted] = await db.batch([
    db
      .prepare(
        `INSERT INTO canon_proposals (namespace, path, title, body, type, tags, status, mode, base_sha, proposer, created_at, directive_lines)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
         RETURNING id`
      )
      .bind(input.namespace, input.path, input.title, input.body, input.type, input.tags, input.status, input.mode, baseSha, actor, now.toISOString(), JSON.stringify(flagged)),
    auditStatement(db, actor, "canon-proposed", input.namespace, input.path, { mode: input.mode, base_sha: baseSha, directive_lines: flagged.length }),
  ]);
  const id = (inserted?.results?.[0] as { id?: number } | undefined)?.id;
  if (typeof id !== "number") throw new Error(`the proposal for ${input.namespace}/${input.path} was not stored: the insert returned no id`);
  return { id, base_sha: baseSha, directive_lines: flagged };
}

interface ProposalRow {
  id: number;
  namespace: string;
  path: string;
  title: string | null;
  body: string;
  type: string | null;
  tags: string | null;
  status: string | null;
  mode: string;
  base_sha: string | null;
  proposer: string;
  created_at: string;
  directive_lines: string;
  state: "pending" | "approved" | "rejected";
  decided_by: string | null;
  decided_at: string | null;
  decided_reason: string | null;
}

async function readProposal(db: D1Database, id: number): Promise<ProposalRow | null> {
  return db.prepare("SELECT * FROM canon_proposals WHERE id = ?1").bind(id).first<ProposalRow>();
}

// The pending queue is short by construction (each row waits on one person), so this is
// a guard, not a page.
const PENDING_LIMIT = 50;

export interface PendingProposal {
  id: number;
  namespace: string;
  path: string;
  proposer: string;
  created_at: string;
  // Whether the document existed when the proposal was written.
  creates: boolean;
  // Whether the stored body is still the one the proposal was written against. A stale
  // proposal cannot be approved; it is rejected and written again.
  stale: boolean;
  added: number;
  removed: number;
  directive_lines: string[];
}

/** The pending proposals, oldest first, each measured against the document as it is
 *  now. One read whatever the count: the feed's read budget is fixed
 *  (OPS_FEED_READS in src/ops-feed.ts). */
export async function pendingProposals(db: D1Database): Promise<PendingProposal[]> {
  const { results } = await db
    .prepare(
      `SELECT p.id, p.namespace, p.path, p.body, p.base_sha, p.proposer, p.created_at, p.directive_lines,
              d.id AS doc_id, d.body AS current_body
       FROM canon_proposals p LEFT JOIN documents d ON d.namespace = p.namespace AND d.path = p.path
       WHERE p.state = 'pending' ORDER BY p.id LIMIT ?1`
    )
    .bind(PENDING_LIMIT)
    .all<Pick<ProposalRow, "id" | "namespace" | "path" | "body" | "base_sha" | "proposer" | "created_at" | "directive_lines"> & { doc_id: number | null; current_body: string | null }>();
  return Promise.all(
    (results ?? []).map(async (r) => {
      const current = r.doc_id === null ? null : (r.current_body ?? "");
      const sha = current === null ? null : await sha256Hex(current);
      const change = lineChanges(current, r.body);
      return {
        id: r.id,
        namespace: r.namespace,
        path: r.path,
        proposer: r.proposer,
        created_at: r.created_at,
        creates: r.base_sha === null,
        stale: sha !== r.base_sha,
        added: change.added.length,
        removed: change.removed.length,
        directive_lines: parseLines(r.directive_lines),
      };
    })
  );
}

function parseLines(raw: string): string[] {
  try {
    const parsed: unknown = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed.filter((l): l is string => typeof l === "string") : [];
  } catch {
    // Written by proposeCanon from JSON.stringify; unreadable means a hand-edited row,
    // shown as no flagged lines rather than failing the whole queue read.
    return [];
  }
}

export type ProposalCheck =
  | { ok: true; row: ProposalRow; current: string | null; directive: string[] }
  | { ok: false; refusal: string };

/** A pending proposal, and the document as it is now, or why it cannot be decided.
 *  `forApproval` also refuses a proposal whose base has moved. */
export async function checkProposal(db: D1Database, id: number, forApproval: boolean): Promise<ProposalCheck> {
  const row = await readProposal(db, id);
  if (!row) return { ok: false, refusal: `no canon proposal ${id}.` };
  if (row.state !== "pending") return { ok: false, refusal: `canon proposal ${id} was already ${row.state}${row.decided_by ? ` by ${row.decided_by}` : ""}${row.decided_at ? ` at ${row.decided_at}` : ""}.` };
  const doc = await db.prepare("SELECT body FROM documents WHERE namespace = ?1 AND path = ?2").bind(row.namespace, row.path).first<{ body: string | null }>();
  const current = doc ? (doc.body ?? "") : null;
  if (forApproval) {
    const sha = doc ? await sha256Hex(current ?? "") : null;
    if (sha !== row.base_sha) {
      return {
        ok: false,
        refusal:
          row.base_sha === null
            ? `${row.namespace}/${row.path} did not exist when proposal ${id} was written, and it does now. Approving would overwrite a body the proposer never read. Reject it, and the proposer writes it again against the current body.`
            : `${row.namespace}/${row.path} changed since proposal ${id} was written (written against ${row.base_sha.slice(0, 12)}, now ${sha ? sha.slice(0, 12) : "deleted"}). Approving would overwrite a body the proposer never read. Reject it, and the proposer writes it again against the current body.`,
      };
    }
  }
  return { ok: true, row, current, directive: parseLines(row.directive_lines) };
}

export type Decided = { ok: true; row: ProposalRow; sha256: string; snapshotted: boolean } | { ok: false; refusal: string };

// The proposal is still pending, inside the batch: the same NOT NULL abort the store's
// other guards use (src/store-guards.ts), so a second approve, or an approve racing a
// reject, changes nothing.
function requirePending(db: D1Database, id: number): D1PreparedStatement {
  return db
    .prepare(
      `INSERT INTO document_versions (document_id, namespace, path)
       SELECT NULL, 'canon_proposals', ?1
       WHERE NOT EXISTS (SELECT 1 FROM canon_proposals WHERE id = ?1 AND state = 'pending')`
    )
    .bind(String(id));
}

/** Approves a proposal: the document is written as the proposal says, as an ordinary
 *  write (snapshot of the live row, the upsert, an audit row naming the approver and the
 *  proposal), guarded in the batch on the base body and on the proposal still pending.
 *  snapshot invariant, test/write-invariants.test.ts. */
export async function approveProposal(db: D1Database, actor: string, now: Date, id: number): Promise<Decided> {
  const checked = await checkProposal(db, id, true);
  if (!checked.ok) return checked;
  const { row, current } = checked;
  const exists = row.base_sha !== null;
  try {
    const results = await db.batch([
      requirePending(db, id),
      exists ? requireBodyUnchanged(db, row.namespace, row.path, current) : requireMissing(db, row.namespace, row.path),
      ...(exists ? [snapshotLive(db, row.namespace, row.path)] : []),
      documentUpsert(db, row.namespace, row.path, row.title, row.body, row.type, row.tags, row.status),
      auditStatement(db, actor, "write", row.namespace, row.path, {
        title: row.title ?? undefined,
        type: row.type ?? undefined,
        tags: row.tags ?? undefined,
        status: row.status ?? undefined,
        mode: row.mode,
        updated: exists,
        canon_proposal: id,
        proposer: row.proposer,
      }),
      db
        .prepare("UPDATE canon_proposals SET state = 'approved', decided_by = ?2, decided_at = ?3 WHERE id = ?1 AND state = 'pending'")
        .bind(id, actor, now.toISOString()),
    ]);
    const snapshotted = exists && (results[2]?.results?.length ?? 0) > 0;
    return { ok: true, row, sha256: await sha256Hex(row.body), snapshotted };
  } catch (err) {
    if (!isMissingRowAbort(err)) throw err;
    return { ok: false, refusal: `canon proposal ${id} was not applied: it was decided, or ${row.namespace}/${row.path} changed, while this approval ran. Nothing changed. Preview it again.` };
  }
}

/** Rejects a proposal, with the reason the proposer reads. The document is untouched. */
export async function rejectProposal(db: D1Database, actor: string, now: Date, id: number, reason: string): Promise<Decided> {
  const checked = await checkProposal(db, id, false);
  if (!checked.ok) return checked;
  const { row } = checked;
  try {
    await db.batch([
      requirePending(db, id),
      db
        .prepare("UPDATE canon_proposals SET state = 'rejected', decided_by = ?2, decided_at = ?3, decided_reason = ?4 WHERE id = ?1 AND state = 'pending'")
        .bind(id, actor, now.toISOString(), reason),
      auditStatement(db, actor, "canon-rejected", row.namespace, row.path, { canon_proposal: id, proposer: row.proposer, reason }),
    ]);
  } catch (err) {
    if (!isMissingRowAbort(err)) throw err;
    return { ok: false, refusal: `canon proposal ${id} was decided while this rejection ran. Nothing changed.` };
  }
  return { ok: true, row, sha256: await sha256Hex(row.body), snapshotted: false };
}

// ---------------------------------------------------------------------------
// Approve all (the canon_approve_all control, src/controls.ts): one decision over the
// whole pending queue, each proposal still decided by approveProposal or rejectProposal
// above, so its snapshot, audit row and guards are the single decision's
// (snapshot invariant, test/write-invariants.test.ts).

type SweepOutcome = "approve" | "superseded" | "outdated";

export interface SweepItem {
  proposal: PendingProposal;
  outcome: SweepOutcome;
  // The reason the proposer reads; null for an approval.
  reason: string | null;
}

const docKey = (p: PendingProposal) => JSON.stringify([p.namespace, p.path]);

/** What approve all does to each pending proposal, by id. The newest proposal on a
 *  document (the highest id) is its candidate; every older one on it is rejected as
 *  superseded. A stale candidate (its document moved, or now exists when it was a
 *  create) is rejected as outdated, and every other candidate is approved. Pure. */
export function planSweep(pending: readonly PendingProposal[]): SweepItem[] {
  const newest = new Map<string, number>();
  for (const p of pending) newest.set(docKey(p), Math.max(newest.get(docKey(p)) ?? 0, p.id));
  return [...pending]
    .sort((a, b) => a.id - b.id)
    .map((p): SweepItem => {
      const top = newest.get(docKey(p)) ?? p.id;
      if (p.id !== top) return { proposal: p, outcome: "superseded", reason: `superseded by proposal ${top} on the same document` };
      if (p.stale) {
        return {
          proposal: p,
          outcome: "outdated",
          reason: `outdated: ${p.namespace}/${p.path} changed after this proposal was written; write it again against the current body`,
        };
      }
      return { proposal: p, outcome: "approve", reason: null };
    });
}

/** The plan in one spelling, "<id>:<outcome>" by id, which the confirmation token binds:
 *  a perform whose plan reads differently now is refused. */
export function sweepKey(items: readonly SweepItem[]): string {
  return items.map((i) => `${i.proposal.id}:${i.outcome}`).join(",");
}

export type Sweep = { ok: true; items: SweepItem[]; key: string } | { ok: false; refusal: string };

/** The plan from the queue as it is now, or why there is none. Reads only. */
export async function readSweep(db: D1Database): Promise<Sweep> {
  const pending = await pendingProposals(db);
  if (!pending.length) return { ok: false, refusal: "no pending canon proposals." };
  // pendingProposals reads the oldest PENDING_LIMIT. A full read may have left out a newer
  // proposal on a document it holds, which would change what is newest, so it is refused.
  if (pending.length >= PENDING_LIMIT) {
    return {
      ok: false,
      refusal: `${PENDING_LIMIT} or more canon proposals are pending, and approve all reads at most ${PENDING_LIMIT}, so it could miss a newer proposal on a document it decides. Decide some one at a time, then preview again.`,
    };
  }
  const items = planSweep(pending);
  return { ok: true, items, key: sweepKey(items) };
}