Document Model & Persistence — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the storage substrate for the collaborative-annotations initiative — schema, write funnel, hashes utilities, git commit helper, source-write protocol, startup recovery, and config/main wire-up — so downstream sub-projects (#2 anchoring, #4 incorporation, #5 perspectives) have a foundation to build on.

Architecture: A new internal/collab package owns one SQLite database (wiki-browser-collab.db) for all non-Source state and provides the Incorporation protocol that crosses git + SQLite. Mirrors the patterns in internal/index (modernc.org/sqlite, WAL mode, single-goroutine write funnel) but with a real migration system, foreign-key enforcement, and crash-safe two-store writes.

Tech Stack: Go 1.22+, modernc.org/sqlite (pure-Go, already in go.mod), gopkg.in/yaml.v3 (existing), Go's database/sql, standard library crypto/sha256 and os/exec for git.

Reference spec: docs/superpowers/specs/2026-05-11-document-model-design.html. When in doubt about why a decision is the way it is, check the decisions log.


File Structure

internal/collab/
├── collab.go                  # Store type, Open, Close, write funnel scaffold
├── collab_test.go
├── schema.go                  # Embed of migrations/*.sql
├── migrate.go                 # Forward-only migration runner
├── migrate_test.go
├── migrations/
│   └── 001_initial.sql        # Full schema from the spec
├── hashes.go                  # source_sha (git blob hash) + persona_sha (SHA-256)
├── hashes_test.go
├── gitops.go                  # Commit helper, working-tree blob-hash utilities
├── gitops_test.go
├── mutators.go                # Typed write operations (one per table family)
├── mutators_test.go
├── incorporate.go             # 5-step Incorporation protocol
├── incorporate_test.go
├── recover.go                 # Startup recovery procedure (3 cases)
└── recover_test.go

internal/config/
└── config.go                  # MODIFIED: add Operator and CollabDB fields

cmd/wiki-browser/
└── main.go                    # MODIFIED: open Store, run Recover, defer Close

wiki-browser.example.yaml      # MODIFIED: add example operator block + collab_db key

The migrations directory is embedded into the binary via embed.FS; SQL files there are the canonical source of schema truth. No SQL strings inline in Go code.


Task 1: Config — operator block + collab_db key

Files:

The existing TestLoad_valid hard-codes the expected Config struct — once we add new required fields, that expected struct must include them or the test fails for the wrong reason. Likewise, every testdata YAML must satisfy the new operator.user_id / operator.display_name required validators, or every existing test breaks.

In internal/config/testdata/valid.yaml, append:

collab_db: "./wiki-browser-collab.db"
operator:
  user_id: "alice"
  display_name: "Alice Example"

In internal/config/testdata/minimal.yaml, append:

operator:
  user_id: "alice"
  display_name: "Alice Example"

In internal/config/testdata/missing-root.yaml, append (so the test fails for the missing-root reason, not for missing operator):

operator:
  user_id: "alice"
  display_name: "Alice Example"

In internal/config/testdata/bad-root.yaml, append the same block, for the same reason.

In internal/config/config_test.go:

a) Update the want := &config.Config{...} literal in TestLoad_valid to include the new fields:

	want := &config.Config{
		Listen:     ":8080",
		Title:      "Orcha wiki",
		Root:       "/tmp",
		Extensions: []string{".md", ".html"},
		IndexDB:    "./wiki-browser-index.db",
		CollabDB:   "./wiki-browser-collab.db",
		Exclude:    []string{"www/**", "marketing/**"},
		Operator: config.Operator{
			UserID:      "alice",
			DisplayName: "Alice Example",
		},
	}

b) Append two new tests at the bottom of the file:

func TestLoad_collabDBDefault(t *testing.T) {
	got, err := config.Load(filepath.Join("testdata", "minimal.yaml"))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if got.CollabDB != "./wiki-browser-collab.db" {
		t.Errorf("CollabDB default not applied: got %q", got.CollabDB)
	}
}

func TestLoad_operatorRequired(t *testing.T) {
	// Build a tmp YAML that has root but no operator block.
	tmp := t.TempDir()
	path := filepath.Join(tmp, "no-operator.yaml")
	body := `root: "` + tmp + `"
`
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := config.Load(path); err == nil {
		t.Error("expected error when operator is missing, got nil")
	}
}

Add "os" to the imports if it isn't already there.

Run: go test ./internal/config/... -v Expected: at minimum, TestLoad_valid fails because CollabDB / Operator aren't on the struct yet, and TestLoad_operatorRequired fails because the new validation isn't wired.

In internal/config/config.go:

type Config struct {
	Listen     string   `yaml:"listen"`
	Title      string   `yaml:"title"`
	Root       string   `yaml:"root"`
	Extensions []string `yaml:"extensions"`
	IndexDB    string   `yaml:"index_db"`
	CollabDB   string   `yaml:"collab_db"`
	Exclude    []string `yaml:"exclude"`
	Operator   Operator `yaml:"operator"`
}

// Operator identifies the single user attributed to all collaborative
// actions in v1. Auth/authz is an orthogonal project that will later
// replace this bootstrap with real provisioning.
type Operator struct {
	UserID      string `yaml:"user_id"`
	DisplayName string `yaml:"display_name"`
}

In applyDefaults:

	if c.CollabDB == "" {
		c.CollabDB = "./wiki-browser-collab.db"
	}

In validate (before the final return nil):

	if c.Operator.UserID == "" {
		return fmt.Errorf("operator.user_id is required")
	}
	if c.Operator.DisplayName == "" {
		return fmt.Errorf("operator.display_name is required")
	}

Modify wiki-browser.example.yaml. Add after the exclude: block:

collab_db: "./wiki-browser-collab.db"

# Single-operator bootstrap. Auth is out of scope for v1; this row is
# inserted into the collab DB's users table at startup, and every Topic,
# message, and proposal in v1 attributes to this user.
operator:
  user_id:      "daniel"
  display_name: "Daniel Barreto"

Run: go test ./internal/config/... -v Expected: PASS, all tests (existing + the two new ones).

git add internal/config/config.go internal/config/config_test.go internal/config/testdata/valid.yaml internal/config/testdata/minimal.yaml internal/config/testdata/missing-root.yaml internal/config/testdata/bad-root.yaml wiki-browser.example.yaml
git commit -m "wiki-browser: config — operator block and collab_db path"

Task 2: Migrate package — forward-only migration runner

Files:

// internal/collab/migrate_test.go
package collab

import (
	"database/sql"
	"path/filepath"
	"testing"
	"testing/fstest"

	_ "modernc.org/sqlite"
)

func TestMigrate_applies_in_order(t *testing.T) {
	db := openMemDB(t)
	mfs := fstest.MapFS{
		"001_a.sql": {Data: []byte("CREATE TABLE a (id INTEGER);")},
		"002_b.sql": {Data: []byte("CREATE TABLE b (id INTEGER);")},
	}
	if err := Migrate(db, mfs); err != nil {
		t.Fatalf("Migrate: %v", err)
	}
	for _, name := range []string{"a", "b"} {
		var n int
		err := db.QueryRow(
			"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name,
		).Scan(&n)
		if err != nil || n != 1 {
			t.Errorf("table %s missing (err=%v, n=%d)", name, err, n)
		}
	}
}

func TestMigrate_skips_already_applied(t *testing.T) {
	db := openMemDB(t)
	mfs := fstest.MapFS{
		"001_a.sql": {Data: []byte("CREATE TABLE a (id INTEGER);")},
	}
	if err := Migrate(db, mfs); err != nil {
		t.Fatal(err)
	}
	// Second run should not error (would error if it tried to recreate the table).
	if err := Migrate(db, mfs); err != nil {
		t.Fatalf("second Migrate: %v", err)
	}
}

func TestMigrate_records_in_schema_migrations(t *testing.T) {
	db := openMemDB(t)
	mfs := fstest.MapFS{
		"001_a.sql": {Data: []byte("CREATE TABLE a (id INTEGER);")},
		"002_b.sql": {Data: []byte("CREATE TABLE b (id INTEGER);")},
	}
	if err := Migrate(db, mfs); err != nil {
		t.Fatal(err)
	}
	rows, err := db.Query("SELECT filename FROM schema_migrations ORDER BY filename")
	if err != nil {
		t.Fatal(err)
	}
	defer rows.Close()
	var got []string
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			t.Fatal(err)
		}
		got = append(got, s)
	}
	if want := []string{"001_a.sql", "002_b.sql"}; !equal(got, want) {
		t.Errorf("schema_migrations = %v, want %v", got, want)
	}
}

func openMemDB(t *testing.T) *sql.DB {
	t.Helper()
	db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db")+"?_pragma=foreign_keys(1)")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = db.Close() })
	return db
}

func equal(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

Run: go test ./internal/collab/... -run TestMigrate -v Expected: FAIL — Migrate function and openMemDB helper don't exist yet.

// internal/collab/migrate.go
package collab

import (
	"database/sql"
	"fmt"
	"io/fs"
	"sort"
	"strings"
)

// Migrate applies any .sql files in mfs whose filename has not already been
// recorded in schema_migrations. Files are applied in lexicographic order;
// each file runs in its own transaction.
//
// Migrations are forward-only: there is no down direction.
func Migrate(db *sql.DB, mfs fs.FS) error {
	if _, err := db.Exec(
		`CREATE TABLE IF NOT EXISTS schema_migrations (
		   filename   TEXT PRIMARY KEY,
		   applied_at INTEGER NOT NULL DEFAULT (unixepoch())
		 )`,
	); err != nil {
		return fmt.Errorf("create schema_migrations: %w", err)
	}

	applied, err := loadApplied(db)
	if err != nil {
		return err
	}

	entries, err := fs.ReadDir(mfs, ".")
	if err != nil {
		return fmt.Errorf("read migrations dir: %w", err)
	}
	var names []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if !strings.HasSuffix(e.Name(), ".sql") {
			continue
		}
		names = append(names, e.Name())
	}
	sort.Strings(names)

	for _, name := range names {
		if applied[name] {
			continue
		}
		body, err := fs.ReadFile(mfs, name)
		if err != nil {
			return fmt.Errorf("read %s: %w", name, err)
		}
		if err := applyOne(db, name, string(body)); err != nil {
			return err
		}
	}
	return nil
}

func loadApplied(db *sql.DB) (map[string]bool, error) {
	rows, err := db.Query("SELECT filename FROM schema_migrations")
	if err != nil {
		return nil, fmt.Errorf("load applied: %w", err)
	}
	defer rows.Close()
	out := map[string]bool{}
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return nil, err
		}
		out[s] = true
	}
	return out, rows.Err()
}

func applyOne(db *sql.DB, name, body string) error {
	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("begin %s: %w", name, err)
	}
	if _, err := tx.Exec(body); err != nil {
		_ = tx.Rollback()
		return fmt.Errorf("apply %s: %w", name, err)
	}
	if _, err := tx.Exec(
		`INSERT INTO schema_migrations(filename) VALUES (?)`, name,
	); err != nil {
		_ = tx.Rollback()
		return fmt.Errorf("record %s: %w", name, err)
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit %s: %w", name, err)
	}
	return nil
}

Run: mkdir -p internal/collab/migrations && touch internal/collab/migrations/.gitkeep

Run: go test ./internal/collab/... -run TestMigrate -v Expected: PASS, three test functions.

git add internal/collab/migrate.go internal/collab/migrate_test.go internal/collab/migrations/.gitkeep
git commit -m "wiki-browser: collab — forward-only migration runner"

Task 3: Schema migration (001_initial.sql)

