diff --git a/docs/snapshots-replay.md b/docs/snapshots-replay.md index e58de1f..371c667 100644 --- a/docs/snapshots-replay.md +++ b/docs/snapshots-replay.md @@ -5,7 +5,7 @@ A **snapshot** is a JSON archive of a mind's state at a point in its history: identity, personality, configuration, workspace and working memory, drives and affect, goals, beliefs (with evidence, versions, alternatives, links), memories -(with relationships), predictions, entities, corrections, policies and +(with relationships), predictions, entities, corrections, policy rules (kept by forks, never imported from archives) and conversations. It records the event sequence number at capture and a SHA-256 checksum. Cognitive events are **not** copied. diff --git a/internal/api/study.go b/internal/api/study.go index 2462d57..02cd6f9 100644 --- a/internal/api/study.go +++ b/internal/api/study.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" @@ -190,15 +191,19 @@ func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, b if err := dec.Decode(&a); err != nil { return minds.Mind{}, ValidationError("invalid archive: " + err.Error()) } - m, err := s.deps.Snapshots.Import(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug}) + m, omitted, err := s.deps.Snapshots.ImportReport(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug}) if err != nil { if errors.Is(err, snapshots.ErrInvalid) { return m, ValidationError(err.Error()) } return m, mindErr(err) } - s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts()}) - s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", "Mind imported from archive of "+a.Source.Slug+" ("+a.Checksum+")", map[string]any{"counts": a.Counts()}) + s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts(), "omitted": omitted}) + summary := "Mind imported from archive of " + a.Source.Slug + " (" + a.Checksum + ")" + if n := omitted["policies"]; n > 0 { + summary += fmt.Sprintf("; %d policy rule(s) in the archive were not imported: policies are set by this instance's operators", n) + } + s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", summary, map[string]any{"counts": a.Counts(), "omitted": omitted}) s.deps.Brain.Reconcile(ctx) //nolint:errcheck return m, nil } diff --git a/internal/snapshots/archive.go b/internal/snapshots/archive.go index bf62c75..2a33590 100644 --- a/internal/snapshots/archive.go +++ b/internal/snapshots/archive.go @@ -248,13 +248,21 @@ func contains(xs []string, x string) bool { var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +// AuthorityTables hold rows that grant or shape authority rather than +// describe cognitive state. They are copied by Fork (same operator, same +// instance) but never by Import: an archive from elsewhere must not be able +// to arrive carrying ALLOW rules for a mind (THREAT_MODEL T16). +var AuthorityTables = map[string]bool{"policies": true} + // Restore writes an archive's rows into mind `into`. Every row id found in // the archive gets a fresh id and all references to it (any uuid-shaped // string anywhere in the rows, including arrays and JSON detail) follow. // References to ids not in the archive (events, templates) are kept: that // is how history stays shared by reference. Must run in the caller's -// transaction after the mind row exists. -func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID) error { +// transaction after the mind row exists. Tables named in skip are not +// written; the returned map reports how many rows were omitted per table. +func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID, skip map[string]bool) (map[string]int, error) { + omitted := map[string]int{} remap := map[string]string{a.Source.MindID.String(): into.String()} for _, t := range Tables { for _, r := range a.Tables[t] { @@ -270,9 +278,13 @@ func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive if len(rows) == 0 { continue } + if skip[t] { + omitted[t] = len(rows) + continue + } cs := cols[t] if len(cs) == 0 { - return fmt.Errorf("%w: table %s not present", ErrInvalid, t) + return omitted, fmt.Errorf("%w: table %s not present", ErrInvalid, t) } conflict := "" if key, ok := keyedByMind[t]; ok { @@ -291,11 +303,11 @@ func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive row["mind_id"] = into.String() b, _ := json.Marshal(row) if _, err := tx.Exec(ctx, sql, b); err != nil { - return fmt.Errorf("restore %s: %w", t, err) + return omitted, fmt.Errorf("restore %s: %w", t, err) } } } - return nil + return omitted, nil } func remapValue(v any, remap map[string]string) any { @@ -420,6 +432,10 @@ type ForkOptions struct { Config *minds.CognitiveConfig Personality *minds.Personality By string + // OmitAuthority skips AuthorityTables (policies). Import sets it; Fork does not. + OmitAuthority bool + // Omitted is filled by the restore with the rows skipped per table. + Omitted map[string]int } // Fork creates a new mind from a snapshot. The new mind's state equals the @@ -430,7 +446,7 @@ func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (min if err != nil { return minds.Mind{}, err } - m, err := s.restoreNew(ctx, a, o) + m, err := s.restoreNew(ctx, a, &o) if err != nil { return m, err } @@ -441,14 +457,26 @@ func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (min } // Import validates a hostile archive and restores it into a new mind. +// Authority-bearing tables are never imported: an archive is data about a +// mind, not a grant of permissions on this instance. The number of skipped +// rows is reported in o.Omitted when the caller passes a pointer-free copy; +// use ImportReport to receive it. func (s *Store) Import(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) { - if err := Validate(a); err != nil { - return minds.Mind{}, err - } - return s.restoreNew(ctx, a, o) + m, _, err := s.ImportReport(ctx, a, o) + return m, err } -func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) { +// ImportReport is Import that also returns the rows omitted per table. +func (s *Store) ImportReport(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, map[string]int, error) { + if err := Validate(a); err != nil { + return minds.Mind{}, nil, err + } + o.OmitAuthority = true + m, err := s.restoreNew(ctx, a, &o) + return m, o.Omitted, err +} + +func (s *Store) restoreNew(ctx context.Context, a Archive, o *ForkOptions) (minds.Mind, error) { cfg := a.Mind.Config if o.Config != nil { cfg = *o.Config @@ -477,8 +505,14 @@ func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds if err != nil { return m, err } + var skip map[string]bool + if o.OmitAuthority { + skip = AuthorityTables + } err = storage.Tx(ctx, s.DB, func(tx pgx.Tx) error { - if err := Restore(ctx, tx, cols, a, m.ID); err != nil { + omitted, err := Restore(ctx, tx, cols, a, m.ID, skip) + o.Omitted = omitted + if err != nil { return err } if o.Status != "" && o.Status != minds.StatusActive { diff --git a/internal/snapshots/snapshots_test.go b/internal/snapshots/snapshots_test.go index 5ef56e0..c2f26c0 100644 --- a/internal/snapshots/snapshots_test.go +++ b/internal/snapshots/snapshots_test.go @@ -43,6 +43,10 @@ func TestSnapshotForkImportCompare(t *testing.T) { Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: string(m1.ID), Source: "user", Weight: 0.5, Credibility: 0.9}}}); err != nil { t.Fatal(err) } + // An ALLOW rule travels with a fork (same operator) but never with an import. + if _, err := pool.Exec(ctx, `INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect, reason, created_by) VALUES ($1,$2,'http.fetch','*','*','allow','test','andy')`, ids.New(), src.ID); err != nil { + t.Fatal(err) + } st := &Store{DB: pool, Events: es, Minds: ms} sn, err := st.Create(ctx, src, "before", "test snapshot", "andy") if err != nil { @@ -110,10 +114,16 @@ func TestSnapshotForkImportCompare(t *testing.T) { if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil { t.Fatal("unknown table accepted") } - imp, err := st.Import(ctx, a, ForkOptions{Slug: "imported", By: "andy"}) + imp, omitted, err := st.ImportReport(ctx, a, ForkOptions{Slug: "imported", By: "andy"}) if err != nil { t.Fatal(err) } + var forkPolicies, importPolicies int + pool.QueryRow(ctx, `SELECT count(*) FROM policies WHERE mind_id=$1`, fork.ID).Scan(&forkPolicies) //nolint:errcheck + pool.QueryRow(ctx, `SELECT count(*) FROM policies WHERE mind_id=$1`, imp.ID).Scan(&importPolicies) //nolint:errcheck + if forkPolicies != 1 || importPolicies != 0 || omitted["policies"] != 1 { + t.Fatalf("policy handling: fork=%d import=%d omitted=%v", forkPolicies, importPolicies, omitted) + } if im, _ := mem.List(ctx, imp.ID, memory.Filter{Limit: 10}); len(im) != 2 { t.Fatalf("import memories: %d", len(im)) }