ticketing · git:20261005.521fc78 · 2026-10-05 · sha256 909d7d61d19116d7

ticketing git:20261005.521fc78A

Immutable. This exact content never changes and is served at /api/v1/blob/909d7d61d19116d7.

---
name: ticketing
description: Local-private task/ticket management for the whiteboard repo. Issues and notes live in the whiteboard itself (via MCP tools) as OKF Markdown documents. The native Task list tracks in-flight session work. Use when triaging findings, tracking work across dev-loop/review/dogfood-triage/reconcile/plan-initiative, or recording/resolving a follow-up.
---

# Ticketing (local-private)

We deliberately do NOT use GitHub Issues. Two layers + a bridge:

## Two layers

| Layer | What | Lifetime |
|---|---|---|
| **Native Task list** (`TaskCreate`/`TaskList`/`TaskUpdate`/`TaskGet`) | the LIVE board: what is running / blocked / done across workflows this session | session/team-scoped |
| **Whiteboard documents** (MCP tools) | the DURABLE private backlog: issues & notes stored as OKF Markdown documents in the `default` workspace | persists on disk via the daemon |

The **main session (integrator / team-lead) owns Task status transitions**. Subagents may update `metadata` but not flip status to completed (mirrors the team workflow).

## Whiteboard as the issue store

Issues and notes are stored as documents in the whiteboard's `default` workspace. Each is an OKF Markdown document with:

- **type**: `issue` or `note`
- **title**: human-readable title
- **facets**: extension metadata. There is no agreed schema for issue metadata
  right now — see "No structured status" below before inventing one
- **body**: the issue description as markdown

### Creating an issue

One call. `kind` is required, and `markdown` on the create op is what saves the
separate `document.set` this flow used to need.

```
wb_workspace_edit → { workspaceId: "default", ops: [
  { op: "document.create", path: "issues/my-issue",
    kind: "markdown", name: "My Issue",
    markdown: "---\ntype: issue\ntitle: My Issue\n---\n\nDescription here." } ] }
```

Several at once — seeding a triage pass, say — are more ops in the same call,
not more calls. `wb_workspace_edit` is the ONLY tool for create / set / delete;
the standalone ones are retired.

### Reading issues

```
wb_document_list → { workspaceId: "default" }                 # every document, open and closed
wb_document_get  → { workspaceId: "default", documentIds: [ … ] }
```

`wb_document_get` is a BATCH read: `documentIds` is an array, and it answers
`{ documents, failed }`. Each entry is `{ documentId, kind, content,
frontmatter? }` — the body is **`content`**, not `markdown`, and `frontmatter`
is present only for a markdown document (a JSON Canvas document has none, per
ADR-0009 decision 3). A document it could not read lands in `failed` with a
reason rather than failing the call, so **check `failed`**: a caller that reads
`documents[0]` alone treats an unreadable document as an empty one.

### Updating an issue

**Amending the BODY — a measurement, a refutation, a status line — is
`wb_body_edit`**, which replaces the passages you quote and leaves the rest
of the document alone:

```
wb_body_edit → { workspaceId: "default", documentId, mode: "apply",
  ops: [{ id: "<yours>", op: "body.replace",
          anchor: { kind: "text", start, end, quote: { exact: "<the passage>" } },
          assumed: "<what that passage said when you wrote this>",
          text: "<what it becomes>" }] }
```

`mode: "apply"` because the backlog is not a surface anyone is watching — the
`propose` default is for a document a person has open. `start`/`end` may be
rough: the QUOTE is what finds the passage, and `assumed` is what refuses the
edit BY NAME if that passage has changed since you read it, rather than
overwriting somebody else's sentence.

Why this rather than the re-import below: an issue accumulates, so the ones
worth amending are the long ones. Re-importing a 20k-character backlog to add
one paragraph re-sends every other paragraph too — expensive, and every
passage retyped on the way is a passage that can come back subtly wrong.
Measured 2026-09-22 on `issues/audit-2026-09-21-backlog`: two `body.replace`
ops, offsets guessed to the nearest thousand characters, both applied.