Files:

Append to internal/collab/migrate_test.go:

func TestMigrate_realSchema_smoke(t *testing.T) {
	db := openMemDB(t)
	if err := Migrate(db, MigrationsFS); err != nil {
		t.Fatalf("Migrate(real): %v", err)
	}
	// Every table named in the spec must exist.
	for _, name := range []string{
		"users", "topics", "topic_messages",
		"incorporation_proposals", "incorporation_attempts",
		"perspective_defs", "perspectives",
	} {
		var n int
		err := db.QueryRow(
			"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", name,
		).Scan(&n)
		if err != nil || n != 1 {
			t.Errorf("table %s missing (err=%v, n=%d)", name, err, n)
		}
	}
}

func TestMigrate_realSchema_fkEnforced(t *testing.T) {
	db := openMemDB(t)
	if err := Migrate(db, MigrationsFS); err != nil {
		t.Fatal(err)
	}
	// Insert a topic that references a non-existent user — should fail.
	_, err := db.Exec(
		`INSERT INTO topics(id, source_path, created_at, created_by, updated_at)
		 VALUES ('t1', 'foo.md', 0, 'nobody', 0)`,
	)
	if err == nil {
		t.Error("expected FK violation, got nil")
	}
}

func TestMigrate_realSchema_topicCHECKs(t *testing.T) {
	db := openMemDB(t)
	if err := Migrate(db, MigrationsFS); err != nil {
		t.Fatal(err)
	}
	if _, err := db.Exec(`INSERT INTO users(id, display_name, created_at) VALUES('u1', 'u', 0)`); err != nil {
		t.Fatal(err)
	}
	// Both incorporated and discarded at once — must violate the mutual-exclusion CHECK.
	_, err := db.Exec(
		`INSERT INTO topics(
		   id, source_path,
		   commit_sha, incorporated_proposal_id, incorporated_by, incorporated_at,
		   discarded_by, discarded_at,
		   created_at, created_by, updated_at
		 ) VALUES (
		   't1', 'foo.md',
		   'sha', 'p1', 'u1', 1,
		   'u1', 1,
		   0, 'u1', 0
		 )`,
	)
	if err == nil {
		t.Error("expected CHECK violation (incorporated AND discarded), got nil")
	}
}

Run: go test ./internal/collab/... -run 'TestMigrate_realSchema' -v Expected: FAIL — MigrationsFS symbol doesn't exist.

Create internal/collab/migrations/001_initial.sql:

-- Users — auth/authz orthogonal; stubbed here for FK targets.
CREATE TABLE users (
  id           TEXT PRIMARY KEY,
  display_name TEXT NOT NULL,
  created_at   INTEGER NOT NULL
);

-- Topics — one row per discussion thread on a Document.
-- Outcome columns (commit_sha, incorporated_*, discarded_*) are null while open.
-- CHECK constraints enforce mutual consistency.
CREATE TABLE topics (
  id                       TEXT PRIMARY KEY,
  source_path              TEXT NOT NULL,
  anchor                   TEXT,
  commit_sha               TEXT,
  incorporated_proposal_id TEXT,
  incorporated_by          TEXT,
  incorporated_at          INTEGER,
  discarded_by             TEXT,
  discarded_at             INTEGER,
  created_at               INTEGER NOT NULL,
  created_by               TEXT NOT NULL,
  updated_at               INTEGER NOT NULL,
  CHECK ((commit_sha               IS NULL) = (incorporated_at IS NULL)),
  CHECK ((incorporated_proposal_id IS NULL) = (incorporated_at IS NULL)),
  CHECK ((incorporated_by          IS NULL) = (incorporated_at IS NULL)),
  CHECK ((discarded_by             IS NULL) = (discarded_at    IS NULL)),
  CHECK (incorporated_at IS NULL OR discarded_at IS NULL),
  FOREIGN KEY (created_by)      REFERENCES users(id),
  FOREIGN KEY (incorporated_by) REFERENCES users(id),
  FOREIGN KEY (discarded_by)    REFERENCES users(id),
  FOREIGN KEY (incorporated_proposal_id, id)
    REFERENCES incorporation_proposals(id, topic_id)
);
CREATE INDEX topics_by_source_path ON topics(source_path);

-- Topic messages — narrative of the conversation.
-- sequence is monotonic per topic; allocated by the write funnel.
CREATE TABLE topic_messages (
  id              TEXT PRIMARY KEY,
  topic_id        TEXT NOT NULL,
  kind            TEXT NOT NULL,
  body            TEXT NOT NULL,
  author_user_id  TEXT,
  proposal_id     TEXT,
  sequence        INTEGER NOT NULL,
  created_at      INTEGER NOT NULL,
  FOREIGN KEY (topic_id)       REFERENCES topics(id),
  FOREIGN KEY (author_user_id) REFERENCES users(id),
  FOREIGN KEY (proposal_id)    REFERENCES incorporation_proposals(id)
);
CREATE UNIQUE INDEX topic_messages_topic_sequence
  ON topic_messages(topic_id, sequence);

-- Incorporation proposals — append-only event log.
-- base_source_sha pins the Source content the Agent generated against.
CREATE TABLE incorporation_proposals (
  id              TEXT PRIMARY KEY,
  topic_id        TEXT NOT NULL,
  revision_number INTEGER NOT NULL,
  proposed_source TEXT NOT NULL,
  base_source_sha TEXT NOT NULL,
  proposed_by     TEXT NOT NULL,
  created_at      INTEGER NOT NULL,
  FOREIGN KEY (topic_id)    REFERENCES topics(id),
  FOREIGN KEY (proposed_by) REFERENCES users(id)
);
CREATE UNIQUE INDEX incorporation_proposals_topic_rev
  ON incorporation_proposals(topic_id, revision_number);
CREATE UNIQUE INDEX incorporation_proposals_id_topic
  ON incorporation_proposals(id, topic_id);

-- Incorporation attempts — recovery marker inserted before Source is written.
CREATE TABLE incorporation_attempts (
  id              TEXT PRIMARY KEY,
  proposal_id     TEXT NOT NULL UNIQUE,
  topic_id        TEXT NOT NULL,
  source_path     TEXT NOT NULL,
  base_source_sha TEXT NOT NULL,
  approved_by     TEXT NOT NULL,
  approved_at     INTEGER NOT NULL,
  committed_sha   TEXT,
  completed_at    INTEGER,
  created_at      INTEGER NOT NULL,
  CHECK ((committed_sha IS NULL) = (completed_at IS NULL)),
  FOREIGN KEY (proposal_id, topic_id)
    REFERENCES incorporation_proposals(id, topic_id),
  FOREIGN KEY (approved_by) REFERENCES users(id)
);
CREATE INDEX incorporation_attempts_incomplete
  ON incorporation_attempts(completed_at);

-- Perspective definitions — per-Document personas.
CREATE TABLE perspective_defs (
  source_path  TEXT NOT NULL,
  id           TEXT NOT NULL,
  label        TEXT NOT NULL,
  persona      TEXT NOT NULL,
  persona_sha  TEXT NOT NULL,
  created_at   INTEGER NOT NULL,
  updated_at   INTEGER NOT NULL,
  PRIMARY KEY (source_path, id)
);

-- Perspectives — generated content, sha-keyed on both Source and persona.
CREATE TABLE perspectives (
  source_path     TEXT NOT NULL,
  perspective_id  TEXT NOT NULL,
  source_sha      TEXT NOT NULL,
  persona_sha     TEXT NOT NULL,
  body            TEXT NOT NULL,
  generated_at    INTEGER NOT NULL,
  PRIMARY KEY (source_path, perspective_id, source_sha, persona_sha)
);

Create internal/collab/schema.go:

package collab

import "embed"

//go:embed migrations/*.sql
var migrationsRaw embed.FS

// MigrationsFS is the canonical migration set baked into the binary.
// Filenames are forward-only and lexicographically ordered.
var MigrationsFS = mustSub(migrationsRaw, "migrations")

func mustSub(efs embed.FS, dir string) interface {
	// fs.FS interface — kept here as a local alias to avoid an import cycle in tests.
} {
	sub, err := efs.ReadDir(dir)
	_ = sub
	if err != nil {
		panic(err)
	}
	out, err := efs.Open(dir)
	_ = out
	if err != nil {
		panic(err)
	}
	return mustSubFS(efs, dir)
}

Actually that's overcomplicated — use Go's fs.Sub directly. Replace the file with:

package collab

import (
	"embed"
	"io/fs"
)

//go:embed migrations/*.sql
var migrationsRaw embed.FS

// MigrationsFS is the canonical migration set baked into the binary,
// rooted at "migrations/" so callers see filenames like "001_initial.sql"
// (not "migrations/001_initial.sql"). Forward-only, lexicographically ordered.
var MigrationsFS fs.FS = mustSub(migrationsRaw, "migrations")

func mustSub(efs embed.FS, dir string) fs.FS {
	sub, err := fs.Sub(efs, dir)
	if err != nil {
		panic(err)
	}
	return sub
}

Also: remove internal/collab/migrations/.gitkeep now that there is a real .sql file in the directory.

Run: rm internal/collab/migrations/.gitkeep

Run: go test ./internal/collab/... -v Expected: PASS, all migration + schema tests.

git add internal/collab/schema.go internal/collab/migrations/001_initial.sql internal/collab/migrate_test.go
git rm internal/collab/migrations/.gitkeep
git commit -m "wiki-browser: collab — initial schema (migration 001)"

Task 4: Collab Store — Open, Close, write funnel

Files:

// internal/collab/collab_test.go
package collab_test

import (
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func TestOpen_createsFreshDB(t *testing.T) {
	path := filepath.Join(t.TempDir(), "fresh.db")
	s, err := collab.Open(collab.Config{
		Path:                path,
		OperatorUserID:      "alice",
		OperatorDisplayName: "Alice Example",
	})
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	if err := s.Close(); err != nil {
		t.Errorf("Close: %v", err)
	}
}

func TestOpen_idempotent(t *testing.T) {
	path := filepath.Join(t.TempDir(), "twice.db")
	for i := 0; i < 2; i++ {
		s, err := collab.Open(collab.Config{
			Path:                path,
			OperatorUserID:      "alice",
			OperatorDisplayName: "Alice Example",
		})
		if err != nil {
			t.Fatalf("Open #%d: %v", i, err)
		}
		if err := s.Close(); err != nil {
			t.Fatalf("Close #%d: %v", i, err)
		}
	}
}

func TestOpen_enablesForeignKeys(t *testing.T) {
	path := filepath.Join(t.TempDir(), "fk.db")
	s, err := collab.Open(collab.Config{
		Path:                path,
		OperatorUserID:      "alice",
		OperatorDisplayName: "Alice Example",
	})
	if err != nil {
		t.Fatal(err)
	}
	defer s.Close()

	// Probe: insert a topic referencing a non-existent user → must fail.
	_, err = s.RawDBForTest().Exec(
		`INSERT INTO topics(id, source_path, created_at, created_by, updated_at)
		 VALUES('t1','foo.md',0,'nobody',0)`,
	)
	if err == nil {
		t.Error("expected FK violation, got nil")
	}
}

Run: go test ./internal/collab/... -run TestOpen -v Expected: FAIL — collab.Open, collab.Config, RawDBForTest don't exist.

// internal/collab/collab.go
package collab

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"

	_ "modernc.org/sqlite"
)

// Config captures the inputs Open needs. Cleaner than positional args
// once the surface grows beyond two fields.
type Config struct {
	Path                string // filesystem path to the SQLite DB
	OperatorUserID      string // bootstrap user identifier
	OperatorDisplayName string // bootstrap user display name
}

