From d24aca92ee2e469e535cf3f3d762c870e99fd5fc Mon Sep 17 00:00:00 2001 From: ElxMaj <66059051+ElxMaj@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:25:59 +0200 Subject: [PATCH] core+cli+shared: marrow redact part one, the audited tombstone (FOUNDER-GATED) THE ONE VISIBLE AMENDMENT TO APPEND-ONLY EVIDENCE. This PR does not merge without the founder's explicit yes. A credential that slipped past the pre-append scrub was immortal: evidence has no update or delete path by design, and the research's deletion completeness metric was unsatisfiable. Redaction is the deliberate, narrow, loud exception: - Migration 0018: redacted_at / redacted_reason on evidence (additive). - store.redactEvidence: overwrite ONE row's payload with the fixed tombstone '[redacted: ]', stamp the moment; id, source, created_at, and every citation survive. A second redaction is refused. - Marrow.redact: requires a reason, refuses when distilled nodes still cite the row (prints the blast radius: the human must see what quotes the secret before anything is destroyed; cascade is part two), and writes the audit trail as a NORMAL append-only evidence row (redactions/) that never contains the secret. - CLI: marrow redact --reason. Deliberately NO MCP tool, pinned by a test; append_evidence's description now names the exception honestly as human-only. What stays true: every other evidence row is untouched and untouchable; the audit record is append-only; nothing writes any status; no agent surface can reach this path. Roadmap: R26 part one of two. Co-Authored-By: Claude Fable 5 --- .changeset/redact-tombstone.md | 19 ++++++++++ docs/roadmap/2026-2027.md | 2 +- packages/cli/src/cli.ts | 10 ++++++ packages/core/migrations/0018_redaction.sql | 11 ++++++ packages/core/src/loop.test.ts | 33 +++++++++++++++++ packages/core/src/marrow.ts | 40 +++++++++++++++++++++ packages/core/src/store.test.ts | 29 +++++++++++++++ packages/core/src/store.ts | 22 +++++++++++- packages/mcp-server/src/tools.test.ts | 9 +++-- packages/mcp-server/src/tools.ts | 2 +- packages/shared/src/spine.ts | 3 ++ 11 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 .changeset/redact-tombstone.md create mode 100644 packages/core/migrations/0018_redaction.sql diff --git a/.changeset/redact-tombstone.md b/.changeset/redact-tombstone.md new file mode 100644 index 0000000..0e6cc18 --- /dev/null +++ b/.changeset/redact-tombstone.md @@ -0,0 +1,19 @@ +--- +"@marrowhq/shared": minor +"@marrowhq/core": minor +"@marrowhq/cli": minor +"@marrowhq/mcp-server": patch +--- + +marrow redact, part one: the audited tombstone for leaked secrets. + +Evidence is append-only by design, so a credential that slipped past the +pre-append scrub was immortal. Redaction is the single, visible exception: +marrow redact --reason destroys ONE row's payload bytes behind +a fixed tombstone while the row itself (id, source, date, citations) +survives, the moment and reason are stamped, and a normal append-only audit +evidence row records that it happened, never the secret. It refuses when +distilled nodes still cite the row (the human must see the blast radius; +cascade arrives in part two), refuses a second redaction, and is CLI-only: +there is deliberately no MCP path, pinned by a test, so no agent and no +instruction embedded in retrieved memory can trigger destruction. diff --git a/docs/roadmap/2026-2027.md b/docs/roadmap/2026-2027.md index 303e6d0..62f5ee9 100644 --- a/docs/roadmap/2026-2027.md +++ b/docs/roadmap/2026-2027.md @@ -269,7 +269,7 @@ that turn stored history into visible history. need. PR: `.env` fallback via `process.loadEnvFile` (guarded try/catch, never overrides a set var, one dim confirmation line), demo-aware remedy text, doctor remedy names the compose URL. -- [ ] **R26. Redact: the one visible exception** (L, split in two PRs; needs R16). +- [~] **R26. Redact: the one visible exception** (L, split in two PRs; needs R16): both draft PRs open, awaiting founder sign-off. Deletion completeness for secrets is structurally impossible today, and the research measures it. PR-a: migration adds `redacted_at`/`redacted_reason` to evidence, `store.redactEvidence` overwrites the payload with a fixed tombstone (id, source, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a50f35e..31a16e7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -243,6 +243,7 @@ Add to the room (transcripts in many formats: vtt, srt, json, txt, md): answer --text "..." [--decide ] The human promote-to-decided step retract --reason "..." [--force] Human-only: a false memory stops surfacing (kept, never erased) history The replacement lineage: what replaced what, when, and why + redact --reason "..." Human-only, audited: destroy ONE row's payload (a leaked secret) goal author "" [--type product|user] [--description "..."] [--entity <id>] Author a decided goal (the human commitment path) goal propose "<title>" --type product|user --evidence <id> [--start N --end N] @@ -570,6 +571,15 @@ export async function runCommand(core: Marrow, argv: string[]): Promise<unknown> return { evidenceId, nodes: await core.getNodesForEvidence(evidenceId) }; } + case "redact": { + const evidenceId = positional(rest); + const reason = flagValue(rest, "--reason"); + if (!evidenceId || reason === undefined) { + throw new Error('Usage: marrow redact <evidenceId> --reason "what leaked and why"'); + } + return core.redact(evidenceId, reason); + } + case "history": { const nodeId = positional(rest); if (!nodeId) throw new Error("Usage: marrow history <nodeId>"); diff --git a/packages/core/migrations/0018_redaction.sql b/packages/core/migrations/0018_redaction.sql new file mode 100644 index 0000000..81db7d3 --- /dev/null +++ b/packages/core/migrations/0018_redaction.sql @@ -0,0 +1,11 @@ +-- 0018: the one visible exception to append-only evidence. A secret that +-- slipped past the pre-append scrub is immortal otherwise: evidence has no +-- update or delete path by design. Redaction overwrites ONE row's payload +-- with a fixed tombstone while the row itself (id, source, created_at, every +-- citation) survives, the reason is stamped here, and a normal append-only +-- audit evidence row records that it happened. Human-only at the CLI; there +-- is deliberately no MCP path, so no agent and no instruction embedded in +-- retrieved memory can ever trigger destruction. + +alter table evidence add column if not exists redacted_at timestamptz; +alter table evidence add column if not exists redacted_reason text; diff --git a/packages/core/src/loop.test.ts b/packages/core/src/loop.test.ts index 0ae5843..55e82c3 100644 --- a/packages/core/src/loop.test.ts +++ b/packages/core/src/loop.test.ts @@ -703,6 +703,39 @@ describe("agent decision gate and truth maintenance", () => { } }); + it("redact destroys one payload, writes the audit row, and refuses when nodes still cite it", async () => { + // uncited leak: redaction proceeds, audited, reason never contains the secret. + const leak = await store.insertEvidence({ + text: "the staging wifi password is horse-battery-staple-42", + source: "session/leak.md", + }); + const receipt = await core.redact(leak.id, "leaked staging credential"); + expect((await store.getEvidence(leak.id))?.text).toBe("[redacted: leaked staging credential]"); + const audit = await store.getEvidence(receipt.auditEvidenceId); + expect(audit?.source).toBe(`redactions/${leak.id}`); + expect(audit?.text).not.toContain("horse-battery"); + + // cited leak: refused with the blast radius named; nothing destroyed. + const cited = await store.insertEvidence({ + text: "another leak cited by a node", + source: "session/leak2.md", + }); + const node = await store.insertDecision({ + title: "Node quoting the leak", + rationale: "", + constraint: false, + status: "open", + confidence: modelConf, + provenance: [{ evidenceId: cited.id, start: 0, end: 12 }], + }); + await expect(core.redact(cited.id, "leaked")).rejects.toThrow(node.id); + expect((await store.getEvidence(cited.id))?.text).toContain("another leak"); + + // guards: reason required, double redact refused. + await expect(core.redact(leak.id, "again")).rejects.toThrow(/already redacted/); + await expect(core.redact(cited.id, " ")).rejects.toThrow(/reason is required/); + }); + it("maintainTruth surfaces the undistilled evidence backlog with a drain action", async () => { // a session-end hook write: evidence appended, never distilled. await store.insertEvidence({ diff --git a/packages/core/src/marrow.ts b/packages/core/src/marrow.ts index 6d3fafc..dd65851 100644 --- a/packages/core/src/marrow.ts +++ b/packages/core/src/marrow.ts @@ -2294,6 +2294,46 @@ export class Marrow { } } + /** + * The human-only redaction: destroy the payload bytes of ONE evidence row + * (a leaked credential the pre-append scrub missed) while the row, its id, + * its source, its date, and every citation survive, and a normal + * append-only audit evidence row records that it happened, never the + * secret. This is the single, visible exception to append-only evidence. + * Without cascade it refuses when distilled nodes cite the row, printing + * the blast radius: the human must see what still quotes the secret before + * anything is destroyed. CLI-only; there is deliberately no MCP tool. + */ + async redact( + evidenceId: string, + reason: string, + ): Promise<{ evidenceId: string; auditEvidenceId: string }> { + if (!reason || reason.trim().length === 0) { + throw new Error("redact: a reason is required"); + } + const evidence = await this.store.getEvidence(evidenceId); + if (!evidence) throw new Error(`redact: evidence ${evidenceId} not found`); + if (evidence.redactedAt !== undefined) { + throw new Error(`redact: evidence ${evidenceId} is already redacted`); + } + const citing = await this.store.getNodesForEvidence(evidenceId); + if (citing.length > 0) { + const list = citing + .map((node) => `${node.id} [${node.status}] ${nodeTitle(node)}`) + .join("; "); + throw new Error( + `redact: ${citing.length} distilled node${citing.length === 1 ? "" : "s"} cite this evidence and would keep quoting it: ${list}. Cascade support arrives with the second redaction PR; until then retract the citing nodes first.`, + ); + } + await this.store.redactEvidence(evidenceId, reason); + // the audit trail is ordinary append-only evidence, and never the secret. + const audit = await this.store.insertEvidence({ + text: `redacted ${evidenceId}: ${reason}`, + source: `redactions/${evidenceId}`, + }); + return { evidenceId, auditEvidenceId: audit.id }; + } + /** * The human-only correction: retract a false memory so it stops surfacing * anywhere retrieval serves facts, while the node, its content, and its diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index 5b23f50..2553be9 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -1113,3 +1113,32 @@ describe("retract (the human-only correction)", () => { expect((await store.getDecision(dec.id))?.title).toBe("retractable widget policy"); }); }); + +describe("redaction: the one visible exception to append-only evidence", () => { + it("tombstones the payload while the row and its identity survive", async () => { + const ev = await store.insertEvidence({ + // scrub-proof phrasing: a fake secret shape the scrub does not catch, + // standing in for anything that slips the pre-append detectors. + text: "the wifi password is horse-battery-staple-42, do not share", + source: "standups/leak2.md", + }); + await store.redactEvidence(ev.id, "leaked wifi credential"); + + const after = await store.getEvidence(ev.id); + expect(after?.text).toBe("[redacted: leaked wifi credential]"); + expect(after?.text).not.toContain("horse-battery"); + expect(after?.redactedAt).toBeDefined(); + // identity survives: id, source, created_at. + expect(after?.id).toBe(ev.id); + expect(after?.source).toBe("standups/leak2.md"); + expect(after?.createdAt).toBe(ev.createdAt); + // and the payload is gone from search too. + expect((await store.searchEvidence("horse-battery")).length).toBe(0); + }); + + it("refuses a second redaction: the state already is what the human asked for", async () => { + const ev = await store.insertEvidence({ text: "double redact target", source: "room/dr.md" }); + await store.redactEvidence(ev.id, "first"); + await expect(store.redactEvidence(ev.id, "second")).rejects.toThrow(/already redacted/); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 5f7dc14..55fa203 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -400,13 +400,32 @@ export class Store { return { id, kind: "evidence", text, source: draft.source, createdAt }; } + /** The ONE exception to append-only evidence, and it is loud about it: the + * payload of a single row is overwritten with a fixed tombstone, the + * moment is stamped, and the row (id, source, created_at, citations) + * survives. Callable from the human CLI path only; there is deliberately + * no MCP route here. Refuses a second redaction: the state already is + * what the human asked for. */ + async redactEvidence(evidenceId: string, reason: string): Promise<void> { + const res = await this.pool.query( + `update evidence + set text = $2, redacted_at = $3, redacted_reason = $4 + where id = $1 and redacted_at is null`, + [evidenceId, `[redacted: ${reason}]`, new Date().toISOString(), reason], + ); + if (res.rowCount === 0) { + throw new Error(`redact: evidence ${evidenceId} not found or already redacted`); + } + } + async getEvidence(id: string): Promise<Evidence | undefined> { const res = await this.pool.query<{ id: string; text: string; source: string; created_at: Date; - }>("select id, text, source, created_at from evidence where id = $1", [id]); + redacted_at: Date | null; + }>("select id, text, source, created_at, redacted_at from evidence where id = $1", [id]); const row = res.rows[0]; if (!row) return undefined; return { @@ -415,6 +434,7 @@ export class Store { text: row.text, source: row.source, createdAt: iso(row.created_at), + ...(row.redacted_at !== null ? { redactedAt: iso(row.redacted_at) } : {}), }; } diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index d36054d..e283853 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -449,10 +449,15 @@ describe("mcp tools", () => { expect((await core.getNode(catchQuestion.id))?.status).toBe("open"); }); - it("there is deliberately no MCP retract tool: agents cannot hide facts", () => { + it("there is deliberately no MCP retract or redact tool: agents cannot hide or destroy", () => { expect(tools.map((t) => t.name)).not.toContain("retract"); - // and no tool description even hints at a retract path. + expect(tools.map((t) => t.name)).not.toContain("redact"); + // no tool description hints at a retract path; redact appears only in + // append_evidence's honesty note, which names it as CLI-only. expect(tools.every((t) => !/retract/i.test(t.description))).toBe(true); + const mentions = tools.filter((t) => /redact/i.test(t.description)); + expect(mentions.map((t) => t.name)).toEqual(["append_evidence"]); + expect(mentions[0]?.description).toMatch(/human-only/i); }); it("append_evidence distills inline by default: the write is retrievable in-session", async () => { diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index bb0e35e..76113cd 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -206,7 +206,7 @@ export function createTools(core: Marrow): ToolDef[] { { name: "append_evidence", description: - "Append raw room evidence (a transcript, note, message) verbatim. Append only: it is never edited or deleted. Credential-shaped spans (API keys, tokens, private keys) are replaced with [redacted:kind] placeholders before storage, because evidence cannot be deleted afterward. Distills inline by default when a model is configured, so what you append is retrievable in this same session; distillation only proposes OPEN nodes, never decided ones. Pass distill: false to defer (faster; drain later with marrow distill --pending).", + "Append raw room evidence (a transcript, note, message) verbatim. Append only: it is never edited or deleted (one human-only, audited CLI redaction exception exists for leaked secrets; no MCP path can trigger it). Credential-shaped spans (API keys, tokens, private keys) are replaced with [redacted:kind] placeholders before storage, because evidence cannot be deleted afterward. Distills inline by default when a model is configured, so what you append is retrievable in this same session; distillation only proposes OPEN nodes, never decided ones. Pass distill: false to defer (faster; drain later with marrow distill --pending).", inputSchema: { type: "object", properties: { diff --git a/packages/shared/src/spine.ts b/packages/shared/src/spine.ts index e4a4ae9..3955421 100644 --- a/packages/shared/src/spine.ts +++ b/packages/shared/src/spine.ts @@ -80,6 +80,9 @@ export const EvidenceSchema = z.object({ text: z.string(), source: z.string().min(1), createdAt: Iso, + /** set when this row's payload was destroyed by the human-only redaction + * exception; the text is the fixed tombstone from that moment on. */ + redactedAt: Iso.optional(), }); export type Evidence = z.infer<typeof EvidenceSchema>;