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.
74 lines
2.2 KiB
Go
74 lines
2.2 KiB
Go
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"})
|
|
}
|