// Store owns the collab DB. All write operations are funneled through a
// single goroutine; reads use the same *sql.DB and run concurrently
// because SQLite (in WAL mode) handles concurrent readers natively.
type Store struct {
	db        *sql.DB
	in        chan mutation
	cancel    context.CancelFunc
	wg        sync.WaitGroup
	closed    chan struct{}
	closeOnce sync.Once
}

// ErrClosed is returned by mutators after Close has been called.
var ErrClosed = errors.New("collab: closed")

// Open opens the collab DB at cfg.Path, applies migrations, inserts the
// bootstrap user row (idempotently), and starts the write funnel.
func Open(cfg Config) (*Store, error) {
	if cfg.OperatorUserID == "" || cfg.OperatorDisplayName == "" {
		return nil, fmt.Errorf("collab: Config.Operator* are required")
	}

	dsn := cfg.Path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)"
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, fmt.Errorf("open %s: %w", cfg.Path, err)
	}

	if err := Migrate(db, MigrationsFS); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("migrate: %w", err)
	}

	if err := bootstrapOperator(db, cfg.OperatorUserID, cfg.OperatorDisplayName); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("bootstrap operator: %w", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	s := &Store{
		db:     db,
		in:     make(chan mutation, 256),
		cancel: cancel,
		closed: make(chan struct{}),
	}
	s.wg.Add(1)
	go s.runFunnel(ctx)
	return s, nil
}

// Close stops the write funnel and closes the DB. Idempotent.
func (s *Store) Close() error {
	var err error
	s.closeOnce.Do(func() {
		if s.cancel != nil {
			s.cancel()
			s.wg.Wait()
		}
		close(s.closed)
		err = s.db.Close()
	})
	return err
}

// RawDBForTest exposes the underlying *sql.DB for tests that need to peek
// at storage directly. Production code must go through typed methods.
func (s *Store) RawDBForTest() *sql.DB { return s.db }

// --- write funnel scaffold ------------------------------------------------

// mutation is the work item type carried on the funnel channel. Each
// mutator (defined in mutators.go) builds a mutation and calls s.send.
type mutation struct {
	apply func(*sql.DB) error
	resp  chan error
}

func (s *Store) runFunnel(ctx context.Context) {
	defer s.wg.Done()
	for {
		select {
		case m := <-s.in:
			m.resp <- m.apply(s.db)
		case <-ctx.Done():
			// Drain pending mutations with ErrClosed.
			for {
				select {
				case m := <-s.in:
					m.resp <- ErrClosed
				default:
					return
				}
			}
		}
	}
}

// send enqueues a mutation and waits for it to complete. Selectable
// against s.closed so callers don't wedge if Close runs concurrently.
func (s *Store) send(apply func(*sql.DB) error) error {
	resp := make(chan error, 1)
	m := mutation{apply: apply, resp: resp}
	select {
	case s.in <- m:
	case <-s.closed:
		return ErrClosed
	}
	select {
	case err := <-resp:
		return err
	case <-s.closed:
		return ErrClosed
	}
}

// --- bootstrap ------------------------------------------------------------

func bootstrapOperator(db *sql.DB, id, displayName string) error {
	_, err := db.Exec(
		`INSERT OR IGNORE INTO users(id, display_name, created_at)
		 VALUES (?, ?, unixepoch())`,
		id, displayName,
	)
	return err
}

Run: go test ./internal/collab/... -v Expected: PASS, all collab + migrate tests.

git add internal/collab/collab.go internal/collab/collab_test.go
git commit -m "wiki-browser: collab — Store with Open/Close, funnel, operator bootstrap"

Task 5: Utilities — hashes and source-path validation

Files:

ValidateSourcePath is the gate every code path uses before joining a source_path to repo_root. Without it, a malformed Topic row (source_path = "/etc/passwd" or source_path = "../../foo") would escape the repo when written or hashed. Per the spec invariant "Agent is the only writer of Source," there's no legitimate reason any source_path should be absolute or traverse upward.

// internal/collab/hashes_test.go
package collab_test

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func TestPersonaSHA_stable(t *testing.T) {
	got1 := collab.PersonaSHA("focus on financial impact")
	got2 := collab.PersonaSHA("focus on financial impact")
	if got1 != got2 {
		t.Errorf("PersonaSHA not deterministic: %s vs %s", got1, got2)
	}
	if len(got1) != 64 {
		t.Errorf("PersonaSHA len = %d, want 64 (hex SHA-256)", len(got1))
	}
}

func TestPersonaSHA_differentInputs(t *testing.T) {
	a := collab.PersonaSHA("alpha")
	b := collab.PersonaSHA("beta")
	if a == b {
		t.Error("PersonaSHA collision on distinct inputs")
	}
}

// initRepo creates a tiny git repo for blob-hash tests. Skips if git missing.
func initRepo(t *testing.T) string {
	t.Helper()
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not on PATH")
	}
	root := t.TempDir()
	if err := exec.Command("git", "-C", root, "init").Run(); err != nil {
		t.Fatalf("git init: %v", err)
	}
	return root
}

func TestSourceSHA_matchesGitHashObject(t *testing.T) {
	root := initRepo(t)
	rel := "doc.md"
	body := []byte("# Hello\n\nbody\n")
	if err := os.WriteFile(filepath.Join(root, rel), body, 0o644); err != nil {
		t.Fatal(err)
	}

	got, err := collab.SourceSHA(root, rel)
	if err != nil {
		t.Fatalf("SourceSHA: %v", err)
	}

	out, err := exec.Command("git", "-C", root, "hash-object", rel).Output()
	if err != nil {
		t.Fatalf("git hash-object: %v", err)
	}
	want := strings.TrimSpace(string(out))

	if got != want {
		t.Errorf("SourceSHA = %s, want %s", got, want)
	}
}

func TestSourceSHA_missingFile(t *testing.T) {
	root := initRepo(t)
	if _, err := collab.SourceSHA(root, "no-such-file.md"); err == nil {
		t.Error("expected error for missing file, got nil")
	}
}

Run: go test ./internal/collab/... -run 'TestPersonaSHA|TestSourceSHA' -v Expected: FAIL — symbols don't exist.

// internal/collab/hashes.go
package collab

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"os/exec"
	"strings"
)

// SourceSHA returns the git blob SHA (40-char SHA-1 hex) of the file at
// repoRoot/relPath, as it currently exists on disk. Equivalent to
// `git hash-object <relPath>` run with `repoRoot` as the cwd.
//
// We shell out instead of computing the SHA ourselves so the hash always
// matches git's view byte-for-byte, including line-ending normalisation
// rules controlled by the repo's .gitattributes.
func SourceSHA(repoRoot, relPath string) (string, error) {
	cmd := exec.Command("git", "-C", repoRoot, "hash-object", "--", relPath)
	out, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("git hash-object %s: %w", relPath, err)
	}
	return strings.TrimSpace(string(out)), nil
}

// SourceSHAOfBytes returns the git blob SHA of body without writing to
// disk. Uses `git hash-object --stdin`, so a git binary is required.
// Used by the recovery procedure when comparing the working tree to a
// proposal's stored content.
func SourceSHAOfBytes(repoRoot string, body []byte) (string, error) {
	cmd := exec.Command("git", "-C", repoRoot, "hash-object", "--stdin")
	cmd.Stdin = strings.NewReader(string(body))
	out, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("git hash-object --stdin: %w", err)
	}
	return strings.TrimSpace(string(out)), nil
}

// PersonaSHA returns the SHA-256 hex digest of the persona text. Used as
// a cache key in the perspectives table; bumps whenever the persona text
// changes by even one byte, invalidating cached generations.
func PersonaSHA(personaText string) string {
	sum := sha256.Sum256([]byte(personaText))
	return hex.EncodeToString(sum[:])
}

Run: go test ./internal/collab/... -run 'TestPersonaSHA|TestSourceSHA' -v Expected: PASS, all four tests.

Create internal/collab/path_test.go:

package collab_test

