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
+2
View File
@@ -34,6 +34,7 @@ import (
"github.com/andydixon/noema/internal/notifications"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/predictions"
"github.com/andydixon/noema/internal/privacy"
"github.com/andydixon/noema/internal/reflection"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
@@ -65,6 +66,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log}
deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds}
deps.Assertions = epistemics.NewStore(pool)
deps.Privacy = &privacy.Service{DB: pool, Events: deps.Events, Audit: deps.Audit, Snapshots: deps.Snapshots, Log: log}
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
+117
View File
@@ -6345,6 +6345,123 @@
}
}
},
"/api/v1/minds/{mind}/redactions": {
"get": {
"tags": [
"minds"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Redacted; irreversible"
}
},
"parameters": [
{
"name": "mind",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"operationId": "redactionsList",
"summary": "list the redaction ledger",
"x-required-role": "operator"
},
"post": {
"tags": [
"minds"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Redacted; irreversible"
}
},
"parameters": [
{
"name": "mind",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"operationId": "redactionCreate",
"summary": "redact a row's content irreversibly (event, memory, message, llm_call, belief, assertion), optionally cascading to derived rows",
"x-required-role": "administrator",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"id": {
"type": "string"
},
"reason": {
"type": "string"
},
"policy": {
"type": "string"
},
"cascade": {
"type": "boolean"
}
},
"required": [
"kind",
"id",
"reason"
]
}
}
}
}
}
},
"/api/v1/minds/{mind}/reflections": {
"get": {
"operationId": "reflectionsList",
+73
View File
@@ -0,0 +1,73 @@
package api
import (
"errors"
"net/http"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/privacy"
"github.com/andydixon/noema/pkg/ids"
)
// privacyRoutes: the audited redaction path. Administrators only; every
// call is irreversible by design and says so in its response.
func (s *Server) privacyRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/redactions", require(auth.RoleOperator, s.redactionsList))
m.Handle("POST /api/v1/minds/{mind}/redactions", require(auth.RoleAdministrator, s.redactionCreate))
}
func (s *Server) redactionsList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Privacy.List(r.Context(), m.ID, 200)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []privacy.Redaction{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// RedactionInput is the request body (API and UI form).
type RedactionInput struct {
Kind string `json:"kind"`
ID ids.ID `json:"id"`
Reason string `json:"reason"`
Policy string `json:"policy"`
Cascade bool `json:"cascade"`
}
// Redact performs a redaction; shared with the UI.
func (s *Server) Redact(r *http.Request, mind ids.ID, in RedactionInput, by string) (privacy.Redaction, []privacy.Redaction, error) {
p, c, err := s.deps.Privacy.Redact(r.Context(), privacy.Request{MindID: mind, Kind: privacy.Kind(in.Kind), ID: in.ID, Reason: in.Reason, Policy: in.Policy, Cascade: in.Cascade, By: by})
switch {
case errors.Is(err, privacy.ErrInvalid):
return p, c, ValidationError(err.Error())
case errors.Is(err, privacy.ErrAlreadyRedacted):
return p, c, ValidationError("already redacted")
}
return p, c, err
}
func (s *Server) redactionCreate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in RedactionInput
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
p, c, err := s.Redact(r, m.ID, in, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"redaction": p, "cascaded": c, "note": "content is irrecoverable; the row, its links and a hash of the removed content remain"})
}
+1
View File
@@ -80,6 +80,7 @@ func (s *Server) routes() {
s.dreamRoutes()
s.studyRoutes()
s.assertionRoutes()
s.privacyRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")