**Re-import the whole document** (`document.set`) when the change is to the
FRONTMATTER — resolution, tags, sources — since that is what it replaces:

```
wb_workspace_edit → { workspaceId: "default",
  ops: [{ op: "document.set", documentId, markdown: "<updated OKF>" }] }
```

It overwrites facets AND body together, which is exactly the hazard the
Resolving section below is about: read the document first.

### Updating facets only

```
wb_facet_set → { workspaceId: "default", documentId, facets: { "<namespace>.<name>/v0": { … } }  # key grammar per ADR-0013 }
```

### Resolving

**Resolution is `type: issue` -> `type: note`, not deletion.** Re-import the
document with the type changed, the name prefixed `RESOLVED — `, and a line
in the body saying what resolved it.

**Read it first.** `document.set` replaces core facets, extension facets and
body together — it does not merge — so a resolution that names only `type` and
`title` silently drops `tags`, `facets`, and any root key the document arrived
with (`status`, `sources`, `stale_after`, all preserved verbatim since
[ADR-0016](../../../docs/contributing/adr/0016-okf-trust-family.md)). Under the
old convention that cost nothing, because the document was about to be deleted.
Now it is the whole point.

```
wb_document_get → { workspaceId: "default", documentIds: [documentId] }
# take the returned `content`, change ONLY these, keep everything else:
#   type:  issue -> note
#   title: prefixed "RESOLVED — "
#   body:  a line at the top saying what closed it
wb_workspace_edit → { workspaceId: "default",
  ops: [{ op: "document.set", documentId, markdown: <the edited whole> }] }
```

Both halves earn their place, and neither invents a schema:

- `type` is core OKF and already means this. A resolved issue stopped being a
  work item and became a record, which is what a note is. `type: issue` stays
  the query for "what is open" — the same thing that identified an issue
  before.
- The `RESOLVED — ` name prefix is what a reader sees. `wb_document_list`
  returns `documentId`, `path` and `name` and no frontmatter, so without it
  telling an open issue from a closed one costs one `wb_document_get` per
  document. Someone reached for this prefix by hand before it was written
  down, which is how it got here. If `wb_document_list` ever reports `type`,
  the prefix becomes redundant.

### Deleting

Deletion is for a document that was never worth keeping — a scratch probe, a
duplicate, a mistake. It is **not** how work is closed.

```
wb_workspace_edit → { workspaceId: "default",
  ops: [{ op: "document.delete", documentId }] }
```

Resolution used to be deletion, and that was carried over from when issues
were markdown files under `tmp/` — where deleting the file was the only move
available. It was never a decision about this store, and it cost real work:
what an issue accumulates is measurements and refuted hypotheses, and deleting
it throws exactly those away while leaving nothing to stop the same issue
being filed and investigated again. Observed: one flake was investigated,
filed, deleted as resolved, and re-filed by another session within minutes,
its analysis redone from scratch.

### No structured status

The `issue/1` facet domain was retired: it was implemented by an earlier pass
without an agreed schema, and a typed companion nobody called made it look
more settled than it was. Nothing has replaced it, and nothing needs to for
open-vs-closed — that is what `type` above carries, using a core OKF field
rather than a domain of our own.

So an issue document carries `type`, a `title`, and a body — and no
machine-readable priority or assignee. There is no evidence either is needed:
one person works this backlog, and the Task list already carries in-flight
state. Inventing one in passing makes it the
convention by accident.

Do not invent a replacement domain in passing. Extension facets round-trip
unvalidated through the generic bucket, so anything you write will persist and
quietly become the convention. Agree the schema first.

### Declaring what an issue is about

An issue MAY declare OKF `sources` — the files or documents its claims are
about. Nothing requires it, and a document without any is simply not judged.

```
sources:
  - resource: apps/web/src/components/workspace-files/SearchResults.tsx
  - resource: packages/server-core/src/tools/document-search.ts
```