import (
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func TestValidateSourcePath_accepts(t *testing.T) {
	for _, in := range []string{
		"a.md",
		"docs/a.md",
		"docs/superpowers/specs/2026-05-10-x.html",
		"deeply/nested/path/with-dashes_and_under/file.html",
	} {
		got, err := collab.ValidateSourcePath(in)
		if err != nil {
			t.Errorf("ValidateSourcePath(%q) err=%v, want nil", in, err)
			continue
		}
		if got != in {
			t.Errorf("ValidateSourcePath(%q) = %q, want unchanged", in, got)
		}
	}
}

func TestValidateSourcePath_rejects(t *testing.T) {
	for _, in := range []string{
		"",                       // empty
		"/abs/path.md",           // absolute
		"../escape.md",           // parent traversal at root
		"docs/../../etc/passwd",  // traversal mid-path
		"docs/./hidden.md",       // dot segment
		"docs//double-slash.md",  // empty segment
		"docs\\windows.md",       // backslash (path separator on Windows; reject for consistency)
		`docs/with-null` + string(rune(0)) + `byte.md`, // NUL byte
	} {
		if got, err := collab.ValidateSourcePath(in); err == nil {
			t.Errorf("ValidateSourcePath(%q) = %q, want error", in, got)
		}
	}
}

func TestValidateSourcePath_normalisesNothing(t *testing.T) {
	// Even though "docs/a.md" and "./docs/a.md" denote the same path,
	// we reject the latter to keep the on-disk and DB representations
	// byte-for-byte stable.
	if _, err := collab.ValidateSourcePath("./docs/a.md"); err == nil {
		t.Error("expected error for './' prefix; we don't normalise, we reject")
	}
}

Run: go test ./internal/collab/... -run TestValidateSourcePath -v Expected: FAIL — ValidateSourcePath doesn't exist.

Create internal/collab/path.go:

// internal/collab/path.go
package collab

import (
	"fmt"
	"path"
	"strings"
)

// ValidateSourcePath returns p unchanged if it is a safe repo-relative
// path: non-empty, no NUL bytes, no backslashes, no absolute prefix, no
// "." or ".." segments, no empty segments. Otherwise returns a non-nil
// error and the empty string.
//
// This is the gate every site that joins source_path to repo_root must
// pass through: InsertTopic, Incorporate's path lookup, and
// CommitSourceRewrite. It is deliberately strict — we don't normalise
// "./foo" → "foo", we reject it, so the DB and the filesystem agree
// byte-for-byte on what a Document's path is.
func ValidateSourcePath(p string) (string, error) {
	if p == "" {
		return "", fmt.Errorf("source path: empty")
	}
	if strings.ContainsRune(p, 0) {
		return "", fmt.Errorf("source path: NUL byte")
	}
	if strings.Contains(p, "\\") {
		return "", fmt.Errorf("source path: backslash not allowed (use forward slash)")
	}
	if path.IsAbs(p) || strings.HasPrefix(p, "/") {
		return "", fmt.Errorf("source path: must be repo-relative, got %q", p)
	}
	for _, seg := range strings.Split(p, "/") {
		switch seg {
		case "":
			return "", fmt.Errorf("source path: empty segment in %q", p)
		case ".", "..":
			return "", fmt.Errorf("source path: %q segment in %q", seg, p)
		}
	}
	return p, nil
}

Run: go test ./internal/collab/... -run TestValidateSourcePath -v Expected: PASS, all three tests.

git add internal/collab/hashes.go internal/collab/hashes_test.go internal/collab/path.go internal/collab/path_test.go
git commit -m "wiki-browser: collab — source_sha, persona_sha, and source-path validation"

Task 6: Git operations — commit helper

Files:

// internal/collab/gitops_test.go
package collab_test

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func TestCommitSourceRewrite_landsCommitWithTrailers(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not on PATH")
	}
	root := t.TempDir()
	for _, c := range [][]string{
		{"init"},
		{"config", "user.email", "init@test"},
		{"config", "user.name", "Init Test"},
	} {
		if err := exec.Command("git", append([]string{"-C", root}, c...)...).Run(); err != nil {
			t.Fatalf("git %s: %v", strings.Join(c, " "), err)
		}
	}
	// Seed the repo with an initial commit so HEAD exists.
	if err := os.WriteFile(filepath.Join(root, "seed.md"), []byte("seed\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := exec.Command("git", "-C", root, "add", "seed.md").Run(); err != nil {
		t.Fatal(err)
	}
	if err := exec.Command("git", "-C", root, "commit", "-m", "seed").Run(); err != nil {
		t.Fatal(err)
	}

	sha, err := collab.CommitSourceRewrite(collab.CommitInput{
		RepoRoot:        root,
		RelPath:         "doc.md",
		NewContent:      []byte("# Doc\n\nfresh body\n"),
		Subject:         "Incorporate Topic abc: tighten finance section",
		Body:            "Discussion converged on a tighter framing.",
		ApprovedBy:      "Daniel Barreto <daniel>",
		TopicID:         "abc-123",
		ProposalRev:     2,
		AuthorName:      "Orcha Agent",
		AuthorEmail:     "agent@orcha.local",
	})
	if err != nil {
		t.Fatalf("CommitSourceRewrite: %v", err)
	}
	if len(sha) != 40 {
		t.Errorf("sha length = %d, want 40", len(sha))
	}

	msg, err := exec.Command("git", "-C", root, "log", "-1", "--format=%B", sha).Output()
	if err != nil {
		t.Fatal(err)
	}
	text := string(msg)
	for _, want := range []string{
		"Incorporate Topic abc: tighten finance section",
		"Approved-by: Daniel Barreto <daniel>",
		"Topic: abc-123",
		"Proposal: 2",
	} {
		if !strings.Contains(text, want) {
			t.Errorf("commit message missing %q\nfull message:\n%s", want, text)
		}
	}

	// Author identity should be the Agent, regardless of repo config.
	author, _ := exec.Command("git", "-C", root, "log", "-1", "--format=%an <%ae>", sha).Output()
	if got := strings.TrimSpace(string(author)); got != "Orcha Agent <agent@orcha.local>" {
		t.Errorf("author = %q, want Agent identity", got)
	}
}

Run: go test ./internal/collab/... -run TestCommitSourceRewrite -v Expected: FAIL — symbol doesn't exist.

// internal/collab/gitops.go
package collab

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

// CommitInput is the fully-specified work a CommitSourceRewrite needs.
// All fields are required.
type CommitInput struct {
	RepoRoot    string
	RelPath     string
	NewContent  []byte
	Subject     string
	Body        string // optional body paragraph(s)
	ApprovedBy  string // "Display Name <user-id>"
	TopicID     string
	ProposalRev int
	AuthorName  string
	AuthorEmail string
}

// CommitSourceRewrite writes NewContent to RepoRoot/RelPath, then stages
// and commits ONLY that path with the Agent identity and trailers.
// Returns the new commit SHA.
//
// This function is the only place in the codebase that runs `git commit`.
// All Source changes must flow through it so the trailer-based recovery
// (see recover.go) can attribute every Source commit to its Topic.
//
// Pathspec discipline: the commit is `git commit -- <RelPath>`, never a
// bare `git commit`. This guarantees that any pre-existing staged
// changes for other files (developer WIP, concurrent tools, etc.) do
// NOT get swept into the Agent's commit.
//
// File mode: if the file already exists, its mode is preserved; if it's
// a new file, 0o644 is used. Source files in this project are documents
// (markdown / HTML) — but preserving mode is cheap defence against a
// file accidentally arriving with non-default mode.
func CommitSourceRewrite(in CommitInput) (string, error) {
	if in.RepoRoot == "" || in.RelPath == "" || in.TopicID == "" || in.Subject == "" {
		return "", fmt.Errorf("collab: CommitInput is missing required fields")
	}
	cleanRel, err := ValidateSourcePath(in.RelPath)
	if err != nil {
		return "", fmt.Errorf("CommitSourceRewrite: %w", err)
	}
	abs := filepath.Join(in.RepoRoot, cleanRel)
	if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
		return "", fmt.Errorf("mkdir parent: %w", err)
	}
	mode := os.FileMode(0o644)
	if info, err := os.Stat(abs); err == nil {
		mode = info.Mode().Perm()
	}
	if err := os.WriteFile(abs, in.NewContent, mode); err != nil {
		return "", fmt.Errorf("write %s: %w", cleanRel, err)
	}

	if err := runGit(in.RepoRoot, nil, "add", "--", cleanRel); err != nil {
		return "", err
	}

	var msg strings.Builder
	msg.WriteString(in.Subject)
	if in.Body != "" {
		msg.WriteString("\n\n")
		msg.WriteString(in.Body)
	}
	msg.WriteString("\n\n")
	fmt.Fprintf(&msg, "Approved-by: %s\n", in.ApprovedBy)
	fmt.Fprintf(&msg, "Topic: %s\n", in.TopicID)
	fmt.Fprintf(&msg, "Proposal: %d\n", in.ProposalRev)

	args := []string{
		"-c", "user.name=" + in.AuthorName,
		"-c", "user.email=" + in.AuthorEmail,
		"commit", "-m", msg.String(),
		"--", cleanRel, // pathspec — ONLY this path is committed.
	}
	if err := runGit(in.RepoRoot, nil, args...); err != nil {
		return "", err
	}

	out, err := exec.Command("git", "-C", in.RepoRoot, "rev-parse", "HEAD").Output()
	if err != nil {
		return "", fmt.Errorf("rev-parse HEAD: %w", err)
	}
	return strings.TrimSpace(string(out)), nil
}

func runGit(repoRoot string, stdin []byte, args ...string) error {
	cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...)
	if stdin != nil {
		cmd.Stdin = strings.NewReader(string(stdin))
	}
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, string(out))
	}
	return nil
}

// ListIncorporationCommits returns commits reachable from HEAD that carry a
// "Topic:" trailer. Used by the recovery procedure on startup. Order is
// chronological (oldest first).
type CommitTrailers struct {
	SHA         string
	TopicID     string
	ProposalRev int
	ApprovedBy  string
	AuthorTime  int64 // unix seconds
}

func ListIncorporationCommits(repoRoot string) ([]CommitTrailers, error) {
	out, err := exec.Command(
		"git", "-C", repoRoot, "log", "--reverse",
		"--grep", "^Topic:",
		"--format", "%H%n%at%n%B%x00",
	).Output()
	if err != nil {
		return nil, fmt.Errorf("git log: %w", err)
	}
	var commits []CommitTrailers
	for _, rec := range strings.Split(string(out), "\x00") {
		rec = strings.TrimSpace(rec)
		if rec == "" {
			continue
		}
		lines := strings.SplitN(rec, "\n", 3)
		if len(lines) < 3 {
			continue
		}
		c := CommitTrailers{SHA: lines[0]}
		if _, err := fmt.Sscanf(lines[1], "%d", &c.AuthorTime); err != nil {
			continue
		}
		for _, ln := range strings.Split(lines[2], "\n") {
			switch {
			case strings.HasPrefix(ln, "Topic: "):
				c.TopicID = strings.TrimPrefix(ln, "Topic: ")
			case strings.HasPrefix(ln, "Proposal: "):
				_, _ = fmt.Sscanf(ln, "Proposal: %d", &c.ProposalRev)
			case strings.HasPrefix(ln, "Approved-by: "):
				c.ApprovedBy = strings.TrimPrefix(ln, "Approved-by: ")
			}
		}
		if c.TopicID != "" {
			commits = append(commits, c)
		}
	}
	return commits, nil
}

Append to internal/collab/gitops_test.go:

