Files
Cyrene_v2/internal/runtime/memory/rules.go
T

149 lines
6.0 KiB
Go

package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// Scope access control sentinels. These surface when a persona attempts a write
// it is not permitted to make under the design §3 rules.
var (
// ErrReadOnly is returned when a persona tries to write or delete a
// persona-scope memory item it does not own. Under design §3 other personas
// are read-only for 角色日常记忆.
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
// ErrMissingProjectID is returned when a project-scoped query has a blank id.
ErrMissingProjectID = errors.New("memory: project_id is required")
)
// canWrite reports whether actorID may write item under the design §3 scope rules:
//
// - ScopeUser: any persona may write (user habits are globally shared).
// - ScopePersona: only the owning persona may write (other personas read-only).
// - ScopeProject: any persona may write (project memory is 人人可写).
//
// The caller must have already validated item.Scope; an invalid scope is not
// writable by anyone.
func canWrite(actorID string, it MemoryItem) bool {
switch it.Scope {
case ScopeUser, ScopeProject:
return true
case ScopePersona:
return it.PersonaID == actorID
default:
return false
}
}
// AddScoped persists item under actorID's authority, enforcing the design §3
// write rules by persona_id + scope. A blank actorID returns ErrMissingPersona.
// When item.PersonaID is blank it is filled with actorID, so a persona always
// records its own provenance; a persona-scope item owned by a different persona
// returns ErrReadOnly. User- and project-scope items are writable by any persona.
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
if strings.TrimSpace(actorID) == "" {
return MemoryItem{}, ErrMissingPersona
}
if item.PersonaID == "" {
item.PersonaID = actorID
}
if !item.Scope.valid() {
return MemoryItem{}, ErrInvalidScope
}
if !canWrite(actorID, item) {
return MemoryItem{}, ErrReadOnly
}
return s.Add(ctx, item)
}
// ListScope returns every item of scope visible to personaID, newest first,
// applying the design §3 read rules:
//
// - ScopeUser: every user-scope item (globally shared across personas).
// - ScopePersona: every persona-scope item. Other personas are read-only but
// still readable, so a persona can read another persona's 角色日常记忆 here.
// - ScopeProject: every project-scope item (人人可读).
//
// personaID names the reading persona and is required so callers always declare
// the identity boundary even for a shared scope. The higher policy layer still
// decides whether a restricted context (group chat / stranger, design §5) may
// receive these; this method only enforces the base read rule.
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
}
if !scope.valid() {
return nil, ErrInvalidScope
}
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE scope = ?
ORDER BY created_at DESC`, string(scope))
if err != nil {
return nil, fmt.Errorf("memory: list scope items: %w", err)
}
return scanItems(rows, "scope item")
}
// ListProject returns every memory item linked to projectID, newest first. A
// project-scoped memory carries a ProjectID that links to the projectindex
// hierarchy (design §3); this is the deep-search entry point — callers combine
// it with the projectindex store to walk 项目索引 → 项目描述 → 章节内容. Project
// memory is readable by all personas, so no persona filter is applied. A blank
// projectID returns ErrMissingProjectID.
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProjectID
}
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE project_id = ?
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, fmt.Errorf("memory: list project items: %w", err)
}
return scanItems(rows, "project item")
}
// DeleteScoped removes the item with the given id, but only if actorID may write
// it under the design §3 rules. This enforces the persona read-only boundary on
// deletion: a persona may delete its own persona-scope items and any user- or
// project-scope item, but not another persona's 角色日常记忆. A blank id returns
// ErrNotFound; writing a foreign persona-scope item returns ErrReadOnly.
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
if strings.TrimSpace(actorID) == "" {
return ErrMissingPersona
}
item, err := s.Get(ctx, id)
if err != nil {
return fmt.Errorf("memory: delete scoped load: %w", err)
}
if !canWrite(actorID, item) {
return ErrReadOnly
}
return s.Delete(ctx, id)
}
// ensureProjectIDColumn rolls the memory_items table forward when it was created
// before the M4 project index (design §3) existed: it adds the project_id column
// if it is missing, and ignores the duplicate-column result when present. It is
// SQLite-specific (the memory store is SQLite-backed) and idempotent, so NewStore
// may be called on a fresh schema or a pre-existing one.
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// SQLite reports "duplicate column name: project_id" when the column already
// exists (e.g. created by the CREATE TABLE in NewStore, or by a previous run).
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward project_id column: %w", err)
}