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.
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.
Files:
internal/config/config.gointernal/config/config_test.go (existing — append new tests AND update the expected struct in TestLoad_valid)internal/config/testdata/valid.yaml (existing — add operator block)internal/config/testdata/minimal.yaml (existing — add operator block)internal/config/testdata/missing-root.yaml (existing — add operator block so the existing "missing root" error stays the failure mode)internal/config/testdata/bad-root.yaml (existing — add operator block, same reasoning)wiki-browser.example.yamlThe 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.
TestLoad_valid expected struct and add new testsIn 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"
Files:
Create: internal/collab/migrate.go
Create: internal/collab/migrate_test.go
Create: internal/collab/migrations/.gitkeep (empty file so the directory commits cleanly until Task 3 adds the real migration)
Step 1: Write the failing test
// 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"
Files:
Create: internal/collab/migrations/001_initial.sql
Create: internal/collab/schema.go (the embed.FS view of migrations/)
Modify: internal/collab/migrate_test.go (add a smoke test that runs the real schema)
Step 1: Write the failing test
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)"
Files:
Create: internal/collab/collab.go
Create: internal/collab/collab_test.go
Step 1: Write the failing test
// 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"
Files:
internal/collab/hashes.gointernal/collab/hashes_test.gointernal/collab/path.gointernal/collab/path_test.goValidateSourcePath 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.
ValidateSourcePathCreate 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.
path.goCreate 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"
Files:
Create: internal/collab/gitops.go
Create: internal/collab/gitops_test.go
Step 1: Write the failing test
// 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"
Files:
internal/collab/mutators.gointernal/collab/mutators_test.goThis 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)"
Files:
internal/collab/incorporate.gointernal/collab/incorporate_test.goThis 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)"
Files:
Create: internal/collab/recover.go
Create: internal/collab/recover_test.go
Step 1: Write the failing test
// 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"
Files:
cmd/wiki-browser/main.goThis 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"
Files:
Modify: wiki-browser.yaml (the local, gitignored config)
Modify: .gitignore (verify wiki-browser-collab.db* is excluded)
Step 1: Add the operator block to your local config
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.
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:
-- <RelPath> (won't sweep pre-staged unrelated changes), preserves existing file mode, and has a regression test.source_path.valid.yaml, minimal.yaml, missing-root.yaml, bad-root.yaml), not just appending new tests.os/exec, no indirection); no dangling execCommand symbol.git commit -- <source_path> and has a regression test proving unrelated staged files stay out of the recovered commit.RowsAffected for both the Topic update and attempt completion, so missing IDs cannot commit silently.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?