func TestCommitSourceRewrite_doesNotSweepUnrelatedStaged(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not on PATH")
	}
	root := t.TempDir()
	for _, c := range [][]string{
		{"init"},
		{"config", "user.email", "init@test"},
		{"config", "user.name", "Init Test"},
	} {
		if err := exec.Command("git", append([]string{"-C", root}, c...)...).Run(); err != nil {
			t.Fatal(err)
		}
	}
	// Seed two files in the initial commit.
	for _, name := range []string{"seed.md", "unrelated.md"} {
		if err := os.WriteFile(filepath.Join(root, name), []byte("seed\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		_ = exec.Command("git", "-C", root, "add", name).Run()
	}
	_ = exec.Command("git", "-C", root, "commit", "-m", "seed").Run()

	// Modify and stage "unrelated.md" — this represents developer WIP or
	// some other tool's staged change that must NOT be swept into the
	// Agent's incorporation commit.
	if err := os.WriteFile(filepath.Join(root, "unrelated.md"), []byte("WIP\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := exec.Command("git", "-C", root, "add", "unrelated.md").Run(); err != nil {
		t.Fatal(err)
	}

	sha, err := collab.CommitSourceRewrite(collab.CommitInput{
		RepoRoot: root, RelPath: "doc.md", NewContent: []byte("body\n"),
		Subject: "Incorporate Topic t1", ApprovedBy: "Daniel <daniel>",
		TopicID: "t1", ProposalRev: 1,
		AuthorName: "Orcha Agent", AuthorEmail: "agent@orcha.local",
	})
	if err != nil {
		t.Fatalf("CommitSourceRewrite: %v", err)
	}

	// The commit must touch ONLY "doc.md".
	out, err := exec.Command("git", "-C", root, "show", "--name-only", "--format=", sha).Output()
	if err != nil {
		t.Fatal(err)
	}
	touched := strings.Fields(string(out))
	if len(touched) != 1 || touched[0] != "doc.md" {
		t.Errorf("commit touched files = %v, want [doc.md]", touched)
	}

	// "unrelated.md" should still be staged (the pre-existing WIP),
	// and the working tree should still show "WIP\n".
	body, err := os.ReadFile(filepath.Join(root, "unrelated.md"))
	if err != nil || string(body) != "WIP\n" {
		t.Errorf("unrelated.md body = %q (err=%v), want \"WIP\\n\"", string(body), err)
	}
}

Append to internal/collab/gitops_test.go:

func TestListIncorporationCommits_parsesTrailers(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not on PATH")
	}
	root := t.TempDir()
	for _, c := range [][]string{
		{"init"},
		{"config", "user.email", "init@test"},
		{"config", "user.name", "Init Test"},
	} {
		if err := exec.Command("git", append([]string{"-C", root}, c...)...).Run(); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.WriteFile(filepath.Join(root, "seed.md"), []byte("seed\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	_ = exec.Command("git", "-C", root, "add", "seed.md").Run()
	_ = exec.Command("git", "-C", root, "commit", "-m", "seed").Run()

	_, err := collab.CommitSourceRewrite(collab.CommitInput{
		RepoRoot: root, RelPath: "doc.md", NewContent: []byte("hi\n"),
		Subject: "Incorporate one", ApprovedBy: "Daniel <daniel>",
		TopicID: "topic-1", ProposalRev: 1,
		AuthorName: "Orcha Agent", AuthorEmail: "agent@orcha.local",
	})
	if err != nil {
		t.Fatal(err)
	}

	got, err := collab.ListIncorporationCommits(root)
	if err != nil {
		t.Fatalf("ListIncorporationCommits: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("got %d commits, want 1", len(got))
	}
	if got[0].TopicID != "topic-1" || got[0].ProposalRev != 1 {
		t.Errorf("trailers = %+v, want TopicID=topic-1 ProposalRev=1", got[0])
	}
	if got[0].ApprovedBy != "Daniel <daniel>" {
		t.Errorf("ApprovedBy = %q", got[0].ApprovedBy)
	}
}

Run: go test ./internal/collab/... -run 'TestCommit|TestList' -v Expected: PASS, all three tests (happy-path commit, no-sweep-unrelated, list trailers).

git add internal/collab/gitops.go internal/collab/gitops_test.go
git commit -m "wiki-browser: collab — git commit helper (pathspec-scoped) and trailer listing"

Task 7: Mutators — typed write operations

Files:

This task adds the typed write methods downstream sub-projects will call. Every write goes through the funnel; method signatures take the values they need and return any error.

// internal/collab/mutators_test.go
package collab_test

import (
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func openStore(t *testing.T) *collab.Store {
	t.Helper()
	s, err := collab.Open(collab.Config{
		Path:                filepath.Join(t.TempDir(), "store.db"),
		OperatorUserID:      "alice",
		OperatorDisplayName: "Alice Example",
	})
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	t.Cleanup(func() { _ = s.Close() })
	return s
}

func TestInsertTopic_roundtrip(t *testing.T) {
	s := openStore(t)
	id, err := s.InsertTopic(collab.NewTopic{
		ID: "t1", SourcePath: "docs/a.md", CreatedBy: "alice",
	})
	if err != nil {
		t.Fatalf("InsertTopic: %v", err)
	}
	if id != "t1" {
		t.Errorf("InsertTopic returned id=%q, want t1", id)
	}

	var srcPath, createdBy string
	err = s.RawDBForTest().QueryRow(
		"SELECT source_path, created_by FROM topics WHERE id=?", "t1",
	).Scan(&srcPath, &createdBy)
	if err != nil {
		t.Fatal(err)
	}
	if srcPath != "docs/a.md" || createdBy != "alice" {
		t.Errorf("row = (%q, %q), want (docs/a.md, alice)", srcPath, createdBy)
	}
}

func TestInsertTopic_rejectsBadPaths(t *testing.T) {
	s := openStore(t)
	for _, bad := range []string{
		"/etc/passwd",
		"../escape.md",
		"docs/../../etc/passwd",
		"",
	} {
		_, err := s.InsertTopic(collab.NewTopic{
			ID: "t-" + bad, SourcePath: bad, CreatedBy: "alice",
		})
		if err == nil {
			t.Errorf("InsertTopic(%q) succeeded, want error", bad)
		}
	}
}

func TestInsertMessage_allocatesSequencePerTopic(t *testing.T) {
	s := openStore(t)
	if _, err := s.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: "a.md", CreatedBy: "alice"}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.InsertTopic(collab.NewTopic{ID: "t2", SourcePath: "b.md", CreatedBy: "alice"}); err != nil {
		t.Fatal(err)
	}

	for _, tid := range []string{"t1", "t1", "t2", "t1", "t2"} {
		if _, err := s.InsertMessage(collab.NewMessage{
			ID: tid + "-msg-" + randID(t), TopicID: tid, Kind: "human",
			Body: "hi", AuthorUserID: ptr("alice"),
		}); err != nil {
			t.Fatalf("InsertMessage(%s): %v", tid, err)
		}
	}

	for _, want := range []struct {
		topic string
		max   int
	}{{"t1", 3}, {"t2", 2}} {
		var got int
		err := s.RawDBForTest().QueryRow(
			"SELECT max(sequence) FROM topic_messages WHERE topic_id=?", want.topic,
		).Scan(&got)
		if err != nil {
			t.Fatal(err)
		}
		if got != want.max {
			t.Errorf("topic %s max sequence = %d, want %d", want.topic, got, want.max)
		}
	}
}

func TestInsertProposal_appendOnly(t *testing.T) {
	s := openStore(t)
	if _, err := s.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: "a.md", CreatedBy: "alice"}); err != nil {
		t.Fatal(err)
	}
	for i := 1; i <= 3; i++ {
		if _, err := s.InsertProposal(collab.NewProposal{
			ID: "p" + itoa(i), TopicID: "t1", RevisionNumber: i,
			ProposedSource: "v" + itoa(i), BaseSourceSHA: "sha" + itoa(i),
			ProposedBy: "alice",
		}); err != nil {
			t.Fatalf("InsertProposal #%d: %v", i, err)
		}
	}

	// Duplicate revision number must violate the unique index.
	_, err := s.InsertProposal(collab.NewProposal{
		ID: "pdup", TopicID: "t1", RevisionNumber: 2,
		ProposedSource: "vdup", BaseSourceSHA: "shadup", ProposedBy: "alice",
	})
	if err == nil {
		t.Error("expected unique-index violation on duplicate revision_number")
	}
}

func TestInsertAttempt_rejectsBadPaths(t *testing.T) {
	s := openStore(t)
	if _, err := s.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: "a.md", CreatedBy: "alice"}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "after\n", BaseSourceSHA: "base", ProposedBy: "alice",
	}); err != nil {
		t.Fatal(err)
	}
	for _, bad := range []string{"/etc/passwd", "../escape.md", "docs/../../etc/passwd"} {
		_, err := s.InsertAttempt(collab.NewAttempt{
			ID: "a-" + randID(t), ProposalID: "p1", TopicID: "t1",
			SourcePath: bad, BaseSourceSHA: "base", ApprovedBy: "Alice <alice>",
		})
		if err == nil {
			t.Errorf("InsertAttempt(%q) succeeded, want error", bad)
		}
	}
}

func TestCompleteIncorporation_missingRowsFails(t *testing.T) {
	s := openStore(t)
	err := s.CompleteIncorporation(collab.CompleteIncorporationInput{
		TopicID: "missing-topic", ProposalID: "missing-proposal",
		AttemptID: "missing-attempt", CommitSHA: "abc123",
		IncorporatedBy: "alice", IncorporatedAt: 1,
	})
	if err == nil {
		t.Error("expected error when topic/attempt rows are missing, got nil")
	}
}

// --- helpers ---
func ptr(s string) *string { return &s }
func itoa(i int) string    { return string(rune('0' + i)) } // small-N, single digit
func randID(t *testing.T) string {
	t.Helper()
	return filepath.Base(t.TempDir())[:8]
}

Run: go test ./internal/collab/... -run 'TestInsert' -v Expected: FAIL — NewTopic, NewMessage, NewProposal, InsertTopic, InsertMessage, InsertProposal don't exist.

// internal/collab/mutators.go
package collab

import (
	"database/sql"
	"fmt"
)

// NewTopic is the input for InsertTopic. CreatedAt / UpdatedAt default to now().
type NewTopic struct {
	ID         string
	SourcePath string
	Anchor     *string // nullable JSON; null at creation; set by sub-project #2
	CreatedBy  string
}

// InsertTopic inserts a row into topics. Returns the topic id on success.
// Rejects any source_path that fails ValidateSourcePath — this is the only
// guarded entrypoint for inserting topics, so it's also the only place we
// need to enforce the path-safety invariant at write time.
func (s *Store) InsertTopic(t NewTopic) (string, error) {
	if t.ID == "" || t.SourcePath == "" || t.CreatedBy == "" {
		return "", fmt.Errorf("collab.InsertTopic: id/source_path/created_by required")
	}
	if _, err := ValidateSourcePath(t.SourcePath); err != nil {
		return "", fmt.Errorf("collab.InsertTopic: %w", err)
	}
	err := s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO topics(
			   id, source_path, anchor,
			   created_at, created_by, updated_at
			 ) VALUES (?, ?, ?, unixepoch(), ?, unixepoch())`,
			t.ID, t.SourcePath, t.Anchor, t.CreatedBy,
		)
		return err
	})
	if err != nil {
		return "", err
	}
	return t.ID, nil
}

// NewMessage is the input for InsertMessage. Sequence is allocated by the
// funnel — callers don't set it.
type NewMessage struct {
	ID           string
	TopicID      string
	Kind         string  // 'human' | 'agent-proposal' | future kinds owned by #2
	Body         string
	AuthorUserID *string // null for non-human messages
	ProposalID   *string // non-null when Kind == 'agent-proposal'
}

// InsertMessage allocates the next per-topic sequence and inserts the row.
// Sequence allocation is safe under the funnel: at most one mutator runs
// at a time, so MAX(sequence)+1 cannot race with another insert.
func (s *Store) InsertMessage(m NewMessage) (string, error) {
	if m.ID == "" || m.TopicID == "" || m.Kind == "" {
		return "", fmt.Errorf("collab.InsertMessage: id/topic_id/kind required")
	}
	err := s.send(func(db *sql.DB) error {
		var next int
		err := db.QueryRow(
			`SELECT COALESCE(MAX(sequence), 0) + 1
			 FROM topic_messages WHERE topic_id = ?`,
			m.TopicID,
		).Scan(&next)
		if err != nil {
			return fmt.Errorf("allocate sequence: %w", err)
		}
		_, err = db.Exec(
			`INSERT INTO topic_messages(
			   id, topic_id, kind, body, author_user_id, proposal_id,
			   sequence, created_at
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch())`,
			m.ID, m.TopicID, m.Kind, m.Body, m.AuthorUserID, m.ProposalID, next,
		)
		return err
	})
	if err != nil {
		return "", err
	}
	return m.ID, nil
}

// NewProposal is the input for InsertProposal. Append-only — there's no
// UpdateProposal counterpart.
type NewProposal struct {
	ID             string
	TopicID        string
	RevisionNumber int
	ProposedSource string
	BaseSourceSHA  string
	ProposedBy     string
}

// InsertProposal inserts a row into incorporation_proposals.
func (s *Store) InsertProposal(p NewProposal) (string, error) {
	if p.ID == "" || p.TopicID == "" || p.RevisionNumber < 1 ||
		p.BaseSourceSHA == "" || p.ProposedBy == "" {
		return "", fmt.Errorf("collab.InsertProposal: required fields missing")
	}
	err := s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO incorporation_proposals(
			   id, topic_id, revision_number, proposed_source,
			   base_source_sha, proposed_by, created_at
			 ) VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
			p.ID, p.TopicID, p.RevisionNumber, p.ProposedSource,
			p.BaseSourceSHA, p.ProposedBy,
		)
		return err
	})
	if err != nil {
		return "", err
	}
	return p.ID, nil
}

// NewAttempt is the input for InsertAttempt — a recovery marker recorded
// before the working tree is touched. See incorporate.go for the protocol.
type NewAttempt struct {
	ID            string
	ProposalID    string
	TopicID       string
	SourcePath    string
	BaseSourceSHA string
	ApprovedBy    string
}

// InsertAttempt inserts an incorporation_attempts row with committed_sha NULL.
func (s *Store) InsertAttempt(a NewAttempt) (string, error) {
	if a.ID == "" || a.ProposalID == "" || a.TopicID == "" ||
		a.SourcePath == "" || a.BaseSourceSHA == "" || a.ApprovedBy == "" {
		return "", fmt.Errorf("collab.InsertAttempt: required fields missing")
	}
	if _, err := ValidateSourcePath(a.SourcePath); err != nil {
		return "", fmt.Errorf("collab.InsertAttempt: %w", err)
	}
	err := s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO incorporation_attempts(
			   id, proposal_id, topic_id, source_path,
			   base_source_sha, approved_by, approved_at, created_at
			 ) VALUES (?, ?, ?, ?, ?, ?, unixepoch(), unixepoch())`,
			a.ID, a.ProposalID, a.TopicID, a.SourcePath,
			a.BaseSourceSHA, a.ApprovedBy,
		)
		return err
	})
	if err != nil {
		return "", err
	}
	return a.ID, nil
}

