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:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user