feat(privacy): audited redaction that keeps history's shape and removes its content

Rows in append-only history are never deleted; their content can now be
made irrecoverable through one audited path. A redaction replaces the
content columns of an event, memory, message, model call, belief or
assertion with a fixed marker, keeps identifiers, links, timestamps,
scores and breakdowns, writes an append-only ledger row with a SHA-256 of
the removed content, appends a redaction event, optionally cascades to
derived rows and to the events that quote them, and scrubs stored snapshot
archives before re-signing them. noema_redact() is the only write the
append-only trigger admits, for UPDATE only, under a transaction-local flag
the application never sets; plain updates and all deletes stay refused.
Replay labels redacted stimuli instead of inventing them.

Cryptographic erasure was considered and deferred with reasons (ADR-0009);
docs/privacy.md defines retained, redacted, irrecoverable and deleted and
states what backups and earlier exports are not covered. Migration 0023.
This commit is contained in:
Andy Dixon
2026-08-23 23:52:07 +01:00
parent 9eed6e2a18
commit 7d608c2783
33 changed files with 1245 additions and 45 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
<section class="grid cols-3-1">
<div class="stack">
<div class="card">
<h2>Causal chain <a class="small" href="/events/{{$e.ID}}/timeline">full timeline →</a></h2>
<h2>Causal chain <a class="small" href="/events/{{$e.ID}}/timeline">full timeline →</a>{{if .Principal.User.Role.AtLeast "administrator"}} · <a class="small danger" href="/minds/{{.Data.Mind.Slug}}/redactions?kind=event&id={{$e.ID}}">redact…</a>{{end}}</h2>
<div class="chain">
{{if $c.Cause}}<div class="chain-node"><span class="muted small">caused by</span><br><span class="etype cat-{{$c.Cause.Category}}">{{$c.Cause.Type}}</span> <a href="/events/{{$c.Cause.ID}}">{{$c.Cause.Summary}}</a></div><div class="chain-arrow">↓</div>{{end}}
<div class="chain-node current"><span class="etype cat-{{$e.Category}}">{{$e.Type}}</span> {{$e.Summary}}</div>
+25
View File
@@ -0,0 +1,25 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead"><div><h1>{{$m.DisplayName}} <span class="muted small">redactions</span></h1><p class="muted">The audited path for making sensitive content irrecoverable. A redaction keeps the row, its identifiers, its links, its type and its time, replaces the content with <code>[redacted]</code>, scrubs stored snapshot archives, and records a hash of what was removed. It cannot be undone. Backups and exports taken before it are outside this system's reach.</p></div></div>
{{template "mind_tabs" (dict "M" $m "Active" "redactions")}}
<section class="grid cols-3-1">
<div class="card">
{{if .Data.Redactions}}<table class="table compact"><thead><tr><th>When</th><th>Kind</th><th>Target</th><th>Reason</th><th>Policy</th><th>By</th><th>Cascade</th><th>Hash</th></tr></thead><tbody>
{{range .Data.Redactions}}<tr><td class="small muted nowrap">{{ago .CreatedAt}}</td><td><span class="badge">{{.Kind}}</span></td><td class="mono small">{{if eq .Kind "event"}}<a href="/events/{{.TargetID}}">{{trunc (printf "%s" .TargetID) 13}}</a>{{else if eq .Kind "memory"}}<a href="/minds/{{$m.Slug}}/memory/{{.TargetID}}">{{trunc (printf "%s" .TargetID) 13}}</a>{{else if eq .Kind "belief"}}<a href="/minds/{{$m.Slug}}/beliefs/{{.TargetID}}">{{trunc (printf "%s" .TargetID) 13}}</a>{{else}}{{trunc (printf "%s" .TargetID) 13}}{{end}}</td><td>{{.Reason}}</td><td class="small">{{.Policy}}</td><td class="small">{{.RequestedBy}}</td><td class="small muted">{{if .CascadeOf}}from {{trunc (printf "%s" .CascadeOf) 8}}{{else}}primary{{end}}</td><td class="mono small" title="{{.ContentHash}}">{{trunc .ContentHash 19}}…</td></tr>{{end}}</tbody></table>
{{else}}<p class="muted">Nothing has been redacted for this mind.</p>{{end}}
</div>
{{if .Principal.User.Role.AtLeast "administrator"}}
<div class="card"><h2>Redact</h2>
<form method="post" action="/minds/{{$m.Slug}}/redactions" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Kind <select name="kind">{{range $k := list "event" "memory" "message" "llm_call" "belief" "assertion"}}<option value="{{$k}}" {{if eq $k $.Data.Kind}}selected{{end}}>{{$k}}</option>{{end}}</select></label>
<label>Id <input name="id" required value="{{.Data.ID}}" class="mono"></label>
<label>Reason <input name="reason" required maxlength="300" placeholder="data subject request, retention policy…"></label>
<label>Policy <input name="policy" maxlength="120" placeholder="e.g. GDPR art. 17"></label>
<label class="check"><input type="checkbox" name="cascade" value="true" checked> Also redact rows derived from it (episodes, messages, model calls, assertions, beliefs)</label>
<div><button class="btn btn-danger" data-confirm="This cannot be undone. The content will be irrecoverable everywhere this system controls. Continue?">Redact</button></div>
</form>
<p class="small muted">Redaction is recorded in the audit log and as a <code>redaction</code> event in the mind's history; neither contains the removed content.</p>
</div>
{{end}}
</section>
{{end}}
+1 -1
View File
@@ -3,6 +3,6 @@
<a href="/minds/{{$m.Slug}}" {{if eq $a "overview"}}aria-current="page"{{end}}>Overview</a>
<a href="/minds/{{$m.Slug}}/thoughts" {{if eq $a "thoughts"}}aria-current="page"{{end}}>Thoughts</a>
<a href="/minds/{{$m.Slug}}/personality" {{if eq $a "personality"}}aria-current="page"{{end}}>Personality</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a href="/minds/{{$m.Slug}}/beliefs" {{if eq $a "beliefs"}}aria-current="page"{{end}}>Beliefs</a><a href="/minds/{{$m.Slug}}/conversations" {{if eq $a "conversation"}}aria-current="page"{{end}}>Conversation</a><a href="/minds/{{$m.Slug}}/dreams" {{if eq $a "dreams"}}aria-current="page"{{end}}>Dreams</a><a href="/minds/{{$m.Slug}}/snapshots" {{if eq $a "snapshots"}}aria-current="page"{{end}}>Snapshots</a><a href="/minds/{{$m.Slug}}/replays" {{if eq $a "replays"}}aria-current="page"{{end}}>Replay</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a href="/minds/{{$m.Slug}}/beliefs" {{if eq $a "beliefs"}}aria-current="page"{{end}}>Beliefs</a><a href="/minds/{{$m.Slug}}/conversations" {{if eq $a "conversation"}}aria-current="page"{{end}}>Conversation</a><a href="/minds/{{$m.Slug}}/dreams" {{if eq $a "dreams"}}aria-current="page"{{end}}>Dreams</a><a href="/minds/{{$m.Slug}}/snapshots" {{if eq $a "snapshots"}}aria-current="page"{{end}}>Snapshots</a><a href="/minds/{{$m.Slug}}/replays" {{if eq $a "replays"}}aria-current="page"{{end}}>Replay</a><a href="/minds/{{$m.Slug}}/redactions" {{if eq $a "redactions"}}aria-current="page"{{end}}>Redactions</a>
</nav>
{{end}}