// CompleteIncorporation is the final write of the Incorporation protocol.
// It runs both updates (topic outcome columns + attempt completion) inside
// a single SQLite transaction so they're either both applied or both not.
type CompleteIncorporationInput struct {
	TopicID         string
	ProposalID      string
	AttemptID       string
	CommitSHA       string
	IncorporatedBy  string
	IncorporatedAt  int64 // unix seconds; usually time.Now().Unix()
}

func (s *Store) CompleteIncorporation(in CompleteIncorporationInput) error {
	if in.TopicID == "" || in.ProposalID == "" || in.AttemptID == "" ||
		in.CommitSHA == "" || in.IncorporatedBy == "" {
		return fmt.Errorf("collab.CompleteIncorporation: required fields missing")
	}
	return s.send(func(db *sql.DB) error {
		tx, err := db.Begin()
		if err != nil {
			return err
		}
		res, err := tx.Exec(
			`UPDATE topics
			   SET commit_sha = ?,
			       incorporated_proposal_id = ?,
			       incorporated_by = ?,
			       incorporated_at = ?,
			       updated_at = unixepoch()
				 WHERE id = ?`,
			in.CommitSHA, in.ProposalID, in.IncorporatedBy, in.IncorporatedAt, in.TopicID,
		)
		if err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("update topic: %w", err)
		}
		if n, err := res.RowsAffected(); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("update topic rows affected: %w", err)
		} else if n != 1 {
			_ = tx.Rollback()
			return fmt.Errorf("update topic: affected %d rows, want 1", n)
		}
		res, err = tx.Exec(
			`UPDATE incorporation_attempts
			   SET committed_sha = ?, completed_at = ?
			 WHERE id = ?`,
			in.CommitSHA, in.IncorporatedAt, in.AttemptID,
		)
		if err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("complete attempt: %w", err)
		}
		if n, err := res.RowsAffected(); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("complete attempt rows affected: %w", err)
		} else if n != 1 {
			_ = tx.Rollback()
			return fmt.Errorf("complete attempt: affected %d rows, want 1", n)
		}
		return tx.Commit()
	})
}

// IncompleteAttempt is what ListIncompleteAttempts returns; one row per
// attempt that hasn't yet been completed (committed_sha IS NULL).
type IncompleteAttempt struct {
	ID            string
	ProposalID    string
	TopicID       string
	SourcePath    string
	BaseSourceSHA string
	ApprovedBy    string
}

// ListIncompleteAttempts returns all attempt rows whose completed_at IS NULL.
// Reads run outside the funnel.
func (s *Store) ListIncompleteAttempts() ([]IncompleteAttempt, error) {
	rows, err := s.db.Query(
		`SELECT a.id, a.proposal_id, a.topic_id, a.source_path,
		        a.base_source_sha, a.approved_by
		 FROM incorporation_attempts a
		 WHERE a.completed_at IS NULL
		 ORDER BY a.created_at`,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []IncompleteAttempt
	for rows.Next() {
		var a IncompleteAttempt
		if err := rows.Scan(
			&a.ID, &a.ProposalID, &a.TopicID, &a.SourcePath,
			&a.BaseSourceSHA, &a.ApprovedBy,
		); err != nil {
			return nil, err
		}
		out = append(out, a)
	}
	return out, rows.Err()
}

// GetProposal loads a single proposal row by id. Used by the recovery
// procedure when resuming an incomplete attempt.
type Proposal struct {
	ID             string
	TopicID        string
	RevisionNumber int
	ProposedSource string
	BaseSourceSHA  string
}

func (s *Store) GetProposal(id string) (Proposal, error) {
	var p Proposal
	err := s.db.QueryRow(
		`SELECT id, topic_id, revision_number, proposed_source, base_source_sha
		 FROM incorporation_proposals WHERE id = ?`,
		id,
	).Scan(&p.ID, &p.TopicID, &p.RevisionNumber, &p.ProposedSource, &p.BaseSourceSHA)
	return p, err
}

// GetTopicCommitSHA returns commit_sha for the given topic (may be NULL).
func (s *Store) GetTopicCommitSHA(topicID string) (sql.NullString, error) {
	var sha sql.NullString
	err := s.db.QueryRow(
		`SELECT commit_sha FROM topics WHERE id = ?`, topicID,
	).Scan(&sha)
	return sha, err
}

Run: go test ./internal/collab/... -run TestInsert -v Expected: PASS, all mutator tests.

git add internal/collab/mutators.go internal/collab/mutators_test.go
git commit -m "wiki-browser: collab — typed mutators (topic, message, proposal, attempt, complete)"

Task 8: Incorporation protocol (5-step write order)

Files:

This task wires the mutators + gitops together into the 5-step protocol from the spec.

// internal/collab/incorporate_test.go
package collab_test

import (
	"os"
	"os/exec"
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func initRepoWithSeed(t *testing.T, body string) (root, relPath string) {
	t.Helper()
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not on PATH")
	}
	root = t.TempDir()
	for _, c := range [][]string{
		{"init"},
		{"config", "user.email", "init@test"},
		{"config", "user.name", "Init"},
	} {
		if err := exec.Command("git", append([]string{"-C", root}, c...)...).Run(); err != nil {
			t.Fatal(err)
		}
	}
	relPath = "doc.md"
	if err := os.WriteFile(filepath.Join(root, relPath), []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := exec.Command("git", "-C", root, "add", relPath).Run(); err != nil {
		t.Fatal(err)
	}
	if err := exec.Command("git", "-C", root, "commit", "-m", "seed").Run(); err != nil {
		t.Fatal(err)
	}
	return root, relPath
}

func TestIncorporate_happyPath(t *testing.T) {
	root, rel := initRepoWithSeed(t, "before\n")
	store := openStore(t)

	// Seed Topic + Proposal.
	baseSHA, err := collab.SourceSHA(root, rel)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := store.InsertTopic(collab.NewTopic{
		ID: "t1", SourcePath: rel, CreatedBy: "alice",
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := store.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "after\n", BaseSourceSHA: baseSHA,
		ProposedBy: "alice",
	}); err != nil {
		t.Fatal(err)
	}

	sha, err := collab.Incorporate(store, collab.IncorporateInput{
		RepoRoot:     root,
		ProposalID:   "p1",
		ApproverID:   "alice",
		ApproverName: "Alice Example",
		Subject:      "Incorporate Topic t1: tighten phrasing",
		AuthorName:   "Orcha Agent",
		AuthorEmail:  "agent@orcha.local",
	})
	if err != nil {
		t.Fatalf("Incorporate: %v", err)
	}
	if len(sha) != 40 {
		t.Errorf("commit sha len = %d, want 40", len(sha))
	}

	// File on disk matches the proposed content.
	body, err := os.ReadFile(filepath.Join(root, rel))
	if err != nil || string(body) != "after\n" {
		t.Errorf("file on disk = %q (err=%v), want %q", string(body), err, "after\n")
	}

	// Topic now references the commit.
	got, err := store.GetTopicCommitSHA("t1")
	if err != nil {
		t.Fatal(err)
	}
	if !got.Valid || got.String != sha {
		t.Errorf("topic.commit_sha = %+v, want %s", got, sha)
	}
}

func TestIncorporate_staleProposal(t *testing.T) {
	root, rel := initRepoWithSeed(t, "before\n")
	store := openStore(t)
	if _, err := store.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: rel, CreatedBy: "alice"}); err != nil {
		t.Fatal(err)
	}
	// Proposal generated against a stale SHA (intentionally wrong).
	if _, err := store.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "anything\n",
		BaseSourceSHA:  "0000000000000000000000000000000000000000",
		ProposedBy:     "alice",
	}); err != nil {
		t.Fatal(err)
	}

	_, err := collab.Incorporate(store, collab.IncorporateInput{
		RepoRoot: root, ProposalID: "p1", ApproverID: "alice",
		ApproverName: "Alice", Subject: "won't land",
		AuthorName: "Orcha Agent", AuthorEmail: "agent@orcha.local",
	})
	if err == nil || !errorContains(err, "stale") {
		t.Errorf("expected stale-proposal error, got %v", err)
	}
}

func errorContains(err error, substr string) bool {
	if err == nil {
		return false
	}
	return contains(err.Error(), substr)
}

func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}

Run: go test ./internal/collab/... -run TestIncorporate -v Expected: FAIL — Incorporate, IncorporateInput don't exist.

// internal/collab/incorporate.go
package collab

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"time"
)

// IncorporateInput is the user-facing summary of an approval action.
// SourcePath, TopicID, ProposalSource, BaseSourceSHA are all loaded from
// the proposal row — callers don't pass them.
type IncorporateInput struct {
	RepoRoot     string
	ProposalID   string
	ApproverID   string // user id
	ApproverName string // display name
	Subject      string // commit subject line
	Body         string // optional commit body
	AuthorName   string // git author/committer name (Agent identity)
	AuthorEmail  string // git author/committer email
}

// Incorporate runs the 5-step Incorporation protocol from the spec:
//   1. Load proposal; stale-check base_source_sha against current Source.
//   2. Insert incorporation_attempts row (recovery marker).
//   3. Write proposed_source to disk.
//   4. git add + git commit with trailers.
//   5. CompleteIncorporation (update topic + complete attempt) in one tx.
// Returns the new commit SHA.
func Incorporate(s *Store, in IncorporateInput) (string, error) {
	if in.RepoRoot == "" || in.ProposalID == "" || in.ApproverID == "" ||
		in.ApproverName == "" || in.Subject == "" || in.AuthorName == "" || in.AuthorEmail == "" {
		return "", fmt.Errorf("Incorporate: required fields missing")
	}

	// 1. Load proposal and stale-check.
	prop, err := s.GetProposal(in.ProposalID)
	if err != nil {
		return "", fmt.Errorf("load proposal: %w", err)
	}

	// Look up the source_path via the topic, then re-validate it.
	// Defense in depth: even though InsertTopic validates on the way in,
	// a corrupted or manually-edited row would otherwise escape the repo
	// when we join it to RepoRoot.
	var sourcePath string
	if err := s.db.QueryRow(
		`SELECT source_path FROM topics WHERE id = ?`, prop.TopicID,
	).Scan(&sourcePath); err != nil {
		return "", fmt.Errorf("load source_path: %w", err)
	}
	if _, err := ValidateSourcePath(sourcePath); err != nil {
		return "", fmt.Errorf("Incorporate: stored source_path is unsafe: %w", err)
	}

	currentSHA, err := SourceSHA(in.RepoRoot, sourcePath)
	if err != nil {
		return "", fmt.Errorf("hash current source: %w", err)
	}
	if currentSHA != prop.BaseSourceSHA {
		return "", fmt.Errorf(
			"stale proposal: base_source_sha=%s, current=%s (regenerate)",
			prop.BaseSourceSHA, currentSHA,
		)
	}

	// 2. Insert recovery marker.
	attemptID := randomID()
	if _, err := s.InsertAttempt(NewAttempt{
		ID: attemptID, ProposalID: prop.ID, TopicID: prop.TopicID,
		SourcePath: sourcePath, BaseSourceSHA: prop.BaseSourceSHA,
		ApprovedBy: fmt.Sprintf("%s <%s>", in.ApproverName, in.ApproverID),
	}); err != nil {
		return "", fmt.Errorf("insert attempt: %w", err)
	}

	// 3 + 4. Write file and commit. (CommitSourceRewrite does both.)
	sha, err := CommitSourceRewrite(CommitInput{
		RepoRoot:    in.RepoRoot,
		RelPath:     sourcePath,
		NewContent:  []byte(prop.ProposedSource),
		Subject:     in.Subject,
		Body:        in.Body,
		ApprovedBy:  fmt.Sprintf("%s <%s>", in.ApproverName, in.ApproverID),
		TopicID:     prop.TopicID,
		ProposalRev: prop.RevisionNumber,
		AuthorName:  in.AuthorName,
		AuthorEmail: in.AuthorEmail,
	})
	if err != nil {
		return "", fmt.Errorf("commit source: %w", err)
	}

	// 5. Complete (topic update + attempt completion) atomically.
	if err := s.CompleteIncorporation(CompleteIncorporationInput{
		TopicID:        prop.TopicID,
		ProposalID:     prop.ID,
		AttemptID:      attemptID,
		CommitSHA:      sha,
		IncorporatedBy: in.ApproverID,
		IncorporatedAt: time.Now().Unix(),
	}); err != nil {
		return "", fmt.Errorf("complete incorporation: %w", err)
	}

	return sha, nil
}

