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:
@@ -69,6 +69,7 @@ type Summary struct {
|
||||
LLMLive int `json:"llm_live"`
|
||||
LLMReplayed int `json:"llm_replayed"`
|
||||
Cycles int `json:"cycles"`
|
||||
Redacted int `json:"redacted"` // stimuli whose content was redacted and could not be replayed
|
||||
}
|
||||
|
||||
// Step is the outcome of replaying one stimulus.
|
||||
@@ -218,6 +219,13 @@ func (s *Service) Execute(ctx context.Context, r Replay) (Replay, error) {
|
||||
}
|
||||
orig, _ := s.Events.Query(ctx, evstore.Filter{MindID: src.ID, AfterSeq: stim.Seq, BeforeSeq: upper, Ascending: true, Limit: 2000})
|
||||
st.Original = Digest(withoutPerception(orig))
|
||||
if stim.RedactedAt != nil {
|
||||
// The content is gone; replaying a marker would fabricate cognition.
|
||||
st.Label, st.Note = "redacted", "stimulus content was redacted under a privacy policy and was not replayed"
|
||||
r.Summary.Redacted++
|
||||
steps = append(steps, st)
|
||||
continue
|
||||
}
|
||||
startedAt := time.Now()
|
||||
inj, err := drv.Inject(ctx, stim, map[string]any{"replay_of": stim.ID, "replay_id": r.ID})
|
||||
if err != nil {
|
||||
|
||||
@@ -87,6 +87,18 @@ func TestReplayIsExactWithoutModel(t *testing.T) {
|
||||
if after[0].Seq != last[0].Seq {
|
||||
t.Fatal("replay wrote into the source mind")
|
||||
}
|
||||
// A redacted stimulus is not replayed and is labelled as such.
|
||||
if _, err := h.Pool.Exec(ctx, `INSERT INTO redactions (id, mind_id, target_kind, target_id, reason, content_hash, requested_by) VALUES ($1,$2,'event',$3,'test','sha256:0','test')`, ids.New(), src.ID, steps[0].StimulusID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := h.Pool.QueryRow(ctx, `SELECT noema_redact('event', $1, $2)`, steps[0].StimulusID, ids.New()).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("redact: %v %d", err, n)
|
||||
}
|
||||
red, err := svc.Run(ctx, src, sn.ID, sn.EventSeq, last[0].Seq, "test")
|
||||
if err != nil || red.Summary.Redacted != 1 || red.Summary.Exact != 0 || StepsOf(red)[0].Label != "redacted" {
|
||||
t.Fatalf("redacted stimulus should be skipped and labelled: %v %+v", err, red.Summary)
|
||||
}
|
||||
// Digest normalisation ignores ids and numbers.
|
||||
if Normalise("selected 3 thoughts for 01a0a148-9034-7685-9794-cf5ec0c25fb7") != "selected <n> thoughts for <id>" {
|
||||
t.Fatal(Normalise("selected 3 thoughts for 01a0a148-9034-7685-9794-cf5ec0c25fb7"))
|
||||
|
||||
Reference in New Issue
Block a user