Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/redact-tombstone.md
Original file line number Diff line number Diff line change
@@ -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 <evidenceId> --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.
2 changes: 1 addition & 1 deletion docs/roadmap/2026-2027.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ Add to the room (transcripts in many formats: vtt, srt, json, txt, md):
answer <questionId> --text "..." [--decide <id>] The human promote-to-decided step
retract <nodeId> --reason "..." [--force] Human-only: a false memory stops surfacing (kept, never erased)
history <nodeId> The replacement lineage: what replaced what, when, and why
redact <evidenceId> --reason "..." Human-only, audited: destroy ONE row's payload (a leaked secret)
goal author "<title>" [--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]
Expand Down Expand Up @@ -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>");
Expand Down
11 changes: 11 additions & 0 deletions packages/core/migrations/0018_redaction.sql
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 33 additions & 0 deletions packages/core/src/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/marrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
22 changes: 21 additions & 1 deletion packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) } : {}),
};
}

Expand Down
9 changes: 7 additions & 2 deletions packages/mcp-server/src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/spine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand Down