# Working documents ## Purpose A working document is an operator-owned, editable Markdown document created from one Stream observation. It proves one bounded workflow: `open an event → create a working document linked to it → select exact supporting context → request a proposal from a runner → inspect the diff → accept or reject → retain the version and the judgment` It is not a general editor, a task system, a collaboration surface, or a new agent runtime. Those remain out of scope and are listed under [Not implemented](#not-implemented). ## Kind Observed filesystem versions (`filesystem-charter.md`) record what a connector saw on disk. A working document is a separate kind: it is written only through the trusted workbench path and never touches a file on disk. Editing a working document created from a file observation does not modify the observed file. Working documents reuse the existing `documentVersions` (immutable content) and `documents` (mutable current head) tables rather than adding tables: | Field | Value | | --- | --- | | `source` | `workbench:documents` | | `contentType` | `text/markdown` | | `path` | `documents/.md` (a label, not a filesystem path) | | version `content` | `---\ntitle: \n---\n` | The title travels in frontmatter so every version is self-describing without the head projection. Body is at most 64,000 characters; title at most 200. ## Identity - `documentId = stableKey("working-document", originEventId, createRequestId)`. The origin event is the observation the operator started from. The create request id is a client-generated idempotency key, so a retried create converges on the same document. - `versionId = stableKey("version", "workbench:documents", documentId, sha256(content))`, exactly as filesystem versions derive theirs, so `appendDocumentVersion` idempotency holds and identical content is one version. - The `documents` row id is `stableKey("document", "workbench:documents", documentId)`. Its `versionId` is the current head. The row is a projection; the version events are the historical authority. - Context snapshots are immutable JSON document versions under `source: "context:workbench"` with `snapshotVersionId = stableKey("workbench-context-snapshot", documentId, sha256(canonical { baseVersionId, ids: sorted selected ids }))`. - Runner runs are ordinary `AgentRun` rows with `id = stableKey("workbench-run", documentId, requestId)`. ## Events All workbench events are append-only under `source: "workbench:documents"`, `sourceKind: "system"`, `privacy: "sensitive"`, `schemaVersion: 1`, actor = the configured proposal actor (`operator:local` by default), with zod-validated payloads in `src/workbench/contracts.ts` and registration in the default event registry. | Type | Lineage | Payload | | --- | --- | --- | | `stream.thought.workbench.document.created` | root = origin root, parent = origin event | `documentId`, `title`, `originEventId`, `versionId`, `sha256`, `requestId` | | `stream.thought.workbench.document.version` | parent = created event (operator edit) or proposal event (accepted proposal) | `documentId`, `versionId`, `baseVersionId \| null`, `sha256`, `sizeBytes`, `reason: created \| operator-edit \| proposal-accepted`, `proposalEventId?`, `decisionEventId?`, `requestId` | | `stream.thought.workbench.context.selected` | parent = created event | `documentId`, `selectionId`, `snapshotVersionId`, `selectedEventIds`, `selectedVersionIds`, `requestId` | | `stream.thought.workbench.proposal.requested` | parent = selected event | `documentId`, `baseVersionId`, `baseSha256`, `selectionId`, `snapshotVersionId`, `runnerId`, `requestId` | | `stream.thought.workbench.proposal.proposed` | parent = requested event | `proposalState: "runner-proposed"`, `proposer { runnerId, runnerRevision, requestId, runId?, contextSnapshotId }`, `target { documentId, baseVersionId, baseSha256 }`, `operation: "replace-document"`, `proposedText`, `proposedTextChars`, `proposedTextSha256`, `reason`, `evidenceEventIds` (at most 16, all admitted by the selection), `publicationEligible: false` | | `stream.thought.workbench.proposal.decision` | parent = proposal event | `proposalEventId`, `disposition: accept \| reject`, `submissionId`, `authority: "human"`, `resultVersionId?` (accept only) | Every event is findable from the origin event's lineage because all of them carry the origin's `rootEventId`. Idempotency keys are deterministic per request: created `(documentId)`, version `(documentId, reason, requestId)`, selected `(selectionId)`, requested and proposed `(documentId, requestId)`, decision `(proposalEventId)`. One decision identity per proposal is the concurrency boundary, exactly like agent-proposal decisions: the same submission id with identical content replays, a different submission on a decided proposal fails with "already has a human decision", and a reused submission id with different content fails with "submission id conflicts". ## Source links and context snapshots Selecting context records exact evidence, never mutable titles. The snapshot content is canonical JSON: ```json { "documentId": "...", "baseVersionId": "...", "selectedEvents": [{ "eventId": "...", "type": "...", "source": "...", "occurredAt": "...", "payloadHash": "...", "excerpt": "..." }], "selectedVersions": [{ "documentId": "...", "versionId": "...", "sha256": "...", "path": "..." }] } ``` `excerpt` is a bounded plain-text rendering of the payload (at most 2,000 characters), never HTML. Every selected id must exist at selection time. The UI labels this as the evidence the operator selected and states that a runner receives this snapshot plus the current document head and nothing else in this slice; a future model-backed runner that supplies broader context must record that separately rather than widening the selection silently. ## Trusted mutation path `src/workbench/workflow.ts` is the only writer. It runs behind the inspector's Review capability and serializes mutations per document in process. - `createWorkingDocument`: origin event must exist; idempotent on request id; a request id reused with different content is a conflict. - `saveOperatorEdit`: fails closed with `StaleBaseError` (HTTP 409 with `headVersionId`) when the submitted `baseVersionId` is not the head. Identical content to the head is a no-op. A replayed request id settles the head if the earlier attempt appended the version but not the projection, then returns the current head. - `selectContext`: validates every id, writes the snapshot version, then the selected event. - `requestProposal`: resolves the runner from the registry, appends the requested event, records a `running` run row, runs the runner, appends the proposed event, then completes the run with `outputEventIds = [proposalEventId]`. Runner failure records a `failed` run with a bounded classified `errorText` and appends no proposal; the error is returned to the action that requested it. A replayed request id returns the existing proposal or the existing failure. - `decideProposal`: write order is version row, version event, decision event, head upsert, judgment. Accept requires `head.versionId === target.baseVersionId`; otherwise `StaleBaseError` and nothing is appended. Reject appends the decision and judgment and leaves the document unchanged; rejecting a stale proposal is allowed. Replaying the same submission settles any unfinished head upsert or judgment and applies nothing twice. ## Proposal state A proposal is `pending` until decided. The projection computes `stale = head.versionId !== target.baseVersionId` and exposes `headVersionId`; a stale undecided proposal is shown as `stale` with Accept disabled and a hint to request a new proposal. Decided proposals are `accepted` or `rejected`. There is no merge, rebase, or overwrite path: a stale proposal either gets rejected or is superseded by a new request. The diff is computed server side with the `diff` package between the exact base version body and `proposedText` (the identical frontmatter is excluded so the diff shows only body changes) and is rendered in the UI as escaped text lines, never as HTML. ## Judgment lineage Each decision records one judgment through the existing `recordJudgment` with criterion `workbench-document-proposal@1`, kind `accept` or `reject`, `qualityEligible: false`, `externalExportEligible: false`, `feedbackSourceEventId` = the decision event, `source: judgment:workbench-documents`. The judged run is the runner run whose trigger is the requested event and whose only output is the proposal event, with `contextManifest.contextSnapshot` naming the selection snapshot. The judgment therefore links proposal, base version, result version (through the decision payload), context snapshot, and run. A completed run is not training consent: no workbench judgment is quality- or export-eligible, and nothing here exports documents or judgments. ## Runner seam `src/workbench/runners.ts` defines `WorkbenchProposalRunner { id, revision, label, inference, propose(request) }` with `request = { documentId, baseVersionId, baseSha256, title, baseText, snapshot, maxProposedChars }` and `result = { proposedText, reason, evidenceEventIds }`. The trusted workflow validates the result (length bound, hash, evidence ids admitted by the selection) before it becomes an event. The registered runner is `fixture-deterministic@1`: it appends or replaces a `## Sources` section listing each selected event and version and a one-line `Summary:` count, reports `reason: "fixture: append sources section"`, cites the selected event ids, and performs no inference. Identical input yields identical output. Coordination with the separate fx consumer work: an fx-backed runner implements the same interface, maps the harness result frame's `finalText` to `proposedText` and its run id to `proposer.runId`, and reports its own reason. This repository does not implement that adapter and `src/workbench/` imports nothing from `src/agents/harness/`. ## Access Reads: `GET /api/workbench/documents`, `GET /api/workbench/documents/:id`, `GET /api/workbench/candidates?documentId=`. Candidates are bounded: the recent root-observation window plus the document's own versions. Writes: `POST /api/workbench/documents`, `POST /api/workbench/documents/:id/versions`, `POST /api/workbench/documents/:id/selections`, `POST /api/workbench/documents/:id/proposals`, `POST /api/workbench/proposals/:id/decisions`. Each requires the same body-bound Review capability signature as the agent-proposal decision route and returns 405 when no verifier is configured. Bodies are strict zod schemas of at most 98 KB with a client-generated `requestId`/`submissionId`. The authenticated proxy signs these routes only for an allowlisted OAuth session with the session CSRF header; Basic remains read-only. See `web-auth.md`. Jazz credentials stay server side. Browser clients never write Jazz directly. ## UI The `Documents` destination lists working documents and opens a detail view with the editor, sources, proposals, and versions. From an observation, `Create working document` appears only when the session has write access. Drafts persist in browser local storage per document until saved. Every write carries a client-generated id held in page state; after a network failure the UI reports the outcome as unknown, reloads the document before any retry, and reuses the same id rather than resending under a new one. See `ui.md`. ## Not implemented - Real-time or multi-user collaboration, comments, or a permission contract beyond the single owner session. The append-only version chain and per-document serialization leave room for a future collaboration contract without claiming one. - Tasks, assignments, or status fields. - Model-backed runners. Only the deterministic fixture is registered; the fx adapter is coordinated, not implemented. - Document deletion, rename of `documentId`, or import of an observed file version as the initial body beyond the operator typing it. - Training export of any workbench judgment or document.