Status: Phase 0 contract shipped; Phase 1 capture/list MVP shipped in #3135. Scope: packages/core (contract) + packages/storage (store and migration).
Tracking: Work Board delivery #2560
The Work Board is a user-owned, local-first surface for deferred work. It is not an execution authority:
task_* tools, no task.ledger.query, and no workflow_task_ledger_* reads/writes;interface WorkBoardItem { schemaVersion: 1; id: string; // durable UUID, never rewritten revision: number; // monotonic per item, incremented on every effective mutation scope: WorkBoardScope; // inbox | { kind: 'project'; projectId } title: string; notes?: string; state: 'todo' | 'in_progress' | 'done'; creator: { kind: 'user' } | { kind: 'agent_suggestion'; confirmedAt: number }; provenance: WorkBoardProvenance; createdAt: number; updatedAt: number; // Active items never carry `archivedAt`; archived items always carry it: // { archived: false } | { archived: true; archivedAt: number } archived: boolean; archivedAt?: number; }
linkedSessions is added by the Phase 3 start-task spike as an additive record field. Existing records without the field decode as an empty list. Each entry is Host-scoped (profileId, hostId, sessionId, linkedAt) and is written only by the semantic WorkBoardStore.linkSession mutation after the normal Composer first-send has produced a durable Session/Turn outcome. Session state is never copied into the item.
Inbox is a scope, not a status. State transitions are user-confirmed only:
todo <-> in_progress todo -> done in_progress -> done done -> todo | in_progress
done is user intent. It is never derived from a Session or AgentRun outcome.
Provenance is a discriminated union:
manual;main_conversation (sessionId, messageId, optional runId / turnId, capturedAt, bounded excerpt; parentSessionId is rejected);side_conversation (same fields plus required parentSessionId).The bounded excerpt is snapshotted at capture time so a side-chat item survives the temporary fork's deletion. Typed refs are best-effort links, not hard dependencies.
Agent suggestions require confirmedAt and are only created by explicit user action or an unambiguous instruction. The board never writes itself.
WorkBoardStore in packages/storage owns the workflow_work_board_items table in runtime.sqlite (operational-state database), added by the additive workflow schema 8 → 9 migration:
CREATE TABLE IF NOT EXISTS workflow_work_board_items ( item_id TEXT PRIMARY KEY, revision INTEGER NOT NULL CHECK (revision >= 1), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, scope_kind TEXT NOT NULL CHECK (scope_kind IN ('inbox', 'project')), project_id TEXT, archived INTEGER NOT NULL CHECK (archived IN (0, 1)), record_json TEXT NOT NULL, CHECK ( (scope_kind = 'inbox' AND project_id IS NULL) OR (scope_kind = 'project' AND project_id IS NOT NULL) ) );
Writes:
title / notes / scope / state), never full-record replacement. In this patch contract, undefined (or an omitted field) means “not provided” and keeps the stored value; notes: null, an empty string, or a whitespace-only string is the explicit clear signal. This is a contract decision, not a JavaScript object-equality rule;revision;expectedRevision provides optimistic concurrency (CAS);updatedAt, so keyset ordering stays monotonic even if the wall clock moves backwards;record_json.scope as corrupt_record.There is no total item cap. List/query size is bounded by pagination (default 50, max 100) with an opaque keyset cursor. Cursors are bound to the normalized scope / includeArchived filters; reusing a cursor with different filters is rejected as invalid input.
The default archived = 0 list queries are covered by partial indexes on active rows only:
CREATE INDEX workflow_work_board_items_active_scope_order ON workflow_work_board_items(scope_kind, project_id, updated_at DESC, item_id DESC) WHERE archived = 0; CREATE INDEX workflow_work_board_items_active_order ON workflow_work_board_items(updated_at DESC, item_id DESC) WHERE archived = 0;
This keeps an archive-heavy database from walking most of the ordering index to produce one page of active items.
Deferred. The Phase 3 spike stores links only; it does not ship an execution projection. A future projection must be implemented beside the real continuity adapter using only canonical SessionContinuitySnapshot / TurnSnapshot facts.
The default-off Start task spike (#4598) establishes a deliberately small link contract while it stays experimental:
WORK_BOARD_MAX_LINKED_SESSIONS = 1): a project-scoped item owns at most one started Session at a time. Linking a freshly started Session replaces any previous link, so linkedSessions stays bounded instead of growing on repeated starts. The stored-item decoder tolerates legacy arrays by preserving valid distinct entries while dropping malformed or duplicate ones; the strict mutation normalizer rejects arrays with more than one entry.workBoard:linkSession is the single main-process mutation boundary. It resolves the item, requires a project scope, passes the canonical board projectId into the Host validator, and requires the Session's workspace target to be a project matching that id. The mutation then commits with expectedRevision CAS on the revision read before the (asynchronous) Host validation, so a concurrent scope/revision change cannot write a Session validated for one project into an item that has moved to another.linkSession fails, the claim (with its Session id) is retained in memory so retrying Start task on the same item and unchanged target reuses that Session. If the item moved to a different target, retry discards the old claim and starts a new Session against the current target. A renderer reload or app restart before the retry drops the claim; restarting the item then creates a second Session and leaves the first unlinked. This is an accepted, documented limitation of the spike (persisting a pending-link intent in the main process/storage is tracked for a later phase).