`node .claude/scripts/stale-issues.mjs` then reports the open issues whose
sources have changed or been deleted since the document's own `generated.at`.
A bare string (`- apps/web/src/main.tsx`) is read the same way as an OKF
`- resource:` entry — half this backlog is written that way, and the two name
the same path. It reads the MAIN checkout's daemon whatever worktree it runs
in, because that is the one every session's MCP client reaches
(`wire-worktree-mcp` registers only the main checkout's proxy: the CLI keeps one slot per repository).
No new storage: `sources` rides the preserved-root-keys bucket, `generated.at`
is stamped by the write, and git already knows what moved. It also runs on
SessionStart, quietly, so a stale issue announces itself rather than waiting to
be re-read.

Quiet is not silent for the three states that look like "nothing stale" and
are not. A store it cannot reach prints `[stale-issues] skipped: <reason>`; a
workspace holding no issue document prints `holds 0 issue documents`; and a
`whiteboard` registration naming a proxy script that no longer exists prints
the two commands that repair it. The last is the one that matters most: the
hook reaches the daemon over its socket, not through that registration, so a
registration left naming a removed worktree leaves this check healthy while
no session can file or read an issue at all.

What it is worth, measured on the six issues one session read and found
already resolved: **four** named something that had since changed or been
deleted. The two it missed share a shape — the fix landed in a file the issue
never named — and that is the honest ceiling: `sources` says what a document is
ABOUT, not where a fix will land. It also cannot see the other failure at all,
a document that was simply wrong when written; the two most expensive
investigations in that session were of that kind.

So it reports "what this is about moved", never "this is resolved". Re-read
before closing anything.

### The other axis: is the write-up still true?

Open-vs-closed is about the WORK. Whether the document's own account is still
accurate is a separate question, and the two move independently — a closed
issue can hold a standing fact worth reading, and an open one can be built on
reasoning since disproved. Both happened in one session.

That axis is OKF v0.2's `status` (`draft` / `stable` / `deprecated`, absent
meaning `stable`), which this codebase preserves on documents that carry it.
Nothing produces or reads it yet, and it should stay that way until something
does — a signal nobody reads is inventory. Do NOT reach for `deprecated` to
mean "resolved": it says the account no longer holds, which would misreport
every closed issue whose write-up is still exactly right.

## tmp/ workspace buckets

Put temporary artifacts in the right bucket (never the root of `tmp/`):
- `tmp/issues/` — legacy issue source files (migrated to whiteboard documents)
- `tmp/notes/` — legacy design docs (migrated to whiteboard documents)
- `tmp/screenshots/` — UI captures during debug/verify
- `tmp/scripts/` — throwaway helper scripts

For **new** issues and notes, create them directly in the whiteboard via MCP tools.

Delete artifacts from `tmp/` when they're no longer useful.

## Bridge (Task list ⇄ whiteboard documents)

- **Session start**: `TaskList` to see live state; for open issues you intend to work this session, `TaskCreate` a task with `metadata.documentPath = "<path>"`.
- **In flight**: `TaskUpdate` status/owner/blockedBy as work moves.
- **New finding** (from review / dogfood-triage / reconcile): create a document via one `wb_workspace_edit` `document.create` op (the body rides on it); `TaskCreate` only if you're acting on it now.
- **Resolve**: a `document.set` op writes the document to `type: note` FIRST (read it back first — see Resolving), then `TaskUpdate status=completed` once that write has succeeded. The other order leaves the Task list saying done while the issue is still open if the write fails. Do not delete the document.
- Workflows can't call the Task tools or AskUserQuestion; they RETURN findings/openQuestions and the main session records them as tickets/tasks and asks the human.

## When to use which

- Orchestrating several workflow runs / parallel dev-loops right now → **Task list**.
- "Don't lose this for later" → **whiteboard document** (`type: issue`).
- Both, for anything you're actively working from the backlog (create the task, link the document path).