Living log of cross-cutting decisions made during sub-project brainstorms, plus parking-lot items destined for future sub-projects. Update at the end of each sub-project's brainstorm; add to the parking lot whenever a discussion surfaces an issue that belongs in a different sub-project.
Companion to the domain model.
Brainstorm complete. Full design at 2026-05-11-document-model-design.html.
Source = file in git, Agent-only writer. The orcha repo is the storage and version control for Sources. The Agent (a Claude Code instance) is the only writer; humans never edit the Source directly. Why: preserves git history, blame, integration with the rest of the repo workflow, and reuses the existing wiki-browser file-serving pipeline.
1 Incorporation = 1 commit. Each approved Incorporation lands as exactly one commit on the working branch. Why: git history mirrors the conversation that produced each change. Auditable; no branch churn.
Incorporation flow = Option C: commit immediately, but with a diff review gate in the UI. Agent proposes → human sees a diff → human approves → commit lands. No side branches; the diff is the review gate. Why: singular, linear history; humans never have to think about branches.
Diff UI MVP = Tier 1 + Tier 2.
git diff style, +/- in a code block).All non-Source state lives in a single SQLite DB (wiki-browser-collab.db): Topics, topic messages, incorporation proposals, perspective definitions, perspective cache, and the stub users table. Why: one store, one write funnel, one connection pool. Mirrors the existing indexer's storage pattern.
Pending incorporation = append-only proposal log, not a single overwriteable row. Every Agent proposal (including reworks) inserts a new row. The proposal table is event-log shaped and includes the generated Source plus the Source hash it was generated against: (id, topic_id, revision_number, proposed_source, base_source_sha, proposed_by, created_at). Why: humans want to see the chain of attempts and rework feedback as history; the audit trail is data, not derivable from git. base_source_sha lets approval detect stale proposals before writing Source.
Rework loop = iterated invocation of the same Incorporate capability, not a new Agent capability. Whether the Agent is called once or N times before a proposal lands, it's the same operation: "produce a proposed rewrite given Source + Topic discussion + (optional) prior proposal + (optional) human feedback." Why: keeps the Agent's capability surface unchanged across the rework loop.
No status column on proposals. All three states (proposed, superseded, incorporated) are derivable from existing data: incorporated = topics.incorporated_proposal_id points at this row; superseded = a higher revision_number exists for the same topic_id; proposed = otherwise. Why: stored state would denormalize and could drift; derivation is cheap on the natural indexes.
Incorporation outcome lives on the Topic, not on the proposal. topics.commit_sha, topics.incorporated_proposal_id, topics.incorporated_by, topics.incorporated_at. The proposal table stays a pure event log. Why: "what did this Topic produce?" is a natural one-step lookup on the Topic; integrity (one commit per Topic) is trivial when stored once; no denormalization risk.
Topic state is derivable from outcome columns: incorporated = commit_sha IS NOT NULL; discarded = discarded_at IS NOT NULL; open = neither. (orphaned is a property of the anchor, owned by #2.) Why: same logic as proposal state — keep stored data minimal and derived predicates explicit.
Perspective cache keyed by both source_sha and persona_sha. Cache invalidates when either dimension changes. Why: editing a persona's prompt should invalidate cached generations as surely as changing the Source does.
Perspective definitions live in the DB, not on disk. Originally proposed as sidecar .collab.yml files; revised because splitting related data (defs on disk, generated content in DB) was an aesthetic preference, not a technical one. Why: consistency — all collaborative-annotation metadata in one store. Editing personas becomes a UI concern (sub-project #8).
Perspective refresh = lazy. No eager regeneration on Incorporation. The cache invalidates implicitly via sha mismatch; the first read after a change waits for generation. The UI must communicate that a Perspective is regenerating. Why: eager refresh spends LLM budget on Perspectives no one might look at; lazy is the cheapest policy that still works.
Per-Document Perspective definitions. Each Document defines its own set of Perspectives (perspective_defs.source_path is part of the primary key). No global Perspectives. Why: Perspectives are tailored to a doc's content; a "CFO view" of a financial doc and of a deploy doc don't share much.
Agent's git identity = a dedicated Agent author. Commits are authored by something like Orcha Agent <agent@orcha.local>. Trailers in the commit message attribute the human approver and the Topic ID. Why: git blame should reflect the actual writer (the Agent). Trailers preserve who-approved and which-conversation context, searchable in git log.
No opt-in mechanism for "collaborative Documents" in v1. Every file served by wiki-browser is eligible. A Document springs into existence the moment someone opens the first Topic on it. Files with no Topics and no Perspective defs remain identical to today's read-only view. Why: zero ceremony; collaborative-ness is opt-in by action, not configuration.
SQLite write concurrency = single-funnel goroutine. Mirror internal/index/index.go's pattern: all writes to wiki-browser-collab.db go through one goroutine reading a buffered channel; reads run concurrently. Why: proven pattern in this codebase; SQLite handles concurrent reads natively; serializing writes avoids busy-locked errors.
Users in the data model are stubbed. A users table holds (id, display_name, created_at). Auth/authz is an orthogonal project that decides how rows get into the table and how requests authenticate. The data model just references users.id from topics.created_by, topic_messages.author_user_id, incorporation_proposals.proposed_by, topics.incorporated_by, and topics.discarded_by. Why: these tables need a stable user identifier to point at; the authentication mechanism is independent of how this initiative shapes its data.
The Agent is not a user. It never appears in users. Agent-authored content (proposed sources, future system messages) is recognised by kind columns on the relevant tables (e.g. topic_messages.kind='agent-proposal') and may have null author_user_id. Why: the Agent is a system actor, not an authenticated identity.
source_sha is the git blob SHA. Specifically the 40-char SHA-1 returned by git hash-object <path> (or git ls-tree HEAD -- <path> for the committed version). Why: content-addressed, stable, queryable without inventing a custom hash.
persona_sha is SHA-256(persona-text), hex-encoded. Recomputed by the harness on every write to perspective_defs.persona. Why: independent of any git mechanism, matches the lifecycle of persona edits.
Proposals carry base_source_sha. The git blob SHA of the Source at proposal generation time. On approval, the harness verifies it still matches the current Source; mismatch ⇒ stale, regenerate. Why: Topics back-link to source_path but not to a specific sha — a concurrent Incorporation on another Topic for the same path can advance the Source under an in-flight proposal. Caught at the schema level rather than relying on serialised incorporations.
DB-level CHECKs and composite FK enforce derived-state invariants. Topics have CHECK constraints for all-or-nothing incorporated columns, all-or-nothing discarded columns, and mutually exclusive outcomes. A composite FOREIGN KEY (incorporated_proposal_id, id) REFERENCES incorporation_proposals(id, topic_id) enforces that a Topic's chosen proposal belongs to that same Topic. Why: "state is derivable" only works if illegal combinations cannot exist in the first place.
Two-store atomicity = ordered writes + startup reconciliation. Incorporation write order: stale-check → insert incorporation_attempts recovery marker → write file → git commit → UPDATE topics and complete the attempt. Recovery on every startup handles both durable partial states: a commit whose SQLite Topic update is missing, or an incomplete attempt whose working tree is at either the base Source or the proposed Source. If the working tree is neither, Source writes for that Document stop until the operator resolves it. Why: SQLite and git cannot share a transaction, so the system needs an explicit recovery marker before mutating the working tree.
Collab DB is authoritative, not disposable. Unlike the index DB (which can be rebuilt from disk), the collab DB carries irreplaceable data (Topics, messages, proposals, users). Location is configured in wiki-browser.yaml, gitignored, backed up by deployment. WAL mode enabled for concurrent reads with writes. Why: the earlier "process-local, rebuildable" framing was inherited from the index DB and didn't match this DB's role.
Foreign-key enforcement is per-connection via DSN. All connections in the pool (read or write) must have PRAGMA foreign_keys = ON. The project already uses modernc.org/sqlite, so the collab DB keeps that driver and opens with _pragma=foreign_keys(1) in the DSN. Why: SQLite's FK enforcement is connection-scoped; missing it on a read connection causes silent FK skips on subsequent writes through that connection if it's reused. Staying on modernc preserves the repo's pure-Go, CGO-free deployment path.
External Source renames / deletes are out of scope for v1. The Agent is the only authorised writer of Source per invariant; renames don't happen through the system. If a Source file is renamed or deleted by an external tool, rows keyed by the old source_path become orphaned with no automatic recovery in v1. Why: rename-tracking and tombstoning add nontrivial complexity for a case that doesn't occur in normal operation; defer to a future sub-project.
Users bootstrap from config at startup. wiki-browser.yaml carries an operator: { user_id, display_name } block; on every startup the harness runs INSERT OR IGNORE INTO users for that row. All v1 actions attribute to this single operator. Why: the FK constraints are NOT NULL, so we need some user from day one; auth remains an orthogonal project that can later add real provisioning without touching the schema.
Topic messages use per-topic sequence numbers for ordering. topic_messages.sequence is allocated by the single SQLite write funnel, unique per topic_id, and is the primary ordering key. created_at remains display metadata. Why: unix-second timestamps can collide during normal threaded conversation; deterministic ordering is cheaper to include now than to retrofit after real message data exists.
Brainstorm in progress. Decisions accumulate here as we settle them; the full design will live at docs/superpowers/specs/YYYY-MM-DD-topic-core-design.html when the spec is written.
Anchors are inline markers stamped into the Source by the Agent. Each open non-global Topic has at most one marker in its Source, rendered as an HTML element with data-orcha-anchor="<topic-id>". Incorporated and discarded Topics do not keep live markers in Source. The Agent has full control over what each marker wraps — a text span, an image, a heading, a figure, a code block, a mermaid fence — sidestepping anchor-kind polymorphism entirely. Why: the Agent is already the sole writer of Source; letting it stamp anchor primitives gives it full control and turns "what's anchorable" into "anything the Agent can wrap."
Marker ID = Topic ID (UUID). No separate marker-ID space, no allocation. The resolver searches the current Source for data-orcha-anchor="<topic-id>". Why: simplest mapping; UUIDs in attributes are ugly but bounded by open non-global Topics, not doc size.
Marker placement conventions:
<span data-orcha-anchor="<topic-id>">selected text</span>.<div data-orcha-anchor="<topic-id>"></div> immediately preceding the block, with a blank line between them in Markdown. div is one of CommonMark's block-level HTML tags so the marker becomes its own HTML block rather than being absorbed into the next paragraph. The resolver detects the empty-content marker pattern and projects the highlight onto the next sibling.Three anchor kinds, all stored as JSON in topics.anchor:
{kind: "pre-marker", source_sha, start, end, quote} — captured at Topic creation when the user selected text in the rendered Document. Means "Agent owes us a marker on next Incorporation." start/end are byte offsets in the Source pinned by source_sha.{kind: "marker"} — Agent has placed data-orcha-anchor in the current Source. The marker is the locator; nothing else needed.{kind: "global"} — deliberately document-level. The user created the Topic without selecting any text, intending it as a comment on the whole Document. No marker in the Source; never transitions to marker. Listed in a "Global topics" group in the sidebar.No orphan state. Every open Topic with marker has a marker the Agent placed; every Topic with global is deliberate. If an open idea doesn't map cleanly to a rewrite, the Agent parks it in an "Other ideas (potentially to discard)" section at the bottom of the new Source — marker included. Why: orphans would represent a botched Incorporation; keeping every open idea visible as text in the doc avoids any "stranded Topics" UI.
Every Incorporation re-anchors all other open non-global Topics on the affected Source path. The Agent's prompt receives the Topic being incorporated plus all other open Topics with their current anchors; it must emit a data-orcha-anchor for each other open non-global Topic in the proposed Source (placed naturally, or parked in the "Other ideas" section). After the commit lands, those other open Topics' anchors transition from pre-marker to marker in one SQLite transaction. The incorporated Topic closes and its marker is not retained in the committed Source. Why: keeps the steady state clean — open Topics are always marker except in the brief window between Topic creation and the first Incorporation on that Source, while resolved Topics do not accumulate dead UUID markers in Source.
The Agent's parking-lot rule is owned by #4's Incorporation prompt. From #2's perspective, the invariant is just: "every other open non-global Topic on this Source must have a marker in the new Source." How the Agent fulfills it (where to park unmappable ideas, what section heading to use) is part of #4.
Source-position attributes plus a server-side render map, emitted by the renderer in #2. Every block-level element in the rendered output carries data-source-start="N" data-source-end="M" referencing byte offsets in the Source. The render package also returns a block-scoped rendered-text-to-Source map so selections crossing inline markup, links, entities, or normalized whitespace can still translate to Source bytes without raw substring matching. Why: substrate for client-side selection capture (#2), the diff viewer (#4), and UI integration (#8). Doing it once here avoids rework. goldmark's AST already tracks segment positions; the renderer records them during rendering.
Selection capture mechanism. Client-side JS on a completed selection: find the nearest block ancestor, read its data-source-start/data-source-end, compute the rendered-text offsets of the selection within that block, and POST {source_path, source_sha, quote, block_source_start, block_source_end, rendered_start, rendered_end} to the server. The server first validates source_sha against the current Source (rejecting stale_source if the iframe was rendered against an older Source); then translates rendered offsets through the render map and computes exact (start, end) Source offsets for the anchor. Selections overlapping a NonSource render-map segment (renderer-injected content) return non_source_selection.
Render map structure: per-block linear segments. Each BlockMap carries Segments with {rendered_start, rendered_end, source_start, source_end, non_source}. Inside a segment, rendered ↔ Source positions translate linearly. Entity references and Markdown escapes collapse into a single segment whose Source range covers the whole encoded form (selections inside an entity expand outward). Renderer-injected content (syntax-highlighting tokens, list-marker glyphs, Mermaid SVG labels) is represented as non_source: true and is non-selectable. Why: the earlier wording "rendered text offsets → Source byte ranges" was too coarse to handle entities, escapes, or auto-injected text deterministically.
Rendered page carries source_sha. Every rendered Document includes <meta name="wb-source-sha" content="…"> so the client can echo it on Topic creation, letting the server detect a stale iframe before translating offsets. Why: a concurrent Incorporation between rendering and submission would change the Source under an in-flight selection; without the staleness check, the server would translate old offsets against new Source and land a wrong anchor with a fresh source_sha.
Cross-block selections rejected client-side for v1. A selection that spans more than one block element disables the composer's Save with an "please select inside a single block" message; the API carries one block range per request. Multi-block anchors are deferred — extending the API and storage shape becomes a future decision.
Topic creation chrome is in #2. Google Docs-style: text selection in the rendered iframe pops a floating input box; the user types the initial message; Save creates the Topic + first message + pre-marker anchor in one transaction. A minimal sidebar lists Topics on the current Document and shows the message thread of the focused Topic. Why: the domain model says #2 ships "minimal UI"; without these affordances you can't demonstrate Topic creation end-to-end. Polished styling, Resolve button, Perspective switcher, and persona editor remain #8's.
topic_messages.kind enum = {'human', 'agent-proposal'} for v1. Topic resolution (incorporated / discarded) is derivable from topics outcome columns; no parallel message rows needed. Future kinds (e.g., system events, rework-request) can be added when a concrete need surfaces.
Overlapping anchors allowed. Nothing prevents a user opening a Topic on a region another open Topic already covers. The resolver handles partial intersections by splitting rendered text into intervals and emitting overlap metadata (data-topic-ids) for segments covered by multiple Topics. Why: collisions are rare; forbidding them adds UX friction for no real safety gain.
HTTP API surface for #2:
POST /api/topics — create a Topic. Body: {source_path, selection?, global?, first_message_body}. selection contains {quote, block_source_start, block_source_end, rendered_start, rendered_end}; global: true creates {kind: "global"}. Exactly one of selection or global: true is required. created_by comes from the bootstrap operator in config for v1.GET /api/topics?source_path=… — list open Topics for a Document (drives the sidebar).GET /api/topics/{id}/messages — message thread.POST /api/topics/{id}/messages — append a 'human' message.Resolved by #3 — see the "Sub-project #3" Decisions section below.
Brainstorm complete. Full design at 2026-05-11-identity-permissions-design.html.
Internet-facing deployment with public read-only documents. Wiki-browser will be reachable on the public internet through Nginx Proxy Manager. Document browsing routes (/, /doc/..., /content/..., /search, /static/...) remain readable without login. Why: the site should preserve the current read-only wiki experience for anonymous visitors.
Collaborator features are hidden from anonymous users. Signed-out UI should look like today's wiki-browser plus a Sign in affordance. No Topics sidebar, annotation highlights, selection composer, Resolve controls, Perspective controls, or collaborator bootstrapping data render for anonymous users. Why: public readers should not see disabled collaboration UI, and the server must not leak collaborator-only state for the client to hide.
Collaborator data is never public. Topics, topic messages, proposals, pending diffs, Perspective definitions, and Agent job metadata require an authenticated collaborator even when the Source document is public. Why: conversations about a public document may contain private planning, critique, or pending changes.
wiki-browser owns Google OAuth/OIDC and application sessions. Nginx Proxy Manager terminates HTTPS and forwards traffic only; it does not authenticate users or inject trusted identity headers. Why: auth behavior stays testable in Go, and permission checks live next to the collaboration handlers they protect.
Allowed collaborators are exactly daniel@getorcha.com and max@getorcha.com. Google proves the email address through OIDC; wiki-browser lowercases it and checks this exact allowlist. Both accounts have identical collaborator permissions in v1. Why: the deployment has two trusted users and does not need general account lifecycle or RBAC yet.
Email is the durable local users.id. The existing users table becomes the real identity table; users.id is the normalized email and display_name comes from Google's name claim when present. Existing audit columns (created_by, author_user_id, approved_by, etc.) continue to reference users.id. Why: the allowlist is email-based, and readable audit trails matter more than abstract subject identifiers for a two-user deployment.
OAuth auth fully replaces the bootstrap operator. Once #7 lands, operator is removed from required config and startup no longer inserts a single operator row. There is no local single-operator fallback when auth: is absent. Why: the internet-facing deployment needs one clear identity model, and every collaborative action should attribute to the authenticated principal.
Sessions are server-side and stored hashed. The browser receives an opaque high-entropy session token in a Secure, HttpOnly, SameSite=Lax cookie. SQLite stores only a hash of that token in an auth_sessions table with expiry and revocation metadata. Why: logout, expiry, and emergency revocation should work without exposing raw bearer tokens at rest.
CSRF is per-session and hash-backed. Each session has one opaque CSRF token. /auth/me returns the raw token only to same-origin signed-in JS; SQLite stores csrf_hash. All mutating endpoints, including logout, require the session cookie plus X-CSRF-Token. Why: SameSite helps, but internet exposure and cookie-backed auth still need an explicit write guard.
OAuth state and PKCE verifier are stored in short-lived SQLite rows. /auth/login stores state_hash, pkce_verifier, safe return_path, expiry, and consumption metadata; /auth/callback consumes the row once. Why: callback validation survives process restarts and has deterministic expiry/replay behavior.
Protected APIs return explicit auth errors. Missing/bad session on JSON APIs returns 401; authenticated-but-not-permitted returns 403. Browser navigation may redirect to Google, but APIs do not. Why: client code should handle auth state explicitly and avoid surprise HTML responses from JSON endpoints.
Session-varying HTML is not cached. /, /doc/..., and /content/... return Cache-Control: no-store and Vary: Cookie; protected JSON returns Cache-Control: no-store; static assets remain cacheable. Why: the same document URL can render anonymous or collaborator affordances, so shared/browser caches must not reuse authenticated variants publicly.
POST /api/topics, GET /api/topics, GET /api/topics/{id}/messages, and POST /api/topics/{id}/messages should use the authenticated principal, not the bootstrap operator.Brainstorm complete. Full design at 2026-05-12-agent-runtime-design.html.
Headless claude -p subprocess per job for v1; channels deferred to v2. The Go server spawns Claude Code with --dangerously-skip-permissions, cwd = wiki-browser/ (so project-local skills are discoverable), and no MCP. Why: Pi-friendly, mature, zero idle footprint, trivially testable. Channels are a real alternative (no documented session-lifetime cap) but add a supervised long-running process, a custom channel impl, and context-isolation discipline. The v1 skill code never assumes a fresh process, so the v2 swap is a runtime-only change.
From the Go process's perspective, the agent contract is binary: reachable / not reachable. Anything that happens inside the agent session — token limits, retries, tool errors, partial completion — is the agent's problem to handle and report. The Go layer records exit status + last 4 KiB of stderr in agent_jobs.error_tail and surfaces "agent failed; see log" to humans. Why: prevents the harness from accumulating model-specific error-handling logic; keeps the boundary clean.
Agent owns the work surface. It reads/writes Source files directly via Claude Code's standard tools and persists DB results via a new wb-agent CLI. The Go server is a launcher and status mirror; it does not parse agent stdout, does not enforce job-specific invariants (anchor placement, persona shape), and does not retry. Why: matches #1's "Agent is the sole writer of Source" invariant; keeps schema and validation as Go internals, not part of an ad-hoc wire format.
wb-agent is a separate binary, same Go module. Lives at cmd/wb-agent/main.go, ships alongside dist/wiki-browser. Opens the collab DB directly via the existing internal/collab code paths (reusing validation, FK enforcement, CHECK constraints, sequence allocation). Cross-process write contention handled by SQLite WAL + the existing busy_timeout=5000. Why: "agent writes directly to the DB" without bypassing the validation layer or coupling the skill prompts to the schema. Note: this weakens #1's "single-writer goroutine" invariant from "single writer, full stop" to "single writer per process, with SQLite handling cross-process contention via WAL + busy_timeout." Acceptable because wb-agent writes are low-volume (one in-flight job per Source) and the funnel discipline still applies within each process.
Job parameters travel in the prompt body, not env vars. The prompt is a short instruction naming the skill plus a labelled parameter block. Why: self-describing, replayable by copy-pasting the prompt into claude -p, no env-setup ritual, and Go's exec.Command(name, args...) sidesteps shell-escaping concerns that motivated env vars elsewhere.
Project-local skills under wiki-browser/.claude/skills/wb-incorporate/ and wb-perspective/. Same pattern as the existing playwright-cli skill. Why: versioned with the code that depends on them; editable without rebuilding the Go binary; discoverable via Claude Code's standard project-local skill resolution.
#3 ships skill scaffolds with <REWRITE CONTRACT OWNED BY #4/#5> markers plus minimal exit-zero stubs. The scaffold is more than a placeholder: it locks the parameter-block schema, the sequence of wb-agent subcommand calls the skill makes (e.g. get-topic, list-open-topics, insert-proposal for incorporate), and the on-disk file layout. Why: runtime can ship and be tested end-to-end before #4 and #5 finalise prompt wording. #4 and #5 fill in only the domain contract (the re-anchor + rewrite rules for #4; the persona/perspective prose contract for #5); the wb-agent surface and parameter shapes are not theirs to renegotiate.
Migration 003 makes incorporation_proposals.proposed_by nullable. Latent issue from #1 — the column was declared NOT NULL but the decisions doc states "the Agent is not a user; agent-authored content may have null user references." Never bit because nothing exercised the agent-proposal path. NULL proposed_by is the marker for an Agent-produced proposal. Why: keeping the constraint would force inventing an "agent" user row, which contradicts #7's allowlist and #1's invariant.
agent_jobs table is the single status surface. Tracks every invocation: (id, kind, source_path, topic_id?, persona_name?, status, started_at, completed_at, exit_code, error_tail, created_at). State derives via CHECK constraints. Lifecycle writes go through the same single-writer funnel as topics/messages/proposals. Why: one place the UI polls; one place future SSE could hook into; one place to debug "what did the agent do last."
Push-to-UI (SSE / WebSocket) deferred to sub-project #8. v1 ships pure polling against agent_jobs. Why: SSE/queueing is a UI-surface concern owned by #8, not a runtime concern owned by #3. #3 ships queryable state; #8 decides poll vs push without needing schema changes.
In-memory queue keyed by source_path; not persisted across restarts. At most one job per Source at a time. Global cap agent.max_concurrent_jobs defaults to 1 on the Pi. Why: at expected volumes (one operator, two collaborators, manually-triggered jobs) a lost queued entry is a UX paper cut, not a data-integrity hazard — the user just clicks "Propose rewrite" again. Persistence would buy a recovery story for queued state that has no partial side effects to recover, while complicating the simplest-correct implementation.
Startup sweep marks any running/queued rows as failed with error_tail = "server restarted while job in flight". The sweep runs before collab.Recover on every startup. Why: restores the invariant that no running row outlasts a server process. Ordering matters: collab.Recover reconciles incorporation_attempts against the working tree, which may have been left mid-write by an agent job; sweeping the agent_jobs rows first means the recovery logic sees consistent "this job failed" state rather than racing a still-running job row that no live process owns. Re-queueing is rejected because partial side effects may already be in the DB; surfacing failure lets the human decide whether to retry.
Agent's git identity is config plumbing. New agent.author_name / agent.author_email flow into the existing IncorporateInput.AuthorName/AuthorEmail. The agent does not commit — the harness does, post-approval. Why: keeps git blame truthful (Agent wrote the change) while preserving the audit trail in commit trailers (which human approved, which Topic produced it).
agent: config block is required. Startup fails with a clear error if absent. Why: silent fallback to the operator identity would corrupt the git author trail; explicit failure forces deployment to acknowledge the new operational surface.
Startup validates both binaries; missing either is a fatal config error. claude_bin defaults to "claude" and must resolve via exec.LookPath; wb_agent_bin defaults to filepath.Join(filepath.Dir(os.Executable()), "wb-agent") and must os.Stat cleanly. Both validations run during startup config parsing — before HTTP listeners open — and abort the process on failure. The resolved absolute paths are what flow into the prompt body (per the "Repo root" refinement below). Why: the binary boundary is the only thing the harness cannot recover from at job time. Failing fast at startup turns a runtime UX failure ("propose rewrite" silently 500s) into an operational error visible at deploy time.
Per-kind timeouts are explicit configuration. agent.incorporate_timeout defaults to 5m, agent.perspective_timeout defaults to 3m; both parse via time.ParseDuration. The per-job context carries the kind's timeout; on expiry the runner cancels the process group (SIGTERM → 5s grace → SIGKILL fallback per cmd.WaitDelay) and writes status='timed_out' with error_tail describing the timeout. Why: the agent contract is "reachable / not reachable," but reachability includes liveness. Without a wall-clock cap, a stuck agent holds the per-Source slot indefinitely. Per-kind because perspective generation is bounded prose; incorporation may touch a large Source and warrant more headroom.
--dangerously-skip-permissions lifts Claude Code's cwd-rooted filesystem restriction. With this flag, the agent can Read/Write cfg.Root from a subprocess whose cwd is wikiBrowserRoot (one level down). No --add-dir plumbing is needed. Why: the documented Claude Code default restricts Read/Write tools to cwd and child paths; the agent must edit Source files that live above cwd. Skipping permissions is acceptable here because the agent is a trusted system actor invoked by the server, not a user-driven assistant, and the spawn is fully unattended.
No automatic retries on agent failure. Failed/timed-out jobs are surfaced to the user, who retries from the UI. Why: auto-retry risks burning model budget on bad prompts and masks real failures.
Post-exit invariant check (incorporate, #3 baseline): the service verifies a new incorporation_proposals row exists for the expected topic_id with created_at >= job.started_at. Exit 0 with no new row → status=failed, error_tail="agent exited 0 but produced no proposal". Why: the cheapest #3 baseline to catch a confused agent that "completed" without producing the artefact. Superseded for #4 Agent proposals: migration 004 adds incorporation_proposals.agent_job_id, and #4's post-exit check prefers that explicit link.
wb-incorporate SKILL.md prompt body. #3 ships the scaffold and the parameter block; #4 fills in the re-anchor + rewrite contract per #2's invariants. The list of wb-agent calls the skill makes is defined here; #4 may extend it.data-orcha-anchor markers for other open Topics, or that the parking-lot section convention from #2 is honored.wb-agent list-open-topics output / total prompt size. The skill loads every other open Topic on the Source into the agent's context for re-anchoring. With many open Topics or long discussions, the prompt could grow unbounded. #4 decides whether to cap N, truncate per-Topic message history, or fail the job with a clear error. #3 ships the call unbounded.5m / 3m) but #4 may want to revisit incorporate_timeout based on observed run times across real Sources.wb-perspective SKILL.md prompt body.wb-agent get-persona and wb-agent put-perspective subcommand implementations — #3 ships scaffolds only.put-perspective but defined by #5.running, error toast on failed/timed_out, success surface when a new proposal appears.Pass Repo root and wb-agent path in the prompt body, not env or PATH. The subprocess cwd is fixed to wikiBrowserRoot for skill discovery, but Source paths are stored relative to cfg.Root (the orcha monorepo root, one level above). Without an explicit absolute reference the skill would silently read the wrong tree. Same logic for wb-agent: it ships in dist/wb-agent next to the wiki-browser binary, not on $PATH. Both values are computed at startup (cfg.Root and filepath.Join(filepath.Dir(os.Executable()), "wb-agent") by default) and validated. Why: environment-PATH magic is fragile; explicit absolute paths are deterministic and replayable.
Process-group teardown is explicit; default exec.CommandContext is not enough. Claude Code spawns child processes for Bash tool calls. The runner sets SysProcAttr.Setpgid: true, installs a cmd.Cancel that signals the whole group with SIGTERM, and uses cmd.WaitDelay = 5s for the SIGKILL fallback. Why: Go's default context-cancel kill is single-process and SIGKILL-only; that leaks wb-agent or other child processes if a job times out.
agent_jobs CHECK constraint is iff, not just-one-side. The discriminator must enforce that incorporate jobs have a null persona_name and perspective jobs have a null topic_id, not just that the expected field is present. Why: this table is the UI status of truth; loose constraints would let a buggy mutator persist nonsense rows that pass schema validation.
Migration 003 uses a new -- migrate:no-tx directive. PRAGMA foreign_keys is a no-op inside a transaction, so the existing migrate-runner-wraps-in-tx pattern cannot perform the SQLite twelve-step table rebuild safely. The runner gains a one-line directive: when the migration file's first non-blank line is -- migrate:no-tx, the runner skips its own BEGIN/COMMIT and lets the migration manage FK toggling and transactions itself. schema_migrations bookkeeping still happens via the runner in a separate short transaction after the file executes. Why: preserves the "atomic migration apply" property for all other migrations while making the rare schema-altering migration safe.
Brainstorm complete. Full design at 2026-05-13-topic-resolution-incorporation-design.html.
No "rework" concept in the data model. Subsequent proposal requests are identical to the first; the only difference is that more messages have accumulated in the Topic's thread between attempts. The propose endpoint (POST /api/topics/{id}/proposals) takes no body. Rework feedback is delivered through the existing POST /api/topics/{id}/messages endpoint from #2, not as an inline field on propose, a new topic_messages.kind, or a feedback_annotations column on proposals. Why: the schema already supports threaded conversation and an append-only proposal log; carving out a separate "rework" path would duplicate both for no semantic gain. Richer rework UIs (inline diff annotations, margin comments) compose by writing more elaborate 'human' message bodies — no schema change required. How to apply: reject any future design that wants a rework_message, feedback_annotations, or similar field on the propose endpoint or proposal rows. That data belongs in the message thread.
wb-agent subcommand surface keys on what each call naturally operates on. Every subcommand still takes --config=<path>. Identifier flags:
get-topic --job-id=<id> — returns {topic, source_path (absolute), base_source_sha, anchor, messages[]}. repo_root is not returned separately; source_path is already absolute.list-open-topics --source-path=<abs> --exclude-topic=<id> — source-scoped, with explicit exclusion.insert-proposal --job-id=<id> --explanation=<inline-text> — reads proposed Source on stdin, writes both the proposal row and the accompanying agent-proposal message (body = explanation) in one transaction.The --id / --topic-id / --base-sha flags shipped in #3's scaffold are removed, along with any defensive validation that existed only to support them. Why: the only caller is the skill, and the skill should ask the wire for what it naturally needs. list-open-topics is semantically a question about a Source; a single uniform --job-id everywhere would force "ask about a source by passing a job," semantic awkwardness for marginal uniformity gain. Collapsing repo_root into an absolute source_path removes one more concatenation step from the skill body. How to apply: rewrites cmd/wb-agent/{get_topic,list_open_topics,insert_proposal}.go and their tests; cleans up any path-handling that assumed split repo_root/source_path.
Slim the agent prompt to bootstrap-only, in user-task voice. The prompt body the harness ships carries Job ID, Config path, and wb-agent path — phrased as a request from a human asking the Agent to help (e.g. "Please help me incorporate a Topic discussion into a shared document"), not as an infrastructure parameter dump. The four redundant parameters (Repo root, Source path, Base source SHA, Topic ID) drop because the skill discovers them through one wb-agent get-topic --job-id=… call. Why: the agent works better when it understands the situation it's in. Naming "the harness" and "the operator" inside the prompt invites infrastructure framing; speaking as a user invites task framing. The remaining three values are unavoidable: Claude Code's cwd is the skill dir (so the binary needs an absolute path), wb-agent needs --config to support multi-instance deployments, and the job-id is the only thing that varies per invocation. How to apply: rewrites internal/agent/claude_cli_runner.go's prompt builder; rewrites the SKILL.md body in matching voice with a short context paragraph defining Topic / Anchor / Source.
Rewrite contract is encoded in wb-incorporate/SKILL.md, not in the harness. The Agent reads the working set via wb-agent get-topic / list-open-topics, applies the Topic's discussion to the Source, drops the incorporated Topic's marker, stamps a data-orcha-anchor for every other open non-global Topic — naturally where each maps (allowing multiple markers per Topic), otherwise under a ## Other ideas (potentially to discard) section at the bottom of the Source — and writes a 1–3 paragraph natural-language explanation of what it understood the Topic to be asking for and how its rewrite addresses it. Prior proposals surface inside get-topic's message thread as kind='agent-proposal' rows with their proposed_source inlined; no separate get-latest-proposal subcommand. Why: matches #2's invariant that every open non-global Topic on a Source carries at least one marker, #1's append-only proposal log, and the new explanation requirement (see below). How to apply: replaces the <REWRITE CONTRACT OWNED BY #4> placeholder in the skill file wholesale; #5 will own the equivalent body in wb-perspective.
Agent attaches a natural-language explanation to every proposal; stored as the agent-proposal message body; no schema change. Every proposal insert writes both a row in incorporation_proposals (the bytes) and a row in topic_messages with kind='agent-proposal', proposal_id linking to the proposal, and body = the explanation. Today's agent-proposal messages have empty bodies; we start using the column. The Resolve UI surfaces the explanation above the diff. Why: humans approving a rewrite need to know what the Agent thought they were asking for, not just what bytes it produced. The diff shows the outcome; the explanation shows the intent. Without it, every disagreement requires reverse-engineering the prompt. How to apply: wb-agent insert-proposal accepts --explanation=<inline-text> as an argv flag, reads proposed Source on stdin, and inserts both rows in one transaction. The post-job invariant checks LENGTH(TRIM(body)) > 0 on the agent-proposal message.
No temp files in the explanation/source wire. wb-agent insert-proposal takes the explanation inline via --explanation=<text> argv and the proposed Source via stdin. The skill never writes a sidecar file. Why: lingering files in /tmp after job failures are a class of operational paper-cut that this design avoids by not having them at all. Linux argv limit is 128 KiB; 1–3 paragraphs of natural-language explanation is well under that. Pathological cases (e.g. literal null bytes) would fail at the shell level before reaching wb-agent and can be revisited by switching to a length-prefixed stdin framing — a SKILL.md-only change, no server impact.
Multiple anchors per Topic allowed (revision of #2). #2's "each open non-global Topic has at most one marker in its Source" relaxes to "at least one." A Topic can wrap multiple regions when its semantic intent naturally spans them (e.g. "rename foo everywhere"). The post-job invariant becomes "at least one occurrence of data-orcha-anchor="<id>"," not exactly one. The parking-lot section stays as the safety valve for Topics that don't map to any region. Why: the renderer already handles overlapping/multi-occurrence anchors via data-topic-ids; forcing one was conservative; the relaxation costs nothing in implementation and removes an artificial constraint on the Agent's expression. How to apply: updates the post-job invariant string match and the SKILL.md placement rules; #2's renderer needs no change.
Discard is a pure SQLite transition; no Agent job. POST /api/topics/{id}/discard appends a final kind='human' message with the discard reason (author_user_id = the discarding user) and sets discarded_at / discarded_by in one transaction. The Topic's data-orcha-anchor marker (if any) stays in committed Source until the next Incorporation on that Source rewrites it; the renderer treats markers for non-open Topics as inert, so stale markers are invisible to readers. Why: the alternatives are (a) the harness writing to Source — violates "only the Agent writes the Source" — or (b) spawning a no-decision Agent job, burning model budget for no user-visible benefit. The next Incorporation cleans up the marker for free.
Discard reason rides on kind='human'; no new topic_messages.kind. Considered: a dedicated kind='topic-discarded' would be cleaner in the abstract — the discard reason is metadata about the closing action, not a continuation of the discussion. Rejected because the discarding user is already captured by topic_messages.author_user_id (matching topics.discarded_by) and the discard timestamp by topics.discarded_at; the thread display naturally ends with the human's reason as the last message, which is what a reader wants. A new kind would force every reader (current and future) to special-case rendering for one row that already reads correctly as a human message. Why: #2's kind enum can grow when a concrete need surfaces, but this need is cosmetic, not structural. If a future affordance needs to distinguish discard reasons (e.g., a "closing remark" badge), derive it from (topic_messages.sequence == max sequence) AND (topics.discarded_at IS NOT NULL) — no schema change. How to apply: rejects any future design that wants to introduce a dedicated kind purely to label the discard reason.
Tier 2 (side-by-side rendered) is the default diff view; Tier 1 (unified) is a toggle. Two iframes: left iframe loads /content/{source_path} (current Source); right iframe loads a new /content/preview/proposals/{id} route that renders the proposed-Source bytes through the existing internal/render pipeline at the same source_path namespace. Tier 1 served by GET /api/proposals/{id}/diff returning unified-diff text computed with Go's in-process diff library (no git diff --no-index shell-out). Why: users approve content, not patches; the rendered view shows the actual user-facing change including rich content (mermaid, SVG, images) for free. Unified diff is a precision fallback. Relative-link resolution is identical on both iframes because both render under the same source_path namespace.
Post-job invariant check replaces a context-size cap. After wb-agent insert-proposal lands the row but before the harness reports status='succeeded', the runner verifies (1) the new incorporation_proposals row is linked to the job via agent_job_id, (2) every Topic returned by list-open-topics at job start has at least one data-orcha-anchor="<that-id>" substring in the proposed Source, (3) the incorporated Topic's marker is absent, and (4) the accompanying agent-proposal message body is non-empty. Failure flips the job to failed with a descriptive error_tail; the proposal row stays (the log is append-only) but the UI does not surface it as fresh. No arbitrary cap on the number of open Topics whose context the skill loads. Why: caps reject work that might fit; an invariant catches the actual failure mode (silent anchor drop) regardless of prompt size. Substring matching is sufficient because the marker syntax is fixed.
Proposal rows record their producing job. #4 adds migration 004: incorporation_proposals.agent_job_id TEXT REFERENCES agent_jobs(id), nullable for legacy/manual rows and populated by wb-agent insert-proposal --job-id=… for Agent-authored rows. Why: the old timestamp inference (created_at >= job.started_at) was good enough to catch "agent exited 0 but wrote nothing," but it is not a durable audit relationship. The producing job is already the natural scope for the insert, and persisting it makes proposal history and failed-post-invariant rows unambiguous. How to apply: proposal list joins agent_jobs through agent_job_id; Agent proposals whose linked job failed remain in history but are not approvable.
Stale proposals are a UI state, not an error to hide; stale checks use proposal bytes, not timestamps. When a parallel Incorporation on the same Source lands, all pending proposals whose base_source_sha no longer matches become stale (stale_reasons includes "source_sha"). When a collaborator opens a new Topic mid-job, or the Agent silently drops an older Topic, the same concrete invariant fails: the proposed Source lacks a data-orcha-anchor="<topic-id>" marker for a current open non-global Topic on that Source. Read-side and approve-side checks therefore compare the current open-Topic set to incorporation_proposals.proposed_source; missing markers make the proposal stale (stale_reasons includes "missing_topic_markers", with missing_topic_ids). The Resolve UI shows the diff with a stale banner and disables Approve while keeping Propose-rewrite and Discard enabled. Why: timestamps are a proxy and fail under same-second creation, delayed list-open-topics calls, retries, and Agent omissions. The marker-set check tests the real approval safety condition: committing the proposal must not strand an open Topic. How to apply: approval returns 409 stale_proposal with {stale_reasons, missing_topic_ids} before collab.Incorporate writes the file.
Anchor JSON for other open Topics flips pre-marker → marker inside the Incorporation transaction. The Agent's rewrite stamps data-orcha-anchor="<id>" for every other open non-global Topic on the Source (post-job invariant verifies this). The matching SQLite-side update is required for #2's anchor-resolution invariant: any pre-marker Topic resolves via byte offsets pinned to a source_sha, so once the Source commits past that SHA, the offsets refer to nothing. collab.CompleteIncorporation gains a ReanchorTopicIDs []string input field; inside the existing transaction it runs UPDATE topics SET anchor = '{"kind":"marker"}' WHERE id IN (…) AND discarded_at IS NULL AND commit_sha IS NULL AND json_extract(anchor, '$.kind') = 'pre-marker'. The filters guard against concurrent terminal transitions and make the update idempotent under retry. Why: without this pairing, the first time two Topics share a Source the system silently corrupts anchor resolution for the un-incorporated one. The Agent producing the marker bytes and the harness flipping the locator JSON are two halves of the same operation; binding them inside one transaction is the only correctness-preserving option. How to apply: the approval handler computes the list (open non-global Topics on the Source, excluding the incorporated Topic, at approval time) and passes it through CompleteIncorporationInput.ReanchorTopicIDs; #2's renderer needs no change because it already treats marker-kind anchors as the live locator.
Preview rendering uses temporary marker anchors. GET /content/preview/proposals/{id} renders the proposed Source through the normal internal/render pipeline, but its anchor pass treats every current open non-global Topic whose marker exists in proposed_source as a temporary {"kind":"marker"} anchor, even if SQLite still stores that Topic as pre-marker. The incorporated Topic is excluded, and the preview route omits the live wb-source-sha meta tag. Why: ResolveAnchors only uses stamped data-orcha-anchor elements for marker anchors; passing stale pre-marker offsets against proposed bytes would not render the intended post-commit highlights. The preview must show the post-commit shape without mutating the DB before approval.
wb-agent enforces the source-path trust boundary even though the skill is the only caller. --source-path=<abs> accepts an absolute path; wb-agent computes filepath.Rel(cfg.Root, source_path) to derive the repo-relative path used by the DB, then runs that through collab.ValidateSourcePath. Any path that escapes cfg.Root or fails validation exits non-zero. Why: "the only caller is the skill" is a property of the wire, not of the binary — wb-agent is an installed CLI that an operator could invoke directly with a malicious --config, and removing the existing validation as part of the wire cleanup would silently regress an invariant the scaffold currently upholds. The cost (two lines of validation in one subcommand) is negligible vs. the cost of a path-escape bug.
wb-agent insert-proposal uses BEGIN IMMEDIATE for the cross-process write. Server-side message inserts use BEGIN DEFERRED + MAX(sequence)+1 because the single-write goroutine funnel serialises within the process. wb-agent is a different process and cannot share that funnel; its proposal-insert transaction opens with BEGIN IMMEDIATE so the cross-process write-lock acquires up-front, making the MAX(sequence)+1 selection safe. SQLite's busy_timeout (configured from #1's DSN) handles contention with concurrent server-side writes. Why: without BEGIN IMMEDIATE, two writers in different processes could each compute the same MAX(sequence) before either commits, then the second commit fails on the unique index topic_messages_topic_sequence. A retry loop would also work but is more code than the standard idiom. How to apply: documented in the spec's wb-agent surface table; the same pattern applies to future wb-perspective put-perspective writes in #5.
Subject default for Incorporation uses rune-based truncation, not byte-based. When POST /api/proposals/{id}/incorporate receives an empty or absent subject, the server constructs "Incorporate Topic: <summary>" where <summary> is the Topic's first kind='human' message body with leading Markdown syntax (# , - , * , > ) stripped, whitespace collapsed, and truncated to 60 Unicode runes (Go's utf8.DecodeRuneInString, not byte slicing) with … ellipsis. Why: byte truncation would split multibyte codepoints and produce corrupt commit subjects on any non-ASCII content. Markdown-syntax stripping prevents # Hello from landing as the commit subject's first characters. How to apply: a small helper in the approval handler; tested with multibyte strings, Markdown prefixes, and short bodies.
HTTP surface owned by #4 — seven endpoints, all behind auth.RequireCollaborator from #7:
POST /api/topics/{id}/proposals — empty body; enqueues a wb-incorporate job. Returns {job_id}. Per-Topic idempotent: if the latest agent_jobs row for this Topic has status IN ('queued','running'), returns the existing job_id with 200 instead of creating a new row. Any other state (no prior job, succeeded, failed, timed_out) enqueues a fresh job — the propose-rewrite button on proposal-fresh and the retry button on job-failed rely on this. Different Topics on the same Source are allowed to queue (per-Source agent queue serializes them). 422 if Topic is terminal.GET /api/topics/{id}/proposals — list of proposals with fresh, stale_reasons, missing_topic_ids, and linked job status computed at read time.GET /api/proposals/{id}/diff — Tier 1 unified diff response.GET /content/preview/proposals/{id} — Tier 2 right-iframe source; HTML through internal/render.POST /api/proposals/{id}/incorporate — {subject?, body?}; calls existing collab.Incorporate. When subject is absent or empty, the server defaults it to "Incorporate Topic: <summary>", where <summary> is the Topic's first kind='human' message body with leading Markdown syntax stripped, whitespace collapsed, and truncated to 60 Unicode runes with … if truncated. 409 stale_proposal if the Source SHA drifted or current open Topic markers are missing from the proposal bytes.POST /api/topics/{id}/discard — {reason?}; pure DB transition.GET /api/agent/jobs/{id} — existing #3 endpoint, reused for the generating-spinner.topics.commit_sha setter); #5 decides whether refresh is eager or lazy.wb-agent get-persona / put-perspective should follow the same --job-id-only rule as the three #4 subcommands. #5 implements them; the principle is settled here.'human' message primitive #4 froze), the Tier-1/Tier-2 toggle widget, the stale banner styling, the proposal history list within a Topic, the explanation display block above the diff.agent_jobs and the proposals list while a job is in flight.wb-agent flag surface to match the actual single-consumer call shape. Originally an over-specified scaffold for replayability; resolved by #4's two Decisions above (slim prompt + --job-id-only). The cleanup lands as part of #4's implementation plan, not as a separate #3 follow-up.Brainstorm complete. Full design at 2026-05-13-realtime-collab-design.html.
Transport = Server-Sent Events behind a process-local pub/sub hub. New internal/realtime package keyed by source_path. One SSE endpoint (GET /api/stream?source_path=…) + one focus POST (POST /api/stream/focus). Mutation handlers and the agent runner call Hub.Publish after their SQLite write commits. Why: SSE is one HTTP request through net/http + http.Flusher, EventSource has built-in reconnect, and for two collaborators on a Pi the load is trivial. Nothing in v1 needs duplex; WebSocket would add Upgrade wiring and a real heartbeat strategy for no benefit. Polling misses the ~1 s liveness target. How to apply: swapping to WebSocket later, if push-from-client becomes load-bearing, is a localised change inside internal/realtime.
Stream scope = per-Document only. The client opens an EventSource on Document load and closes it on navigation. Activity on other Documents surfaces only when the user visits them. No global / session-wide stream in v1. Why: matches the iframe-per-Document UX; keeps the hub map keyed by source_path with no cross-source fanout. How to apply: cross-document notifications ("Max replied on /doc/foo while you're on /doc/bar") are parking-lot for a future sub-project; the v1 endpoint shape does not block adding a second GET /api/stream/global later.
REST is authoritative; events advise. Event payloads carry IDs + short previews (≤160 chars). Clients refetch canonical state through existing GETs. Reconnect = "refetch the three relevant endpoints for the focused Document." No event replay, no Last-Event-ID, no per-subscriber ring buffer. Frames carry event: and data: lines only; never id:. Why: the cheapest correct contract — recovery is the same code path as initial load, so any client bug that mishandles an event also self-heals on next reconnect. How to apply: reject any future design that wants to put load-bearing fields in event payloads instead of refetching.
Publish-after-commit, with pinned ordering for Agent incorporate jobs. Every publisher calls Hub.Publish only after its SQLite write commits — in-process handlers from inside the write-funnel callback; the agent runner only after collab.CompleteJob succeeds. For incorporate jobs the runner emits events in this exact order: (1) topic.message_appended for the agent-proposal row → (2) proposal.created → (3) job.updated with the terminal status. Failed / timed-out jobs emit only job.updated. Why: job.updated{succeeded} arriving before proposal.created would briefly transition the proposer's UI to "no proposal" before bouncing back to "review proposal." Pinning the order eliminates the flicker without inventing a synthetic "generating-finished" state. How to apply: test matrix carries an explicit ordering test (success path + post-invariant-failure path).
Publisher receives its own events. The hub does not filter the originator out of fanout. Clients dedupe by record ID. Why: removes "optimistic update vs event-driven render" divergence as a bug class — the publisher's UI converges through the same code path as everyone else's. How to apply: every client-side event handler must be idempotent against records it already has.
Event surface (v1). subscribed, topic.created, topic.message_appended, topic.discarded (#4), topic.incorporated (#4), proposal.created (#4), job.updated (#3), presence.updated. Reserved (no v1 emit): perspective.refreshed (#5). Why: these are exactly the data-layer transitions visible to the other collaborator; the surface is closed (no per-keystroke / typing / cursor events). How to apply: new event types follow the same {type, payload} JSON shape; payloads stay small. Two specific payload requirements set during code review — topic.message_appended carries a nullable proposal_id (non-null only when kind='agent-proposal') so consumers can correlate the explanation row with the proposal row in one event; topic.incorporated carries source_path even though the stream is source-scoped, so chrome can dispatch the iframe reload + sibling-Resolve-drawer cleanup without inferring from the stream.
No proposal.staled event. Stale-proposal status is derived on read (#4's existing check). The events that can stale a proposal (topic.created, topic.incorporated) already trigger a /api/topics/{id}/proposals refetch where the existing check resurfaces the banner. Why: dedicated event duplicates state without adding information. How to apply: the name is reserved in case a future payload requires it; do not emit in v1.
Presence = document-level with focused Topic, one entry per subscription. Hub tracks {subscriber_id, user_id, display_name, focused_topic_id?} per subscription. presence.updated carries the full snapshot. No collapse by user_id — two tabs from the same user produce two entries, each carrying its own focused_topic_id. Why: the earlier "most-recently-focused tab wins" collapse policy has a buggy normal-case (Tab B subscribes last with empty focus, overwrites Tab A's active focus on Topic X). One entry per subscription also matches the focus-POST design where each tab focuses its subscription independently. Clients decide whether to dedup visually by user_id (one chip per person) or render per-tab. How to apply: the chip-rendering choice is #8's; the event surface supports both without server-side state.
Focus is the only client→server real-time call. POST /api/stream/focus {subscriber_id, topic_id?}, CSRF-required, rate-limited at 1/sec keyed by (session_id, subscriber_id) so two tabs in one session get independent quotas. Focus on a terminal Topic silently coerces to topic_id = "" (200 path, no 409) — under normal contention the topic.discarded/topic.incorporated event is in flight and the chrome repaints anyway; forcing chrome to swallow 409s on every focus call would add a hand-rolled path with no UX win. Why: everything else stays REST; adding a duplex pipe (typing indicators, cursor positions) would change the transport. Drawing the line at focus keeps SSE viable for v1.
Hub interface splits Subscribe/Activate; subscribed is the first frame. Hub.Subscribe(source_path, principal) allocates the Subscriber but does not register it for fanout. The SSE handler writes the subscribed handshake frame, flushes, then calls Hub.Activate(subscriber), which adds the subscriber to the source's fanout set and triggers the presence recompute. Why: guarantees subscribed is the first frame on the wire; concurrent publishes cannot race in ahead of it. How to apply: Subscribe error vocabulary is ErrHubClosed only (no per-source cap in v1; auth allowlist is the de-facto cap).
Backpressure = close-and-reconnect; per-subscriber buffer = 64. Publish is non-blocking: full channel ⇒ async close ⇒ SSE handler returns ⇒ client reconnects ⇒ refetches via REST. Why: never block publishers on slow consumers; never lose authoritative state (REST has it). 64 not 16: a single Agent incorporation produces 3 events plus prior running transitions; combined with presence churn and concurrent collaborator activity, 16 is borderline. 64 events × ~256 bytes ≈ 16 KiB per subscriber stays trivial. How to apply: reconnect+refetch is the expected overflow recovery path, not a pathological one — every new client must handle it as first-class.
Per-tick session keepalive touches last_seen_at. The 15 s SSE keepalive ticker reads the session row; if revoked_at is set or expires_at has passed, closes the subscriber (single termination path with context-cancel / channel-close / write-error). Otherwise, if now − last_seen_at ≥ auth.SessionTouchInterval (10 min), it calls store.TouchSession to extend the sliding expiry. Why: without this, a passive SSE-only observer never goes through SessionMiddleware's touch path, and the existing #7 sliding-session semantics would time them out under their feet. How to apply: session-revalidation failure routes through the same Subscriber.Close() path as any other handler termination.
Active-job bootstrap on Document load. Chrome fetches GET /api/agent/jobs?source_path=… once when opening the EventSource. The hub does not replay job state on subscribe; bootstrap is the client's job. Why: the spinner-vs-idle UI must be correct on page load even if no job.updated event has been seen yet. How to apply: in the same pass that replaces the legacy GET /api/agent/jobs/{id} polling loop with job.updated consumption, add the one-shot list call on stream open.
Operational config = proxy_buffering off on Nginx Proxy Manager. Handler additionally emits X-Accel-Buffering: no as a belt-and-braces hint, and :keepalive comment every 15 s (NPM default proxy_read_timeout is 60 s). Why: without buffering off, SSE frames pile up at the proxy until the response closes. How to apply: deployment runbook update; no Go-side change needed beyond the header.
Auth-expiry detection in the client. On EventSource.onerror, issue GET /auth/me. Anonymous / 401 ⇒ location.reload() to route through /auth/login. Authenticated ⇒ treat as transient and keep reconnecting. Initial-connect 404 unknown_source is terminal (EventSource does not retry on 4xx); chrome surfaces a "document no longer exists" state and stops. Why: EventSource hides response codes, so the /auth/me probe is the only sane chrome-side classification mechanism.
Replaces the legacy GET /api/agent/jobs/{id} polling loop. The endpoint stays for one-shot lookups (e.g. fetching error_tail on a failed event); only the poll loop goes. Why: job.updated covers every state transition the poll used to detect.
perspective.refreshed — payload {source_path, perspective_id, source_sha, persona_sha}. #5 decides the trigger (lazy-on-read vs eager-on-Incorporation) and emits this event when a regenerated Perspective is ready to swap in. The transport (internal/realtime) is settled here.wb-perspective job lifecycle is already covered by job.updated; #5 inherits the wire without adding a new event for queued/running/succeeded/failed transitions.wb-perspective ends up producing a sibling row that the runner publishes (analogous to incorporate's topic.message_appended → proposal.created → job.updated). #5 settles the exact order when its data model lands.perspective.refreshed closes it.Wire-level contract is fixed inside the #6 spec's Client wiring subsection (EventSource lifecycle, focus POST handshake, active-job bootstrap, reconnect / /auth/me probe, 404 terminal handling). The items below are the genuinely-open UI decisions #8 owns:
user_id (one chip per person) or surfaces per-tab entries, where it lives in chrome (toolbar / sidebar header / topic-card metadata), and the mobile breakpoint.presence.updated whose focused_topic_id matches the Topic. Styling, placement, and whether to show count or just a dot are #8's.topic.created from another user? proposal.created on a Topic you authored?) vs. silent badge update vs. live append. Event surface is fixed; UX policy is open.topic.message_appended rows or surface a "N new messages" pill that protects scroll position. #6's payload supports both.GET /api/agent/jobs/{id} polling loop in favour of job.updated consumption; one-shot endpoint stays for error_tail fetch on failed/timed_out.404 unknown_source (source file deleted out from under live Topics, per #1's external-rename out-of-scope decision), render a friendly "this document no longer exists" state and stop reconnecting.Brainstorm complete. Full design at 2026-05-15-wiki-browser-ui-integration-design.html.
Perspectives UI is deferred to a future sub-project, not folded into #8. The Perspective switcher, persona editor, regenerating indicator, and perspective.refreshed SSE consumer all ship with the future Perspectives sub-project (successor to the original #5), alongside its data model. #8 covers every other parked UI item. Why: Perspectives need a coherent UI + data shape design pass; landing the UI shell now would force decisions about persona editing surface and switcher placement before the data model is settled.
Resolve view = iframe-slot swap, not a new route or modal. When the user clicks "Review changes" on an agent-proposal message, the central iframe slot transforms in place (toolbar + diff area replace the iframe); the right Topic sidebar continues to show the thread. The document URL (/doc/{path}) does not change. Why: keeps the thread visible alongside the diff (the explanation + discussion is half the review surface); matches the existing chrome's "iframe = current view of this doc" mental model; one less route to wire and no modal focus-trap headaches over iframes.
Sidebars become collapsible with a three-state model: expanded ↔ rail ↔ overlay. Left sidebar is user-toggleable and persisted in localStorage["wb.sidebar.left"]. Right sidebar has no manual toggle; it auto-collapses only when Resolve-mode + viewport width require it, then auto-restores. Rail (16px) shows a chevron; clicking it opens the sidebar as a transient overlay (no backdrop on desktop, not persisted). Why: users keep the right sidebar (the thread) by default but the system can claim its space for Tier-2 side-by-side on narrow displays. Manual right-sidebar toggling adds a control with no user benefit since the right side is content-driven.
Anchors get two redundant indicators: background tint + margin glyph. Resting tint is subtle (~12% of --accent-light + dashed accent underline); hover ~30%; selected = full --accent-light. Margin glyph is a speech-bubble icon in a fixed-width gutter inside the iframe, vertically aligned with the anchor's top, recomputed on resize/font-load/image-load via ResizeObserver. Why: glyph is discoverable (Google-Docs-style margin marker); tint preserves the click target on the text. Mobile drops the glyph; tint remains.
Anchor highlight is persistent, not time-decayed. Clicking a Topic card or an anchor applies a persistent selected highlight (--accent-light background); it clears only when another Topic is focused, the user clicks empty iframe space, or Escape is pressed with the sidebar focused. Why: the previous 2-second decay made the selected state ambiguous mid-discussion; persistence makes "this is the Topic you're looking at" unambiguous.
Bidirectional anchor ↔ Topic navigation via postMessage. Clicking an anchor (text or glyph) in the iframe fires topic:focus to chrome; clicking a Topic card in the sidebar fires a new anchor:scroll {topic_id} message from chrome to iframe. Overlapping anchors (data-topic-ids with N≥2) pop a tiny menu listing the Topics. Why: mirrors Google Docs comments; reuses the existing postMessage bridge from #2's renderer.
Topic-level CTAs (Rewrite + Discard) live in the thread header; Approve lives in the diff toolbar. Both Rewrite and Discard prompt confirmation. Discard collects an optional reason that becomes the final kind='human' message body. Approve opens an inline editor with prefilled subject + optional body. Why: Discard is a Topic-level action valid before any proposal exists, so it belongs on the Topic header. Approve is proposal-level and belongs with the diff. All three actions confirm because two are destructive (Discard) or expensive (Rewrite burns model budget); Approve confirms because the commit subject lands in git history.
Compose-and-propose UX = a checkbox on the reply form. Submitting the reply with "Ask Agent to rewrite after sending" fires the message POST then the proposal POST in sequence. No new atomicity needed per #4's decision (the message just becomes thread context the Agent reads). Why: the cleanest UI for the parking-lot item — discoverable, doesn't add a separate button, composes with normal replies.
Agent-proposal messages render with three visual states: pending review, superseded, stale. Latest fresh proposal → green pill + primary "Review changes" button → approvable diff. Superseded (newer revision exists) or stale (base_source_sha drift OR missing topic markers) → muted pill + muted "Review changes" button → read-only diff with no Approve. Stale banner copy differentiates source-changed vs new-Topic-opened. Why: the user's mental model is "stale = automatically dead"; merging superseded and stale under one read-only treatment is correct, but a per-reason banner preserves the why for the human reading it.
When a proposal is stale, the Approve button is hidden entirely. Not disabled — gone from the toolbar. Discard and Rewrite remain on the thread header. Why: a disabled button is an attractive nuisance; hiding makes "the only forward path is Rewrite or Discard" unambiguous.
Tier 2 (side-by-side) is the default diff view; preference is sticky. localStorage["wb.diff.tier"] carries "unified" or "side-by-side". The Tier-1 unified diff is fetched lazily on first tier switch and cached for the session. Why: per #4, Tier 2 is the natural review view (humans approve content, not patches); Tier 1 is a precision fallback. Stickying the user's preference avoids re-choosing on every Resolve.
Mobile is read-only + diff-eval. ≤768px: chrome adds a right-side hamburger that opens the Topic sidebar as an overlay; margin glyphs drop (tint stays). ≤700px in Resolve mode: Tier 2 collapses to a tab strip (one iframe at a time); no Approve button; no subject/body editor. Rewrite + Discard remain visible on the thread because they're cheap and reversible-by-undo. Why: the operator and the two allowlisted collaborators are desktop-first; mobile is "check on the go," not a full work surface. Cutting Approve from mobile avoids accidental commits from a phone.
Presence chips live in the topbar, dedup by user_id. One circular initial-badge per remote user; hover tooltip = full name + tab count. The current user does not render a self-chip. Why: presence is document-scoped, the topbar is doc-level chrome; dedup by user_id keeps the chip row short while preserving the multi-tab info in the tooltip.
Per-Topic reader indicator on each Topic card. Up to 3 stacked initials in the top-right corner of the card; overflow shows +N. Driven directly from presence.updated payloads. Why: the sidebar is where the operator looks for "is Max with me in this thread"; per-card indicators answer that without adding a separate panel.
Toasts fire only for substantive cross-user events. topic.created, topic.incorporated, topic.discarded from another user → toast. Own job.updated failures → toast. Everything else is silent (silent thread updates, silent badge changes, silent presence). Why: the bar for "interrupt the user's reading" is high; substantive cross-user actions clear it, micro-events do not. Avoids notification fatigue.
Thread auto-appends when at bottom; "N new messages ↓" pill when scrolled up. Threshold = scroll-from-bottom < 40px. Why: standard chat pattern; protects scroll position when the user is reading older messages.
Dead-doc state replaces the iframe area when SSE returns 404 unknown_source. Friendly card + link back to the index; right sidebar hides; presence chips clear; no reconnect attempts. Why: matches #6's terminal handling for the rare external-rename / external-delete case.
Drop the legacy GET /api/agent/jobs/{id} polling loop entirely. Job state is driven by job.updated events + the bootstrap GET /api/agent/jobs?source_path=… fetch when SSE opens. The endpoint stays for the one-shot error_tail lookup on failed/timed_out events. Why: #6 covers every transition the poll detected; polling is now pure overhead.
No new server-side endpoints. #8 is pure consumption of the surface specified by #2 / #4 / #6 / #7. One new client→iframe postMessage kind: anchor:scroll {topic_id}. Why: the data and event layers are complete; #8's job is wiring, not extending.
No collaborative-DB schema change; no migration. Why: every UI element binds to existing columns, payloads, or derivations. The "discarded topics archive view" is explicitly out of scope but topics.discarded_at + the final kind='human' message persist for the future polish project.
Batched incorporation is split off as sub-project #9, not folded into #8. Today's "one Topic → one Proposal → one Commit" assumption lives in #4's schema (composite FK from topics to incorporation_proposals, CHECKs on agent_jobs), the post-job invariants, the stale-check, the wb-agent surface, and the wb-incorporate skill prompt. Generalising to N Topics is real but contained work; bundling it into #8 would double the cycle for no architectural benefit. Why: each migration deserves its own design pass; #8 stays a pure-UI effort with no schema risk. How to apply: reject any in-flight expansion of #8 that touches schema, endpoints, or the skill prompt to support batches; route it to the #9 spec.
Two forward-compatibility touches in #8 for #9. (1) Topic card markup carries a hidden wb-topic-card__select slot in the top-left corner; #9 drops a checkbox into it and unhides. (2) The Resolve toolbar shows a 1 Topic label between Close and the tier toggle; with N=1 today it's informational, and #9 turns it into the click target that expands the batch list. Why: these are the only two visible shifts when N becomes >1, so reserving the space now keeps #9's UI delta to wiring + content, not re-layout.
perspective.refreshed event consumption — moved to the future Perspectives sub-project (successor to #5)./api/stream/global.'human' messages per #4; richer affordance deferred.a/d, sidebar toggles, Topic nav) — deferred to a polish pass.incorporation_proposals.topic_id to a many-to-many proposal_topics(proposal_id, topic_id) join (or keep + add join for batches). Drop the composite FK topics.(incorporated_proposal_id, id) → incorporation_proposals(id, topic_id); replace with a CHECK on the join. agent_jobs.topic_id gets the same treatment via job_topics or a JSON array. SQLite twelve-step rebuild via the existing migrate:no-tx directive.POST /api/proposals {topic_ids: []} replaces POST /api/topics/{id}/proposals. Idempotency: reject if any in-flight job covers any requested Topic.wb-agent surface. Replace get-topic with get-job returning {source_path, base_source_sha, topics: [...]}. list-open-topics --exclude-topics=<comma-list> (plural).wb-incorporate/SKILL.md rewrite. Prompt generalizes to "incorporate these N Topics, drop their markers, stamp markers for every other open non-global Topic." Post-job invariant verifies N markers absent + others present.stale_reasons and missing_topic_ids payloads already accept multi-Topic; the server check generalizes to "marker missing for any current open non-batch Topic."wb-topic-card__select slot (reserved by #8) to a checkbox; Rewrite confirmation copy switches to "Rewrite N Topics together" when ≥2 selected. The Resolve toolbar 1 Topic label becomes the batch-list expander.WHERE id NOT IN (batch_ids). Subject default: "Incorporate N Topics: …" with the first Topic's summary or a concatenation.topic.incorporated emits N times (client dedupes by ID); proposal.created and job.updated each emit once for the batch.The #9 brainstorm started and was postponed early. Captured so it doesn't have to be re-derived:
Motivation (from Max). Generating a proposal for a single Topic takes ~1 min of Agent latency. The common real workflow is: a Source has ~6 open Topics, ~5 are settled and need no further discussion, 1 still needs discussion. Max wants to grab the 5 settled ones and incorporate them in one Agent pass + one commit, leaving the 1 open. Batching is latency amortization (one Agent run instead of N sequential) and review/commit economy (one diff, one commit), not a correctness feature.
Leaning: model the batch as a first-class entity; today's single-Topic incorporation becomes "a batch of one." revision_number, the cross-process in-flight gate (the agent_jobs.topic_id partial unique index), the composite FK, and the rework loop all currently hang off the singular incorporation_proposals.topic_id. The lighter parking-lot option (bolt a proposal_topics join onto the existing proposal row, no parent entity) relocates the topic link but leaves revision_number and the in-flight gate with no owning scope — they'd end up keyed off "the sorted topic-ID set," which is an unnamed batch entity that is worse for integrity than a real one. Why a first-class batch wins: N=1 collapses into "batch of one," so #9 generalizes the single existing code path and deletes the special case rather than adding a parallel batch path — fewer code paths after the change, not more. Cost (accepted, eyes open): heavier migration — new batch + batch_topics tables, re-key incorporation_proposals and incorporation_attempts from topic_id to batch_id, a deterministic backfill of every existing proposal into a singleton batch, and replace the composite FK topics.(incorporated_proposal_id, id) → incorporation_proposals(id, topic_id) with a trigger / app-level membership check (SQLite CHECK cannot subquery a join). User confirmed the batch direction "seems correct"; the lighter join-only option is rejected unless migration cost forces a revisit.
Rework stance (proposed, user deferred — not yet ratified). A batch's Topic set is fixed at launch. Rework re-runs the Agent for the same fixed set — semantically identical to today's single-Topic rework, just with more messages accumulated across the N Topic threads. Changing the set = abandon the batch and re-select. Implies a real "abandon batch" lifecycle, otherwise an unwanted in-flight batch permanently locks its Topics out of any new batch. Abandon semantics are an open question for the resumed brainstorm.
Risk to keep in view: "no discussion needed" ≠ small Agent job. Human consensus being settled does not shrink the Agent's task. A 5-Topic batch is one rewrite that must fold in 5 separate discussion outcomes and re-anchor the still-open Topic in one pass — higher variance and more rework-prone than 5 isolated single-Topic runs, not less. The batch rework/recovery path must be solid; do not design it as a rare edge case.
#1 invariant amendment to call out explicitly (not silently). Sub-project #1's "1 Incorporation = 1 commit" with the rationale "git history mirrors the conversation that produced each change" softens under batching to "1 commit mirrors one incorporation event covering N conversations" — N Topic-ID commit trailers instead of one. Still auditable via git log --grep. The #9 spec must record this as an explicit amendment to the #1 decision, not assume it.
Open sub-questions surfaced but not yet decided (each needs a decision when the brainstorm resumes):
agent_jobs_one_inflight_incorporate_topic) cannot express "no Topic belongs to two active batches." Likely a per-batch unique in-flight index plus an app-level "no requested Topic is in another active batch" guard; exact shape TBD.collab.Recover) and the migrate:no-tx twelve-step rebuild.agent-proposal explanation message lives. One explanation per proposal, but N Topic threads. Replicated into each of the N threads vs. a batch-level location. Note the #6 realtime decision already fixes topic.incorporated emitting N times while proposal.created / job.updated emit once — the message-placement choice must stay consistent with that.perspective_defs schema.perspective.refreshed SSE consumption — reserved event from #6; swaps iframe content when a refreshed Perspective lands./api/stream/global (event surface settled in #6).'human'-message primitive).