// randomID returns a hex token suitable for primary-key use. 16 bytes ⇒
// 32 hex chars; collision probability is negligible at our scale.
//
// rand.Read failures indicate the OS's CSPRNG is unavailable — a state
// where we cannot safely continue. Panicking surfaces the problem
// immediately rather than producing predictable IDs.
func randomID() string {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		panic(fmt.Sprintf("collab: crypto/rand.Read failed: %v", err))
	}
	return hex.EncodeToString(b[:])
}

Run: go test ./internal/collab/... -run TestIncorporate -v Expected: PASS, both happy-path and stale-proposal tests.

git add internal/collab/incorporate.go internal/collab/incorporate_test.go
git commit -m "wiki-browser: collab — Incorporate (5-step protocol with stale-check + recovery marker)"

Task 9: Startup recovery — three crash cases

Files:

// internal/collab/recover_test.go
package collab_test

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/collab"
)

// TestRecover_commitLandedSqliteIncomplete simulates case 1: the git commit
// exists, but the SQLite update (topic.commit_sha) is missing. Recovery
// reconciles by parsing the commit's trailers.
func TestRecover_commitLandedSqliteIncomplete(t *testing.T) {
	root, rel := initRepoWithSeed(t, "before\n")
	store := openStore(t)

	baseSHA, _ := collab.SourceSHA(root, rel)
	_, _ = store.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: rel, CreatedBy: "alice"})
	_, _ = store.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "after\n", BaseSourceSHA: baseSHA, ProposedBy: "alice",
	})
	attemptID := "attempt-1"
	_, _ = store.InsertAttempt(collab.NewAttempt{
		ID: attemptID, ProposalID: "p1", TopicID: "t1",
		SourcePath: rel, BaseSourceSHA: baseSHA,
		ApprovedBy: "Alice Example <alice>",
	})

	// Land the commit ourselves (simulating that the process crashed
	// before the topic UPDATE could run).
	sha, err := collab.CommitSourceRewrite(collab.CommitInput{
		RepoRoot:    root,
		RelPath:     rel,
		NewContent:  []byte("after\n"),
		Subject:     "Incorporate Topic t1",
		ApprovedBy:  "Alice Example <alice>",
		TopicID:     "t1",
		ProposalRev: 1,
		AuthorName:  "Orcha Agent",
		AuthorEmail: "agent@orcha.local",
	})
	if err != nil {
		t.Fatal(err)
	}

	// Sanity check: topic still has NULL commit_sha.
	if got, _ := store.GetTopicCommitSHA("t1"); got.Valid {
		t.Fatalf("setup wrong: topic already has commit_sha=%s", got.String)
	}

	if err := collab.Recover(store, root); err != nil {
		t.Fatalf("Recover: %v", err)
	}

	got, _ := store.GetTopicCommitSHA("t1")
	if !got.Valid || got.String != sha {
		t.Errorf("after Recover, topic.commit_sha = %+v, want %s", got, sha)
	}
}

// TestRecover_attemptExistsWorkingTreeAtBase simulates case 2a:
// attempt was inserted, but the file write never happened. The working
// tree still matches base_source_sha. Recovery must complete by writing
// the file and committing.
func TestRecover_attemptExistsWorkingTreeAtBase(t *testing.T) {
	root, rel := initRepoWithSeed(t, "before\n")
	store := openStore(t)

	baseSHA, _ := collab.SourceSHA(root, rel)
	_, _ = store.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: rel, CreatedBy: "alice"})
	_, _ = store.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "after\n", BaseSourceSHA: baseSHA, ProposedBy: "alice",
	})
	_, _ = store.InsertAttempt(collab.NewAttempt{
		ID: "attempt-1", ProposalID: "p1", TopicID: "t1",
		SourcePath: rel, BaseSourceSHA: baseSHA,
		ApprovedBy: "Alice Example <alice>",
	})

	if err := collab.Recover(store, root); err != nil {
		t.Fatalf("Recover: %v", err)
	}

	body, _ := os.ReadFile(filepath.Join(root, rel))
	if string(body) != "after\n" {
		t.Errorf("file body = %q, want %q", string(body), "after\n")
	}
	got, _ := store.GetTopicCommitSHA("t1")
	if !got.Valid {
		t.Errorf("topic.commit_sha not set after recovery")
	}
}

