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:
@@ -0,0 +1,341 @@
|
||||
// Package privacy is the audited path for making sensitive content
|
||||
// irrecoverable without breaking the history it belongs to (ADR-0009).
|
||||
//
|
||||
// A redaction keeps the row, its identifiers, its links, its type and its
|
||||
// timestamps, and replaces the content columns with a fixed marker. What was
|
||||
// removed is attested by a SHA-256 of the removed content in the ledger, so
|
||||
// a later reader can establish that an event existed, when, of what kind,
|
||||
// what depended on it, that it was redacted, by whom and under which policy,
|
||||
// while the content itself is gone from the live database and from stored
|
||||
// snapshot archives. Backups and exports made before the redaction are
|
||||
// outside this system's reach and are documented as such.
|
||||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/andydixon/noema/internal/audit"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/snapshots"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Kind of row a redaction targets.
|
||||
type Kind string
|
||||
|
||||
// Kinds.
|
||||
const (
|
||||
KindEvent Kind = "event"
|
||||
KindMemory Kind = "memory"
|
||||
KindMessage Kind = "message"
|
||||
KindLLMCall Kind = "llm_call"
|
||||
KindBelief Kind = "belief"
|
||||
KindAssertion Kind = "assertion"
|
||||
)
|
||||
|
||||
// Marker replaces removed content everywhere.
|
||||
const Marker = "[redacted]"
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrInvalid = errors.New("privacy: invalid request")
|
||||
ErrAlreadyRedacted = errors.New("privacy: already redacted")
|
||||
)
|
||||
|
||||
// Request describes one redaction.
|
||||
type Request struct {
|
||||
MindID ids.ID
|
||||
Kind Kind
|
||||
ID ids.ID
|
||||
Reason string
|
||||
Policy string
|
||||
Cascade bool // also redact rows derived from the target
|
||||
By string
|
||||
}
|
||||
|
||||
// Redaction is one ledger row.
|
||||
type Redaction struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Kind Kind `json:"target_kind"`
|
||||
TargetID ids.ID `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Policy string `json:"policy"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
CascadeOf ids.ID `json:"cascade_of,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Service performs redactions.
|
||||
type Service struct {
|
||||
DB *pgxpool.Pool
|
||||
Events *evstore.Store
|
||||
Audit *audit.Logger
|
||||
Snapshots *snapshots.Store
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Redact makes the target's content irrecoverable and returns the ledger
|
||||
// row plus any cascaded rows. Everything runs in one transaction; stored
|
||||
// snapshot archives are scrubbed afterwards and the outcome is audited.
|
||||
func (s *Service) Redact(ctx context.Context, req Request) (Redaction, []Redaction, error) {
|
||||
if req.MindID.IsZero() || req.ID.IsZero() || strings.TrimSpace(req.Reason) == "" || !validKind(req.Kind) {
|
||||
return Redaction{}, nil, fmt.Errorf("%w: mind, kind, id and reason are required", ErrInvalid)
|
||||
}
|
||||
var primary Redaction
|
||||
var cascaded []Redaction
|
||||
err := storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
|
||||
var err error
|
||||
primary, err = s.redactOne(ctx, tx, req, ids.Nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !req.Cascade {
|
||||
return nil
|
||||
}
|
||||
deps := s.dependants(ctx, tx, req.MindID, req.Kind, req.ID)
|
||||
// Events that quote a redacted row (the perception of a message, the
|
||||
// encoding of an episode, the creation of a belief) are redacted too,
|
||||
// one level out from the target and from each dependant.
|
||||
deps = append(deps, s.referencingEvents(ctx, tx, req.MindID, req.Kind, req.ID)...)
|
||||
seen := map[dep]bool{{req.Kind, req.ID}: true}
|
||||
for i := 0; i < len(deps); i++ {
|
||||
d := deps[i]
|
||||
if seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
r, err := s.redactOne(ctx, tx, Request{MindID: req.MindID, Kind: d.kind, ID: d.id, Reason: req.Reason, Policy: req.Policy, By: req.By}, primary.ID)
|
||||
if errors.Is(err, ErrAlreadyRedacted) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cascaded = append(cascaded, r)
|
||||
if d.kind != KindEvent {
|
||||
deps = append(deps, s.referencingEvents(ctx, tx, req.MindID, d.kind, d.id)...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return primary, cascaded, err
|
||||
}
|
||||
// Stored archives hold copies of memories, messages and beliefs; scrub them.
|
||||
if s.Snapshots != nil {
|
||||
for _, r := range append([]Redaction{primary}, cascaded...) {
|
||||
if n, err := s.Snapshots.Scrub(ctx, r.MindID, string(r.Kind), r.TargetID); err != nil {
|
||||
s.Log.Warn("snapshot scrub failed", "redaction", r.ID, "err", err)
|
||||
} else if n > 0 {
|
||||
s.Log.Info("snapshot archives scrubbed", "redaction", r.ID, "archives", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.Audit != nil {
|
||||
s.Audit.Log(ctx, "privacy.redact", string(req.Kind), req.ID.String(), map[string]any{"redaction_id": primary.ID, "reason": req.Reason, "policy": req.Policy, "cascaded": len(cascaded), "content_hash": primary.ContentHash})
|
||||
}
|
||||
return primary, cascaded, nil
|
||||
}
|
||||
|
||||
func (s *Service) redactOne(ctx context.Context, tx pgx.Tx, req Request, cascadeOf ids.ID) (Redaction, error) {
|
||||
content, mind, err := s.content(ctx, tx, req.Kind, req.ID)
|
||||
if err != nil {
|
||||
return Redaction{}, err
|
||||
}
|
||||
if mind != req.MindID {
|
||||
return Redaction{}, fmt.Errorf("%w: %s %s does not belong to this mind", ErrInvalid, req.Kind, req.ID)
|
||||
}
|
||||
if content == nil {
|
||||
return Redaction{}, ErrAlreadyRedacted
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
r := Redaction{ID: ids.New(), MindID: req.MindID, Kind: req.Kind, TargetID: req.ID, Reason: req.Reason, Policy: req.Policy, ContentHash: "sha256:" + hex.EncodeToString(sum[:]), RequestedBy: req.By, CascadeOf: cascadeOf, CreatedAt: time.Now()}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO redactions (id, mind_id, target_kind, target_id, reason, policy, content_hash, requested_by, cascade_of, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
r.ID, r.MindID, string(r.Kind), r.TargetID, r.Reason, r.Policy, r.ContentHash, r.RequestedBy, nullID(cascadeOf), r.CreatedAt); err != nil {
|
||||
if strings.Contains(err.Error(), "redactions_target_kind_target_id_key") {
|
||||
return Redaction{}, ErrAlreadyRedacted
|
||||
}
|
||||
return Redaction{}, err
|
||||
}
|
||||
var n int
|
||||
if err := tx.QueryRow(ctx, `SELECT noema_redact($1, $2, $3)`, string(req.Kind), req.ID, r.ID).Scan(&n); err != nil {
|
||||
return Redaction{}, err
|
||||
}
|
||||
if n == 0 {
|
||||
return Redaction{}, ErrAlreadyRedacted
|
||||
}
|
||||
// The history records that a redaction happened, never what was removed.
|
||||
if s.Events != nil {
|
||||
if _, err := s.Events.AppendIn(ctx, tx, events.New{MindID: req.MindID, Type: events.Redaction, Source: "privacy", Summary: fmt.Sprintf("%s %s redacted under %s", req.Kind, req.ID.String()[len(req.ID.String())-8:], firstNonEmpty(req.Policy, "operator request")),
|
||||
Salience: 0.2, Confidence: 1, Evidence: []events.EvidenceRef{{Kind: string(req.Kind), ID: string(req.ID), Note: "redacted"}},
|
||||
Metadata: map[string]any{"redaction_id": r.ID, "target_kind": string(req.Kind), "target_id": req.ID, "policy": req.Policy, "content_hash": r.ContentHash, "cascade_of": cascadeOf},
|
||||
Payload: map[string]any{"redaction_id": r.ID, "reason": req.Reason}}); err != nil {
|
||||
return Redaction{}, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// content returns the JSON of the columns a redaction will remove (nil when
|
||||
// already redacted) and the row's mind, for the hash and the scoping check.
|
||||
func (s *Service) content(ctx context.Context, q storage.Querier, kind Kind, id ids.ID) ([]byte, ids.ID, error) {
|
||||
var mind ids.ID
|
||||
var already *time.Time
|
||||
var raw []byte
|
||||
var err error
|
||||
switch kind {
|
||||
case KindEvent:
|
||||
err = q.QueryRow(ctx, `SELECT mind_id, redacted_at, jsonb_build_object('summary', summary, 'payload', payload, 'metadata', metadata) FROM cognitive_events WHERE id=$1`, id).Scan(&mind, &already, &raw)
|
||||
case KindMemory:
|
||||
err = q.QueryRow(ctx, `SELECT mind_id, redacted_at, jsonb_build_object('content', content, 'detail', detail) FROM memories WHERE id=$1`, id).Scan(&mind, &already, &raw)
|
||||
case KindMessage:
|
||||
err = q.QueryRow(ctx, `SELECT mind_id, redacted_at, jsonb_build_object('content', content, 'intent', intent) FROM messages WHERE id=$1`, id).Scan(&mind, &already, &raw)
|
||||
case KindLLMCall:
|
||||
err = q.QueryRow(ctx, `SELECT coalesce(mind_id, '00000000-0000-0000-0000-000000000000'::uuid), redacted_at, jsonb_build_object('request', request, 'response', response) FROM llm_calls WHERE id=$1`, id).Scan(&mind, &already, &raw)
|
||||
case KindBelief:
|
||||
err = q.QueryRow(ctx, `SELECT mind_id, redacted_at, jsonb_build_object('proposition', proposition, 'detail', detail) FROM beliefs WHERE id=$1`, id).Scan(&mind, &already, &raw)
|
||||
case KindAssertion:
|
||||
err = q.QueryRow(ctx, `SELECT mind_id, redacted_at, jsonb_build_object('proposition', proposition, 'context', context) FROM assertions WHERE id=$1`, id).Scan(&mind, &already, &raw)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ids.Nil, storage.Translate(err)
|
||||
}
|
||||
if already != nil {
|
||||
return nil, mind, nil
|
||||
}
|
||||
return raw, mind, nil
|
||||
}
|
||||
|
||||
type dep struct {
|
||||
kind Kind
|
||||
id ids.ID
|
||||
}
|
||||
|
||||
// dependants lists rows whose content derives from the target: episodes
|
||||
// encoded from an event, the message it carried, assertions made in it,
|
||||
// beliefs whose evidence roots at it, and the model calls that worded the
|
||||
// messages. Cascade is one level from an event; deeper structure keeps its
|
||||
// links but not the content.
|
||||
func (s *Service) dependants(ctx context.Context, q storage.Querier, mind ids.ID, kind Kind, id ids.ID) []dep {
|
||||
var out []dep
|
||||
collect := func(k Kind, sql string, args ...any) {
|
||||
rows, err := q.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var x ids.ID
|
||||
if rows.Scan(&x) == nil && !x.IsZero() {
|
||||
out = append(out, dep{k, x})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case KindEvent:
|
||||
collect(KindMemory, `SELECT id FROM memories WHERE mind_id=$1 AND origin_event=$2 AND redacted_at IS NULL`, mind, id)
|
||||
collect(KindMessage, `SELECT id FROM messages WHERE mind_id=$1 AND event_id=$2 AND redacted_at IS NULL`, mind, id)
|
||||
collect(KindAssertion, `SELECT id FROM assertions WHERE mind_id=$1 AND source_event=$2 AND redacted_at IS NULL`, mind, id)
|
||||
collect(KindBelief, `SELECT DISTINCT belief_id FROM belief_evidence WHERE mind_id=$1 AND $2::uuid = ANY(root_ids)`, mind, id)
|
||||
collect(KindLLMCall, `SELECT llm_call_id FROM messages WHERE mind_id=$1 AND (event_id=$2 OR reply_to IN (SELECT id FROM messages WHERE event_id=$2)) AND llm_call_id IS NOT NULL`, mind, id)
|
||||
collect(KindMessage, `SELECT id FROM messages WHERE mind_id=$1 AND reply_to IN (SELECT id FROM messages WHERE event_id=$2) AND redacted_at IS NULL`, mind, id)
|
||||
case KindMessage:
|
||||
collect(KindLLMCall, `SELECT llm_call_id FROM messages WHERE mind_id=$1 AND id=$2 AND llm_call_id IS NOT NULL`, mind, id)
|
||||
case KindBelief:
|
||||
collect(KindAssertion, `SELECT id FROM assertions WHERE mind_id=$1 AND belief_id=$2 AND redacted_at IS NULL`, mind, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// referencingEvents finds events that quote a row: direct children of an
|
||||
// event, and events whose evidence or metadata point at the row. Redaction
|
||||
// events are never included, so the record of redaction survives.
|
||||
func (s *Service) referencingEvents(ctx context.Context, q storage.Querier, mind ids.ID, kind Kind, id ids.ID) []dep {
|
||||
var out []dep
|
||||
ref := fmt.Sprintf(`[{"kind":%q,"id":%q}]`, string(kind), id.String())
|
||||
metaKey := string(kind) + "_id"
|
||||
rows, err := q.Query(ctx, `SELECT id FROM cognitive_events WHERE mind_id=$1 AND redacted_at IS NULL AND type <> 'redaction'
|
||||
AND (evidence @> $2::jsonb OR metadata->>$3 = $4 OR ($5 AND (causation_id=$6 OR $6 = ANY(parent_ids)))) LIMIT 500`, mind, ref, metaKey, id.String(), kind == KindEvent, id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var x ids.ID
|
||||
if rows.Scan(&x) == nil && x != id {
|
||||
out = append(out, dep{KindEvent, x})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// List returns the ledger for a mind, newest first.
|
||||
func (s *Service) List(ctx context.Context, mind ids.ID, limit int) ([]Redaction, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, target_kind, target_id, reason, policy, content_hash, requested_by, cascade_of, created_at FROM redactions WHERE mind_id=$1 ORDER BY created_at DESC LIMIT $2`, mind, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Redaction
|
||||
for rows.Next() {
|
||||
var r Redaction
|
||||
var kind string
|
||||
var cascade *string
|
||||
if err := rows.Scan(&r.ID, &r.MindID, &kind, &r.TargetID, &r.Reason, &r.Policy, &r.ContentHash, &r.RequestedBy, &cascade, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Kind = Kind(kind)
|
||||
if cascade != nil {
|
||||
r.CascadeOf = ids.ID(*cascade)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func validKind(k Kind) bool {
|
||||
switch k {
|
||||
case KindEvent, KindMemory, KindMessage, KindLLMCall, KindBelief, KindAssertion:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nullID(id ids.ID) *string {
|
||||
if id.IsZero() {
|
||||
return nil
|
||||
}
|
||||
s := string(id)
|
||||
return &s
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Attest recomputes nothing: redacted content is gone. It exposes the hash
|
||||
// so an operator holding an external copy can prove what was removed.
|
||||
func Attest(r Redaction) string { return r.ContentHash }
|
||||
|
||||
var _ = json.Marshal
|
||||
@@ -0,0 +1,197 @@
|
||||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/andydixon/noema/internal/audit"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/epistemics"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/snapshots"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// TestRedactionIsIrrecoverableButReferentiallyIntact: content goes, the
|
||||
// row, its links, its type and time stay; the hash attests what was removed;
|
||||
// derived rows are cascaded; stored archives are scrubbed; and the general
|
||||
// append-only guarantee still holds for every other write.
|
||||
func TestRedactionIsIrrecoverableButReferentiallyIntact(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
ms := minds.NewStore(pool)
|
||||
m, _ := ms.Create(ctx, minds.CreateParams{Slug: "priv"})
|
||||
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
|
||||
mem := memory.NewStore(ctx, pool, nil)
|
||||
bs := beliefs.NewStore(pool)
|
||||
as := epistemics.NewStore(pool)
|
||||
snaps := &snapshots.Store{DB: pool, Events: es, Minds: ms}
|
||||
secret := "Andy said Steve steals laptops from the Leeds office"
|
||||
|
||||
obs, err := es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "conversation", Summary: secret, Payload: events.ObservationPayload{Text: secret, Actor: "andy", Trust: events.TrustUser}, Metadata: map[string]any{"conversation_id": "c1", "channel": "conversation"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child, _ := es.Append(ctx, events.New{MindID: m.ID, Type: events.Perception, Source: "brain.perceive", Summary: "perceived: " + secret, CausationID: obs[0].ID, ParentIDs: []ids.ID{obs[0].ID}})
|
||||
episode, _ := mem.Create(ctx, pool, memory.New{MindID: m.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: "andy said: " + secret, Source: "conversation", Trust: events.TrustUser, Confidence: 1, Importance: 0.5, OccurredAt: time.Now(), OriginEvent: obs[0].ID})
|
||||
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
|
||||
out, err := es.AppendIn(ctx, q, n)
|
||||
if err != nil {
|
||||
return events.Event{}, err
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
res, _ := bs.Create(ctx, pool, emit, beliefs.New{MindID: m.ID, Proposition: "Steve steals laptops", Subject: "steve", Epistemic: concepts.Claim, Source: "conversation",
|
||||
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "assertion", Source: "asserted-by:andy", Family: "actor:andy", Derivation: beliefs.DerivationAssertion, Weight: 0.5, Credibility: 0.85, Note: "andy asserted it", RootIDs: []string{string(obs[0].ID)}}}})
|
||||
assertion, _ := as.Record(ctx, pool, epistemics.New{MindID: m.ID, Actor: "andy", Interpretation: epistemics.Interpret("andy", "Steve steals laptops"), SourceEvent: obs[0].ID, Context: secret, BeliefID: res.Belief.ID})
|
||||
convID, msgID, replyID, callID := ids.New(), ids.New(), ids.New(), ids.New()
|
||||
pool.Exec(ctx, `INSERT INTO conversations (id, mind_id, title, channel) VALUES ($1,$2,'t','conversation')`, convID, m.ID) //nolint:errcheck
|
||||
pool.Exec(ctx, `INSERT INTO llm_calls (id, mind_id, role, provider, model, request_hash, request, response) VALUES ($1,$2,'respond','p','m','h', $3, $4)`, callID, m.ID, `{"messages":[{"content":"`+secret+`"}]}`, `{"text":"noted"}`) //nolint:errcheck
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO messages (id, conversation_id, mind_id, role, actor, content, event_id) VALUES ($1,$2,$3,'user','andy',$4,$5)`, msgID, convID, m.ID, secret, obs[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool.Exec(ctx, `INSERT INTO messages (id, conversation_id, mind_id, role, actor, content, reply_to, llm_call_id) VALUES ($1,$2,$3,'mind','mind','Noted: Steve steals laptops',$4,$5)`, replyID, convID, m.ID, msgID, callID) //nolint:errcheck
|
||||
sn, err := snaps.Create(ctx, m, "before", "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := json.Marshal(mustArchive(t, snaps, sn.ID)); !strings.Contains(string(raw), "Leeds") {
|
||||
t.Fatal("test setup: archive should contain the secret before redaction")
|
||||
}
|
||||
|
||||
svc := &Service{DB: pool, Events: es, Audit: audit.New(pool, log), Snapshots: snaps, Log: log}
|
||||
// Refuses bad requests and rows from other minds.
|
||||
other, _ := ms.Create(ctx, minds.CreateParams{Slug: "other"})
|
||||
if _, _, err := svc.Redact(ctx, Request{MindID: other.ID, Kind: KindEvent, ID: obs[0].ID, Reason: "x", By: "admin"}); err == nil {
|
||||
t.Fatal("redacting another mind's row must fail")
|
||||
}
|
||||
if _, _, err := svc.Redact(ctx, Request{MindID: m.ID, Kind: KindEvent, ID: obs[0].ID, By: "admin"}); err == nil {
|
||||
t.Fatal("a reason is required")
|
||||
}
|
||||
primary, cascaded, err := svc.Redact(ctx, Request{MindID: m.ID, Kind: KindEvent, ID: obs[0].ID, Reason: "data subject request", Policy: "GDPR art. 17", Cascade: true, By: "admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The event exists, keeps type, time, links and ids; content is a marker.
|
||||
ev, _ := es.Get(ctx, obs[0].ID)
|
||||
if ev.Summary != Marker || string(ev.Payload) != `{"redacted": true}` || ev.RedactedAt == nil || ev.Type != events.Observation || ev.Seq != obs[0].Seq {
|
||||
t.Fatalf("event after redaction: %+v payload=%s", ev, ev.Payload)
|
||||
}
|
||||
if ev.Metadata["conversation_id"] != "c1" || ev.Metadata["redacted"] != true {
|
||||
t.Fatalf("metadata should keep only structural keys: %+v", ev.Metadata)
|
||||
}
|
||||
if kids, _ := es.Children(ctx, obs[0].ID, 10); len(kids) != 1 || kids[0].ID != child[0].ID {
|
||||
t.Fatal("links to dependants must survive")
|
||||
}
|
||||
// The hash attests the removed content.
|
||||
want := sha256.Sum256(mustJSONB(t, pool, `SELECT jsonb_build_object('summary', $1::text, 'payload', $2::jsonb, 'metadata', $3::jsonb)`, secret, string(obs[0].Payload), mustJSON(obs[0].Metadata)))
|
||||
if primary.ContentHash != "sha256:"+hex.EncodeToString(want[:]) {
|
||||
t.Fatalf("hash: %s", primary.ContentHash)
|
||||
}
|
||||
// Cascade reached the episode, the message, the reply, the model call, the assertion and the belief.
|
||||
kinds := map[Kind]int{}
|
||||
for _, c := range cascaded {
|
||||
kinds[c.Kind]++
|
||||
if c.CascadeOf != primary.ID {
|
||||
t.Fatalf("cascade lineage missing: %+v", c)
|
||||
}
|
||||
}
|
||||
if kinds[KindMemory] != 1 || kinds[KindMessage] != 2 || kinds[KindLLMCall] != 1 || kinds[KindAssertion] != 1 || kinds[KindBelief] != 1 {
|
||||
t.Fatalf("cascade: %+v", kinds)
|
||||
}
|
||||
if mm, _ := mem.Get(ctx, m.ID, episode.ID); mm.Content != Marker || mm.RedactedAt == nil || mm.OriginEvent != obs[0].ID {
|
||||
t.Fatalf("memory: %+v", mm)
|
||||
}
|
||||
if b, _ := bs.Get(ctx, pool, m.ID, res.Belief.ID); b.Proposition != Marker || b.Confidence != res.Belief.Confidence {
|
||||
t.Fatalf("belief proposition should be redacted and its score untouched: %+v", b)
|
||||
}
|
||||
var vprop, note, msg, reply, req string
|
||||
var resp *string
|
||||
pool.QueryRow(ctx, `SELECT proposition FROM belief_versions WHERE belief_id=$1 LIMIT 1`, res.Belief.ID).Scan(&vprop) //nolint:errcheck
|
||||
pool.QueryRow(ctx, `SELECT note FROM belief_evidence WHERE belief_id=$1 LIMIT 1`, res.Belief.ID).Scan(¬e) //nolint:errcheck
|
||||
pool.QueryRow(ctx, `SELECT content FROM messages WHERE id=$1`, msgID).Scan(&msg) //nolint:errcheck
|
||||
pool.QueryRow(ctx, `SELECT content FROM messages WHERE id=$1`, replyID).Scan(&reply) //nolint:errcheck
|
||||
pool.QueryRow(ctx, `SELECT request::text, response::text FROM llm_calls WHERE id=$1`, callID).Scan(&req, &resp) //nolint:errcheck
|
||||
if vprop != Marker || note != Marker || msg != Marker || reply != Marker || strings.Contains(req, "Leeds") || resp != nil {
|
||||
t.Fatalf("cascade content: version=%q note=%q msg=%q reply=%q req=%q", vprop, note, msg, reply, req)
|
||||
}
|
||||
if a, _ := as.List(ctx, m.ID, epistemics.Filter{Limit: 5}); len(a) != 1 || a[0].Proposition != Marker || a[0].ID != assertion.ID {
|
||||
t.Fatalf("assertion: %+v", a)
|
||||
}
|
||||
// Nothing in the live database still holds the secret.
|
||||
var leaks int
|
||||
pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM cognitive_events WHERE mind_id=$1 AND (summary ILIKE '%leeds%' OR payload::text ILIKE '%leeds%' OR metadata::text ILIKE '%leeds%'))
|
||||
+ (SELECT count(*) FROM memories WHERE mind_id=$1 AND content ILIKE '%leeds%') + (SELECT count(*) FROM messages WHERE mind_id=$1 AND content ILIKE '%leeds%')
|
||||
+ (SELECT count(*) FROM llm_calls WHERE mind_id=$1 AND request::text ILIKE '%leeds%') + (SELECT count(*) FROM assertions WHERE mind_id=$1 AND (proposition ILIKE '%leeds%' OR context ILIKE '%leeds%'))`, m.ID).Scan(&leaks) //nolint:errcheck
|
||||
if leaks != 0 {
|
||||
t.Fatalf("secret still present in %d rows", leaks)
|
||||
}
|
||||
// Stored archives were scrubbed and re-signed; they still validate.
|
||||
a := mustArchive(t, snaps, sn.ID)
|
||||
if raw, _ := json.Marshal(a); strings.Contains(string(raw), "Leeds") || strings.Contains(string(raw), "steals") {
|
||||
t.Fatal("archive still holds redacted content")
|
||||
}
|
||||
if len(a.Redacted) < 3 || snapshots.Validate(a) != nil {
|
||||
t.Fatalf("archive scrub record or checksum: %v %v", a.Redacted, snapshots.Validate(a))
|
||||
}
|
||||
// The ledger and the history record what happened, not what was removed.
|
||||
ledger, _ := svc.List(ctx, m.ID, 20)
|
||||
if len(ledger) != 1+len(cascaded) || ledger[0].Reason != "data subject request" {
|
||||
t.Fatalf("ledger: %d", len(ledger))
|
||||
}
|
||||
reds, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.Redaction}, Limit: 20})
|
||||
if len(reds) != 1+len(cascaded) || strings.Contains(reds[0].Summary, "Leeds") {
|
||||
t.Fatalf("redaction events: %d", len(reds))
|
||||
}
|
||||
// Idempotent and still append-only for everything else.
|
||||
if _, _, err := svc.Redact(ctx, Request{MindID: m.ID, Kind: KindEvent, ID: obs[0].ID, Reason: "again", By: "admin"}); err == nil {
|
||||
t.Fatal("second redaction should report already redacted")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE cognitive_events SET summary='tampered' WHERE id=$1`, child[0].ID); err == nil {
|
||||
t.Fatal("plain updates must still be forbidden")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM cognitive_events WHERE id=$1`, obs[0].ID); err == nil {
|
||||
t.Fatal("deletes must still be forbidden, even of redacted rows")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE redactions SET reason='x' WHERE id=$1`, primary.ID); err == nil {
|
||||
t.Fatal("the ledger is append-only")
|
||||
}
|
||||
}
|
||||
|
||||
func mustArchive(t *testing.T, s *snapshots.Store, id ids.ID) snapshots.Archive {
|
||||
t.Helper()
|
||||
a, err := s.Archive(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func mustJSONB(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) []byte {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
if err := pool.QueryRow(context.Background(), sql, args...).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user