// TestRecover_attemptExistsWorkingTreeAtProposalDoesNotSweepUnrelatedStaged
// simulates case 2b: the proposed Source was already written to disk, but the
// commit did not land. Recovery must commit only that Source path, even if an
// unrelated file was already staged by some other tool.
func TestRecover_attemptExistsWorkingTreeAtProposalDoesNotSweepUnrelatedStaged(t *testing.T) {
	root, rel := initRepoWithSeed(t, "before\n")
	store := openStore(t)

	// Add a tracked unrelated file before setting up the incomplete attempt.
	if err := os.WriteFile(filepath.Join(root, "unrelated.md"), []byte("seed\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	_ = exec.Command("git", "-C", root, "add", "unrelated.md").Run()
	_ = exec.Command("git", "-C", root, "commit", "-m", "seed unrelated").Run()

	baseSHA, _ := collab.SourceSHA(root, rel)
	_, _ = store.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: rel, CreatedBy: "alice"})
	_, _ = store.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "after\n", BaseSourceSHA: baseSHA, ProposedBy: "alice",
	})
	_, _ = store.InsertAttempt(collab.NewAttempt{
		ID: "attempt-1", ProposalID: "p1", TopicID: "t1",
		SourcePath: rel, BaseSourceSHA: baseSHA,
		ApprovedBy: "Alice Example <alice>",
	})

	// File write happened before crash.
	if err := os.WriteFile(filepath.Join(root, rel), []byte("after\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	// Unrelated staged WIP must remain outside the recovered commit.
	if err := os.WriteFile(filepath.Join(root, "unrelated.md"), []byte("WIP\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	_ = exec.Command("git", "-C", root, "add", "unrelated.md").Run()

	if err := collab.Recover(store, root); err != nil {
		t.Fatalf("Recover: %v", err)
	}

	got, _ := store.GetTopicCommitSHA("t1")
	if !got.Valid {
		t.Fatal("topic.commit_sha not set after recovery")
	}
	out, err := exec.Command("git", "-C", root, "show", "--name-only", "--format=", got.String).Output()
	if err != nil {
		t.Fatal(err)
	}
	touched := strings.Fields(string(out))
	if len(touched) != 1 || touched[0] != rel {
		t.Errorf("recovery commit touched files = %v, want [%s]", touched, rel)
	}
}

// TestRecover_attemptAmbiguousWorkingTree simulates case 3: the working
// tree is neither the base nor the proposal. Recovery must return a
// non-nil error mentioning operator intervention, and the topic must
// remain in its prior (open) state.
func TestRecover_attemptAmbiguousWorkingTree(t *testing.T) {
	root, rel := initRepoWithSeed(t, "before\n")
	store := openStore(t)

	baseSHA, _ := collab.SourceSHA(root, rel)
	_, _ = store.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: rel, CreatedBy: "alice"})
	_, _ = store.InsertProposal(collab.NewProposal{
		ID: "p1", TopicID: "t1", RevisionNumber: 1,
		ProposedSource: "after\n", BaseSourceSHA: baseSHA, ProposedBy: "alice",
	})
	_, _ = store.InsertAttempt(collab.NewAttempt{
		ID: "attempt-1", ProposalID: "p1", TopicID: "t1",
		SourcePath: rel, BaseSourceSHA: baseSHA,
		ApprovedBy: "Alice Example <alice>",
	})

	// Operator (or a third party) modified the file to something
	// unrelated to either base or proposed.
	if err := os.WriteFile(filepath.Join(root, rel), []byte("MYSTERY\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	err := collab.Recover(store, root)
	if err == nil {
		t.Error("expected error for ambiguous working tree, got nil")
	}
	var ambig *collab.AmbiguousTreeError
	if !errors.As(err, &ambig) {
		t.Errorf("error is not AmbiguousTreeError: %v", err)
	}

	got, _ := store.GetTopicCommitSHA("t1")
	if got.Valid {
		t.Errorf("topic.commit_sha was set during ambiguous recovery: %s", got.String)
	}
}

// helper to quiet unused-import warning if errors.As isn't already used
var _ = fmt.Sprintf

Run: go test ./internal/collab/... -run TestRecover -v Expected: FAIL — Recover and AmbiguousTreeError don't exist.

// internal/collab/recover.go
package collab

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

// AmbiguousTreeError is returned by Recover when an incomplete attempt's
// working tree is neither at the base Source nor at the proposed Source.
// Callers should surface the embedded SourcePath to operators.
type AmbiguousTreeError struct {
	SourcePath string
	AttemptID  string
}

func (e *AmbiguousTreeError) Error() string {
	return fmt.Sprintf(
		"collab: working tree for %s is neither base nor proposal; operator intervention required (attempt=%s)",
		e.SourcePath, e.AttemptID,
	)
}

// Recover runs startup reconciliation. Two passes:
//
//   1. For each commit on HEAD carrying a "Topic:" trailer whose Topic
//      still has commit_sha IS NULL: complete the incorporation.
//   2. For each remaining incomplete incorporation_attempts row:
//      compare the working tree to base/proposed shas; resume or
//      surface AmbiguousTreeError.
//
// Returns the first error encountered. Callers should abort startup on
// AmbiguousTreeError and leave operator intervention to a human.
func Recover(s *Store, repoRoot string) error {
	if err := recoverFromCommits(s, repoRoot); err != nil {
		return err
	}
	return recoverFromAttempts(s, repoRoot)
}

func recoverFromCommits(s *Store, repoRoot string) error {
	commits, err := ListIncorporationCommits(repoRoot)
	if err != nil {
		return fmt.Errorf("list incorporation commits: %w", err)
	}
	for _, c := range commits {
		got, err := s.GetTopicCommitSHA(c.TopicID)
		if err != nil {
			// Topic might not exist in this DB (e.g. cloned-but-empty);
			// skip silently — there's nothing to reconcile.
			continue
		}
		if got.Valid {
			continue // already reconciled
		}

		// Find the proposal row by (topic_id, revision_number).
		var propID, attemptID, baseSourceSHA string
		err = s.db.QueryRow(
			`SELECT id, base_source_sha FROM incorporation_proposals
			 WHERE topic_id = ? AND revision_number = ?`,
			c.TopicID, c.ProposalRev,
		).Scan(&propID, &baseSourceSHA)
		if err != nil {
			// No matching proposal — can happen if the proposal row was
			// never persisted. Log and skip.
			continue
		}
		err = s.db.QueryRow(
			`SELECT id FROM incorporation_attempts WHERE proposal_id = ?`, propID,
		).Scan(&attemptID)
		if err != nil {
			// No attempt row either — pre-attempts-table commit; create
			// a synthetic completed row so the topic_messages FK works
			// in the future if anyone joins on the attempt.
			attemptID = randomID()
			if _, err := s.InsertAttempt(NewAttempt{
					ID: attemptID, ProposalID: propID, TopicID: c.TopicID,
					SourcePath: pathFromTopic(s, c.TopicID),
					BaseSourceSHA: baseSourceSHA,
					ApprovedBy: c.ApprovedBy,
				}); err != nil {
				return fmt.Errorf("synthesize attempt: %w", err)
			}
		}
		// Look up the user_id from the Approved-by trailer if we can; for
		// v1 single-operator deployments, this is the operator. We don't
		// parse the trailer strictly — sub-project #7 (Identity) owns that.
		if err := s.CompleteIncorporation(CompleteIncorporationInput{
			TopicID:        c.TopicID,
			ProposalID:     propID,
			AttemptID:      attemptID,
			CommitSHA:      c.SHA,
			IncorporatedBy: extractUserID(c.ApprovedBy),
			IncorporatedAt: c.AuthorTime,
		}); err != nil {
			return fmt.Errorf("complete incorporation for %s: %w", c.TopicID, err)
		}
	}
	return nil
}

func recoverFromAttempts(s *Store, repoRoot string) error {
	attempts, err := s.ListIncompleteAttempts()
	if err != nil {
		return fmt.Errorf("list incomplete attempts: %w", err)
	}
	for _, a := range attempts {
		prop, err := s.GetProposal(a.ProposalID)
		if err != nil {
			return fmt.Errorf("load proposal %s: %w", a.ProposalID, err)
		}
		cleanPath, err := ValidateSourcePath(a.SourcePath)
		if err != nil {
			return fmt.Errorf("unsafe attempt source_path %q: %w", a.SourcePath, err)
		}
		currentSHA, err := SourceSHA(repoRoot, cleanPath)
		if err != nil && !errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("hash %s: %w", cleanPath, err)
		}
		proposedSHA, err := SourceSHAOfBytes(repoRoot, []byte(prop.ProposedSource))
		if err != nil {
			return fmt.Errorf("hash proposed for %s: %w", cleanPath, err)
		}

		switch currentSHA {
		case a.BaseSourceSHA:
			// Case 2a: file write never happened. Run the full commit.
			sha, err := CommitSourceRewrite(CommitInput{
				RepoRoot: repoRoot, RelPath: cleanPath,
				NewContent: []byte(prop.ProposedSource),
				Subject:    fmt.Sprintf("Incorporate Topic %s (recovered)", a.TopicID),
				ApprovedBy: a.ApprovedBy, TopicID: a.TopicID,
				ProposalRev: prop.RevisionNumber,
				AuthorName:  "Orcha Agent", AuthorEmail: "agent@orcha.local",
			})
			if err != nil {
				return fmt.Errorf("commit recovery for %s: %w", cleanPath, err)
			}
			if err := s.CompleteIncorporation(CompleteIncorporationInput{
				TopicID: a.TopicID, ProposalID: a.ProposalID, AttemptID: a.ID,
				CommitSHA: sha, IncorporatedBy: extractUserID(a.ApprovedBy),
				IncorporatedAt: time.Now().Unix(),
			}); err != nil {
				return fmt.Errorf("complete recovery for %s: %w", cleanPath, err)
			}
		case proposedSHA:
			// Case 2b: file write happened, commit didn't. Stage + commit
			// without rewriting the file. Use the same pathspec discipline
			// as CommitSourceRewrite: never run bare `git commit`.
			if err := runGit(repoRoot, nil, "add", "--", cleanPath); err != nil {
				return fmt.Errorf("git add %s: %w", cleanPath, err)
			}
			msg := fmt.Sprintf(
				"Incorporate Topic %s (recovered)\n\nApproved-by: %s\nTopic: %s\nProposal: %d\n",
				a.TopicID, a.ApprovedBy, a.TopicID, prop.RevisionNumber,
			)
			if err := runGit(repoRoot, nil,
				"-c", "user.name=Orcha Agent",
				"-c", "user.email=agent@orcha.local",
				"commit", "-m", msg,
				"--", cleanPath,
			); err != nil {
				return fmt.Errorf("git commit recovery: %w", err)
			}
			out, err := runGitOutput(repoRoot, "rev-parse", "HEAD")
			if err != nil {
				return err
			}
			if err := s.CompleteIncorporation(CompleteIncorporationInput{
				TopicID: a.TopicID, ProposalID: a.ProposalID, AttemptID: a.ID,
				CommitSHA: out, IncorporatedBy: extractUserID(a.ApprovedBy),
				IncorporatedAt: time.Now().Unix(),
			}); err != nil {
				return fmt.Errorf("complete recovery for %s: %w", cleanPath, err)
			}
		default:
			return &AmbiguousTreeError{SourcePath: cleanPath, AttemptID: a.ID}
		}
	}
	return nil
}

func pathFromTopic(s *Store, topicID string) string {
	var p string
	_ = s.db.QueryRow(`SELECT source_path FROM topics WHERE id = ?`, topicID).Scan(&p)
	return p
}

// extractUserID pulls "user-id" out of "Display Name <user-id>". If the
// pattern doesn't match, it returns the whole string. The full identity
// model is sub-project #7's job.
func extractUserID(approvedBy string) string {
	start := -1
	end := -1
	for i, r := range approvedBy {
		if r == '<' {
			start = i + 1
		} else if r == '>' {
			end = i
			break
		}
	}
	if start >= 0 && end > start {
		return approvedBy[start:end]
	}
	return approvedBy
}

// runGitOutput is like runGit (in gitops.go) but returns trimmed stdout.
// Used by recovery to read `git rev-parse HEAD` after a fix-up commit.
func runGitOutput(repoRoot string, args ...string) (string, error) {
	out, err := exec.Command("git", append([]string{"-C", repoRoot}, args...)...).Output()
	if err != nil {
		return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
	}
	return strings.TrimRight(string(out), "\r\n"), nil
}

The full imports for recover.go:

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"time"
)

Note: runGit lives in gitops.go and is reused here — there is no second copy. The only new local helper is runGitOutput.

Run: go test ./internal/collab/... -run TestRecover -v Expected: PASS, three recovery tests (all three cases).

git add internal/collab/recover.go internal/collab/recover_test.go
git commit -m "wiki-browser: collab — startup recovery for three crash cases"

Task 10: Wire into main.go

Files:

This task is manually verified, not TDD-tested. The run function in main.go is the integration seam between every package; pulling enough of it apart to unit-test the wiring would be a refactor larger than the wiring change itself. Earlier tasks already provide deep unit-test coverage of every collaborator (collab.Open, collab.Recover, config loading); the work in this task is to call them in the right order and surface their errors via the existing error path.

In cmd/wiki-browser/main.go, add to the imports:

"github.com/getorcha/wiki-browser/internal/collab"

Inside run, after idx.SetCache(renderCache) and before the rootCtx, cancel := signal.NotifyContext... line, insert:

	collabStore, err := collab.Open(collab.Config{
		Path:                cfg.CollabDB,
		OperatorUserID:      cfg.Operator.UserID,
		OperatorDisplayName: cfg.Operator.DisplayName,
	})
	if err != nil {
		return fmt.Errorf("open collab store: %w", err)
	}
	defer collabStore.Close()
	if err := collab.Recover(collabStore, cfg.Root); err != nil {
		return fmt.Errorf("recover collab state: %w", err)
	}
	slog.Info("collab store ready", "path", cfg.CollabDB)

If fmt is not already imported, add it.

The store is opened, recovered, and deferred for close. Downstream sub-projects will receive collabStore via the server.Deps struct in their own implementations; sub-project #1 stops short of wiring it into HTTP handlers — there's no UI yet.

Run: make -C /home/volrath/code/orcha/wiki-browser build Expected: build succeeds with no errors. (This catches type / import mistakes in the wiring.)

Run: go test ./... -v 2>&1 | tail -40 Expected: PASS — all existing tests + the new collab tests. (Catches the case where the wiring change broke something elsewhere.)

Run: pkill -f 'dist/wiki-browser'; sleep 1; nohup /home/volrath/code/orcha/wiki-browser/dist/wiki-browser -config=/home/volrath/code/orcha/wiki-browser/wiki-browser.yaml >/tmp/wb.log 2>&1 & disown; sleep 1; curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8080/ Expected: HTTP 200. Inspect /tmp/wb.log and confirm it contains a line like collab store ready path=./wiki-browser-collab.db.

If the existing wiki-browser.yaml doesn't have the operator block, the binary will refuse to start. Copy the relevant lines from wiki-browser.example.yaml first (Task 11 covers this for the local config).

git add cmd/wiki-browser/main.go
git commit -m "wiki-browser: main — open collab store + run startup recovery"

Task 11: Update local config with the operator block

Files:

Append to /home/volrath/code/orcha/wiki-browser/wiki-browser.yaml:

collab_db: "./wiki-browser-collab.db"

operator:
  user_id:      "daniel"
  display_name: "Daniel Barreto"

(The local YAML is gitignored; this step is for the runtime, not the repo.)

Run: grep -n 'collab' /home/volrath/code/orcha/wiki-browser/.gitignore || true

Expected: existing line(s) for wiki-browser-index.db* exist. If wiki-browser-collab.db* is not yet covered, add it:

# Add or merge under the existing DB ignore lines:
wiki-browser-collab.db
wiki-browser-collab.db-wal
wiki-browser-collab.db-shm

Run: pkill -f 'dist/wiki-browser'; sleep 1; nohup /home/volrath/code/orcha/wiki-browser/dist/wiki-browser -config=/home/volrath/code/orcha/wiki-browser/wiki-browser.yaml >/tmp/wb.log 2>&1 & disown; sleep 1; tail /tmp/wb.log Expected: log contains collab store ready path=./wiki-browser-collab.db.

Run: ls -la /home/volrath/code/orcha/wiki-browser/wiki-browser-collab.db* Expected: the .db file exists (and likely -wal / -shm sidecars once WAL has been used).

Run: sqlite3 /home/volrath/code/orcha/wiki-browser/wiki-browser-collab.db 'SELECT id, display_name FROM users;' Expected: one row, daniel|Daniel Barreto.

If sqlite3 is not installed, skip this step or use the Go binary's RawDBForTest exposed via a quick scratch program.

git add .gitignore
git commit -m "wiki-browser: gitignore — collab DB + WAL/SHM sidecars"

Don't commit wiki-browser.yaml itself; it's gitignored.


Self-Review

I'm running this against the spec:

1. Spec coverage:

2. Placeholder scan: Verified — every step has concrete code or a concrete command. No "TBD", no "add error handling later," no "similar to Task N."

3. Type consistency: Store, Config, NewTopic, NewMessage, NewProposal, NewAttempt, CommitInput, IncorporateInput, CompleteIncorporationInput, IncompleteAttempt, Proposal, CommitTrailers, AmbiguousTreeError — names and field shapes are consistent across tasks (search reveals no rename mid-plan).

4. Code review fixes applied after first draft:


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-11-document-model-implementation.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?