Commit Graph

338 Commits

Author SHA1 Message Date
xarmian 03ff8fc43a fix(BUG-3186): the CLI turns an attachment name into a path Windows can store, on Windows only; stored names stay as given (#1498)
A stored attachment name keeps < > : " | ? * (legal and common on Linux
and macOS: "Meeting: notes.pdf"). On Windows those are refused, and a
colon is worse: "a.svg:x.txt" names an NTFS alternate data stream, so the
bytes land in a stream on an empty "a.svg".

- attachments.WindowsPathComponent, beside WindowsDeviceName: reserved
  characters and C0 controls become "_", trailing dots and spaces are
  trimmed, and a device stem gets the "_" prefix. It is for the boundary
  where a name becomes a path, never for the stored name.
- The CLI's safeLocalFilename applies it only when it RUNS on Windows
  (safeLocalFilenameFor(runtime.GOOS, ...), so it is testable on any host).
  Each saver applies its own filesystem's rules, as browsers do.
  The accepted edge is written down there: a Linux CLI writing onto an NTFS
  mount keeps the colon.

Not changed, by lead ruling on the trail: the Content-Disposition name
(it stays faithful; the server cannot know the client filesystem) and the
bundle tar path. Import recomputes that path from the manifest and skips
unmatched blobs silently, so changing it would make older servers drop
bytes. A cross-version contract comment on bundleAttachmentPath says so.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 05:25:53 -04:00
xarmian c50f0585ad fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap, and the editor jitters its reconnects (BUG-1308) (#1499)
* fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap; the web provider jitters its reconnects (BUG-1308)

A collab dial drew on the general per-user API bucket (burst 60), so 100
dials from one user got 60 through, and a user's own dials and REST
calls refused each other. Dials now draw on a CollabDial bucket (5/s,
burst 50), and how many sockets a principal may hold is bounded
separately by PAD_COLLAB_MAX_PER_USER (default 50), refused with 429
collab_limit_exceeded and Retry-After. Both defaults are sized from the
reconnect-storm measurement on BUG-1308's trail (checkpoint 2).

A browser cannot read a refused handshake's status, so the provider's
backoff is what spaces a refused herd out: each step is now jittered to
between half and all of itself.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(collab): PAD_COLLAB_MAX_PER_USER and the collab dial bucket, in the deployment docs, samples and load-test usage (BUG-1308)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(collab): only a WebSocket upgrade is a dial; per-user cap pinned within one workspace; dial-bucket keying stated correctly (BUG-1308, codex round 1)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(collab): a PAD_COLLAB_MAX_PER_USER below 1 means unlimited, as the SSE knobs do (BUG-1308, codex round 2)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(collab): the struct field agrees, 0 or less is unlimited (BUG-1308, codex round 3)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 04:51:57 -04:00
xarmian 80ec51c18c fix(BUG-3185): an item export's file name whose stem is a Windows device gets a "_" prefix, on the server and in the CLI (#1495)
`pad item export` saves to "<slug>.pad.md", so an item slugged nul, con,
aux, prn, com1-9 or lpt1-9 named a Windows device, and the CLI on Windows
wrote the artifact nowhere.

- attachments.WindowsDeviceName is exported (a rename, no behaviour
  change): the one device rule from BUG-2822 (#1493), where the stem ends
  at the first "." or ":". Its doc says not to write a second one.
- artifactExportFilename prefixes "_" to a device name, so the
  Content-Disposition both clients save under is safe.
- The CLI applies the same rule to the path it will write
  (defaultExportPath): it is the process writing the file, an older server
  still suggests the device name, and the "<ref>.pad.md" fallback never
  passed through the server.

Population (every Content-Disposition and client default name): the
bundle export ("<slug>-export.tar.gz", whose stem is never bare), the account
export (a fixed name) and the edit temp/recovery files (the "pad-edit-"
prefix) are safe; attachments are #1493. Import has no mirror: nothing it
creates is named from content.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 03:44:11 -04:00
xarmian 1db30daa7f fix(project): standup and changelog rows show the value of the item's own done field, not a blank read from a literal status (BUG-2640) (#1477)
Both copies of the completed-work listing (the server's
listTerminalItemsSince and the CLI's listCompletedWorkSince) already resolve
each collection's done field per collection when querying. The rows
rendering those items read the literal "status", so an item from a
collection whose done field is something else (board_group_by: stage) was
included with a blank Status. The resolved field now travels beside the
items as a collection-id map, and the four row builders (server and CLI,
standup and changelog) read it through completedWorkValue, falling back to
status. The JSON key stays "status"; only the value it reads moves.

Dashboard and item-list views that read the literal status to decide
active, in-progress and stalled ask a different question, and are left
alone.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 19:37:58 -04:00
xarmian 9b8f72d202 fix(insights): a default report leaves out system collections (Conventions, Playbooks), so they stop counting in Insights (BUG-2410, ToolSurfaceVersion 0.49) (#1471)
* fix(BUG-2410): a default report leaves out system collections; Insights' picker stops offering them; ToolSurfaceVersion 0.49

* fix(BUG-2410): a saved layout naming a system collection no longer filters the report by a selection the picker cannot show (codex r1)

* fix(BUG-2410): with no collection list the report goes unfiltered, and a failed load never erases the saved layout (codex r2)

* fix(BUG-2410): a collections response for a workspace the user left is ignored (codex r3)
2026-09-23 18:21:38 -04:00
xarmian a3ebf69932 fix(project): a guest's item grants count as completed work only under their own collection's terminal values, and each item is listed once (BUG-2639) (#1474)
listTerminalItemsSince passed the caller's full item-grant list into every
per-(field, value) completed-work query. ListItems ORs ItemIDs past the
CollectionIDs restriction, which is right for visibility, but here
CollectionIDs also means 'the collections for which this value is completed
work'. So a granted item was counted under another collection's terminal
value, and one matching two groups' keys was listed twice. Each granted
item's collection is now resolved once, and each group receives only the
granted items that live in one of its collections (the group's full list,
so item-grant-only collections still contribute).

Both copies (the server's and the CLI's listCompletedWorkSince) now also
dedup by id. That is not only defensive: the list endpoint splits a filter
value containing a comma into an IN list, so a collection whose
completed-work values include both 'x' and 'x,y' matched one item twice,
for members too.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 17:34:27 -04:00
xarmian 6cc2c7eb95 fix(items): create refuses reserved metadata keys; convention metadata goes through a typed create member; a full fields update may only carry them (BUG-3163, ToolSurfaceVersion 0.48) (#1470)
Item CREATE was the last door that stored a hand-written reserved metadata
key (implementation_notes, decision_log, github_pr, convention). A `--field`
or MCP `field` value landed as a string no extractor reads. For notes and
decisions, that also made `note` / `decide` refuse on the item until the row
was repaired.

Create's `fields` now refuses all four keys with 400 validation_error. The
check runs on the caller's map. Library activation, the only system writer that
used this door, moves to a new typed `convention` create member, which is
validated (an empty object is refused), normalized and lowered into the blob.
Every convention writer switches to it: the CLI and remote MCP library
activate, the web library activate, the web Conventions page create, and the
e2e seed helper. The CLI checks the STORED `convention` key to confirm the
member landed. It cannot use item.Convention for that, because that value is
derived from the sibling keys and is non-nil even when an older server has
dropped the member.

A FULL `fields` update may CARRY stored reserved values unchanged. A differing
value, or an OMITTED stored key (which the whole-blob replace would delete),
is refused with 400. The comparison uses decoded JSON, never bytes, and runs
in the update transaction against the row re-read under the write lock
(composed into the precheck, like the BUG-3133 guard). A note appended
concurrently therefore cannot be erased by a stale blob. A stored blob that is
not a JSON object has no readable reserved metadata, so a full write can
still repair it.

Artifact import shares createItemChecked but needs no gate, because
artifact.Decode fills Fields from a fixed frontmatter key list. Existing test
fixtures that put reserved keys in through the two closed doors now seed at
the store level. The stdio refusal texts are pinned to the classifier's
validation_failed table.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 16:23:06 -04:00
xarmian bee399bc94 fix(items): github_pr is written only through a typed, validated update member; the field-setter door refuses it (BUG-2696, ToolSurfaceVersion 0.47) (#1469)
A `--field github_pr=...` / MCP `field` setter stored the PR as a string on
every transport, so no link rendered, and `=null` stored the string "null".
BUG-2627 had exempted github_pr from the reserved-key refusal as remote MCP's
only writer; that writer never worked.

ItemUpdate gains a typed `github_pr` PR object (number > 0 and an absolute
http(s) url required) plus `clear_github_pr`, lowered by the handler into the
field patch after the caller's own fields_patch is refused every reserved key.
`pad github link` / `unlink` / `project reconcile` use it and verify from the
response that the change landed (an older server ignores the member and
answers 200). Not exposed on the MCP catalog; a remote PR-link action is
deferred to the catalog-trim decision. The point is validity, not sender
identity. Create (still accepts hand-written reserved keys) is BUG-3163.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 14:35:41 -04:00
xarmian c95168ff0d fix(mcp): bulk-update refuses an undeclared status/priority per item on stdio and remote too (BUG-3156, ToolSurfaceVersion 0.46) (#1463)
* fix(mcp): bulk-update refuses an undeclared status/priority per item on stdio and remote too (BUG-3156, ToolSurfaceVersion 0.46)

Over WebMCP, pad_item bulk-update has refused (since 0.44) a status or
priority change on an item whose collection declares no such field; stdio
and remote went through the single-item PATCH, which accepted it with a
warning and stored an orphan field.

The item update door gains a general, additive strict opt-in,
`refuse_undeclared_fields`: with it, the write is refused with 400
validation_error for exactly the keys warnings.undeclared_fields would have
named, and nothing is written. Both bulk-update transports set it. A write
without it is unchanged; a server that predates it ignores it, which is the
old accept-and-warn.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* test(server): a refused write also leaves the title and body alone (BUG-3156, codex round 1 on #1463)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 10:48:28 -04:00
xarmian 919c2975c2 fix(items): note and decide append server-side under the write lock, so they no longer revert a concurrent field write (BUG-3056) (#1458)
* wip(BUG-3056): server-side note/decide append, capability-gated CLI

* test(BUG-3056): server append tests

* test(BUG-3056): CLI capability gate tests

* test(BUG-3056): store append tests on both backends; CLAUDE.md API note

* fix(BUG-3056): keep the remote /mcp door's created_by label (codex r1)

* docs(BUG-3056): CLAUDE.md names the remote door's author label
2026-09-23 07:46:45 -04:00
xarmian 033615ac94 fix(metrics): bound the MCP tool label and the HTTP method label, which callers could fill with invented values (BUG-2817) (#1456)
* fix(metrics): bound the MCP tool label and the HTTP method label, which callers could fill with invented values (BUG-2817)

Two metric labels took caller text verbatim, and each new value minted a
new Prometheus series.

- pad_mcp_tool_calls_total and pad_mcp_tool_call_duration_seconds (tool):
  the value is params.name or the JSON-RPC method, recorded even for
  rejected and denied calls. It is now bounded by mcp.Server.IsKnownCallName,
  which asks the LIVE server for registered tools and adds the protocol
  methods. Anything else is recorded as "unknown". The predicate is injected
  (SetMCPMetricsCallNames) because internal/mcp imports internal/server.
  Unwired, every name is "unknown". The audit row keeps the full value.
- pad_http_requests_total, _request_duration_seconds and
  _response_size_bytes (method): raw r.Method, recorded by middleware that
  runs AHEAD of auth, and Go accepts any token as a method, so an
  unauthenticated caller could mint series. Measured: 50 invented methods
  gave 50 series. A method outside the standard set is now "OTHER".

The checkpoint-1 population on BUG-2817's trail enumerates all 14 labelled
metrics and their 22 label slots; the other slots take constants or bounded values.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* fix(mcp): every mcp-go method constant is a known metrics call name (BUG-2817, codex round 2)

The protocol set was a hand-picked subset, so a legitimate method such as
notifications/tools/list_changed would have been counted as "unknown". It is
now every method constant mcp-go v1.1.0 defines (34). Each is a library
constant, so the label stays bounded.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* test(mcp): pin all 34 protocol methods as literals, so a dropped entry fails (BUG-2817, codex round 3)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* fix(server): the MCP metrics bound is a required SetMCPTransport argument, and the method test covers all nine standard methods (BUG-2817, codex round 4)

A separate setter could be left uncalled with every test still green, so the
predicate is now a parameter of the call that mounts the transport. nil fails
closed. The HTTP test now keeps each of the nine standard methods as its own
value, so mapping any one of them to OTHER fails it.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* test(server): the bounded-label test passes its predicate through SetMCPTransport, so a broken assignment there fails it (BUG-2817, codex round 5)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 06:01:08 -04:00
xarmian 969955c810 fix(mcp): a 413 is too_large on both transports, not server_error (BUG-2829) (#1455)
* fix(mcp): a 413 is too_large on both transports, not server_error (BUG-2829)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(mcp): tool surface 0.45 in instructions, README and CLAUDE.md (BUG-2829)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 05:17:16 -04:00
xarmian a92af3eebc fix(attachments): drop Bidi_Control characters from attachment names and rewrite legacy rows (BUG-3153) (#1452)
* fix(attachments): drop Bidi_Control characters from attachment names and rewrite legacy rows (BUG-3153)

A name such as "x<U+202E>gvs.txt" is a .txt that every bidi-aware renderer
shows as "xtxt.svg". About forty web read sites, the CLI list and the server
render the stored name, so the fix is at the source rather than per renderer.

DroppedFilenameRune now also drops the Unicode Bidi_Control property: U+202A to
U+202E, U+2066 to U+2069, and the marks U+200E, U+200F and U+061C. It is chosen
by property, so ZWJ (Join_Control) and emoji sequences are untouched. The rule
is shared, so both ingest doors store the name clean and the download header
drops the characters for any row. One of these characters inside an extension
also hid that extension from the blocklist, as a control byte did: an SVG
uploaded as "x.s<U+202E>vg" was accepted, and is now refused as ".svg".

Legacy rows are rewritten once, at startup, by
BackfillBidiAttachmentFilenames, and only rows containing a Bidi_Control
character are touched. The new name is the SERVED name (ServedFilename), not a
plain strip, so a legacy "x.s<U+202E>vg" becomes "x.bin" rather than storing the
blocked "x.svg". The pass is idempotent with no marker, since a rewritten row no
longer matches. Each UPDATE is conditional on the old name. Every rewrite is
logged with its old and new names quoted to ASCII, so the rewrite is reversible,
and the count is logged on every boot, 0 included.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* fix(store): the bidi filename backfill states its wider rewrite and is tested per rune on both dialects (BUG-3153, codex round 1)

- The rewrite is the served name, so a selected row also loses whatever else
  NormalizeFilename removes from a legacy name. That is now said in the
  comment and pinned by a case, and rows without a Bidi_Control are still
  never touched.
- "Reversible by the log" now says: for as long as the log is retained, with
  no durable record in the database.
- A row that gains such a name later (derived from a parent a failed pass
  left) is picked up by the next boot, since selection is by content.
- New test: one row per member of unicode.Bidi_Control, each selected by the
  SQL and rewritten. It enumerates the property itself rather than the list
  under test, so a rune missing from the LIKE list fails it.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* fix(store): a rows-affected error stops the bidi backfill instead of passing as a concurrent rename (BUG-3153, codex round 2)

The UPDATE may have landed, and without a count the row could not be logged as
rewritten, which the reversibility rests on. The comment also names the
readers it means, instead of an unmeasured count.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* docs(attachments): NormalizeFilename covers the caller-supplied doors, and derived rows inherit (BUG-3153, codex round 3)

"Every door that writes attachments.filename calls it" was not true of
thumbnails, transforms or copies, which derive their name from another row.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* test(e2e): the hostile-name parity spec expects the stored name, with Bidi_Control dropped (BUG-3153)

The spec uploads "start <U+202E>reversed<U+202C> مرحبا.png" and asserted that
the tile and dialog carry it exactly as uploaded. Ingest now drops the
override. The spec still uploads the hostile name, and now asserts that the
STORED name (RTL letters kept, controls gone) is addressable and names its
dialog. Its fixture now uses \u escapes instead of literal controls. CI caught
this on #1452; the local sweep had not searched web/e2e.

Against this tree's binary the spec passes, and against a binary without the
fix it fails, so the spec discriminates.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 04:35:58 -04:00
xarmian 88a5af1a74 fix(mcp): stdio passes "-"-leading free text as text, and reports argv refusals and 429s with the right code (BUG-3142, BUG-3147) (#1444)
* fix(mcp): stdio sends positionals behind "--", so free text starting with "-" is no longer parsed as a flag (BUG-3142)

BuildCLIArgs emitted positionals first with no terminator, so cobra read
`item search "-ship it"` as an unknown shorthand flag and refused the call
before the command ran, on all 12 free-text positionals the stdio transport
carries. It now emits every flag (root flags, --workspace, --format
included) and then `--` and the positionals; the remote door ignores this
vector. Pinned against the real cobra tree through each command's own flag
parser, including a positional that is literally "--workspace".

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(mcp): stdio reports an argv refusal as validation_failed, with its message, instead of a retryable server_error (BUG-3142)

Two defects on one path. The root command's flag-error hook prints cobra's
usage block BEFORE the "Error:" line, and stripCobraUsageBlock truncated at
"Usage:", so a flag-parse refusal reached the classifier as an empty string.
And no stderr pattern matched cobra's or pflag's argv refusals at all. The
strip now stops at a following "Error:" line, and one anchored pattern covers
the parse-time family enumerated from cobra v1.10.2 and pflag v1.0.10.

Pinned end to end: the test binary re-executes as the real CLI under
ExecDispatcher, so the real stderr ordering is what gets classified.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(mcp): a 429 reaches a stdio caller as rate_limited with the server's wait, and the tool surface is 0.42 (BUG-3147, BUG-3142)

The CLI printed only the limiter's message, which no stderr pattern matched,
so every 429 reached a stdio MCP caller as a retryable server_error while the
remote transport said rate_limited. The CLI now writes the structured marker
line at its root for a 429 on any command, carrying Retry-After as
details.retry_after_seconds, and stdio lifts it.

ToolSurfaceVersion 0.42 covers this and BUG-3142's classification change in
one entry: both change the code a stdio caller receives, and nothing else.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* test(cmd/pad): the stdio argv test restores the root flag globals it parses into (BUG-3142)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-22 22:49:36 -04:00
xarmian 6fdd8816f3 feat(playbook): match free text to an active playbook via the decision provider (TASK-3120) (#1429)
* feat(server): POST /playbooks/match — typed-decision Choice over active playbooks (TASK-3120)

PLAN-3114 unit 5. A synchronous, read-only endpoint: given free text, asks
the workspace's typed-decision provider which active (non-draft,
non-deprecated) playbook it best matches, with a reserved "none" option so
unrelated text gets an honest answer instead of a forced pick — and, as a
side effect, keeps a workspace with exactly one active playbook askable
(decision.Question.Validate needs >=2 Choice options).

- decision.Runner gains a nil-safe Provider() accessor: this endpoint needs
  a plain synchronous Ask, not the owed-jobs pipeline Evaluate drives.
- No provider configured -> 404 decision_provider_unavailable (callers fall
  back to slug/trigger routing). A provider error or an out-of-set answer
  the provider was never offered -> 502 decision_provider_error, distinct
  from the 404 case so a caller can tell "try again" from "not configured".
  Zero active playbooks -> 200 with a stated reason, no provider call.
  Over the provider's 255-option ceiling (including "none") -> refused
  rather than silently dropping playbooks and answering from an
  incomplete set.
- listPlaybookItems is factored out of handleListPlaybooks so list and
  match share one visibility-scoped item source and can't drift on which
  playbooks a caller can see.
- 30s timeout on the synchronous provider call, tighter than the
  provider's own worst-case retry budget (~330s) since an HTTP caller
  blocks on this one directly, unlike the async decision tick.
- content_state_population_test.go's BUG-3033 sweep gets a new ruling: the
  PlaybookSummary call here feeds the provider prompt only, never the
  response, so there's no reader-facing text to mark.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(cli): pad playbook match "<text>" (TASK-3120)

CLI + client-method half of PLAN-3114 unit 5 — POST /playbooks/match.
Table output prints the matched ref/title (or "none"), confidence, model,
and a probabilities table sorted descending; --format json passes the
response through unmodified. Read-only, side-effect-free.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* docs(skill): route to playbook match as rung 3, offer-only (TASK-3120)

PLAN-3114 unit 5's skill half. Exact invocation-slug match and trigger
match stay rungs 1-2; match is consulted only when the user is plainly
asking for a procedure/workflow and both of those missed — never on
ordinary conversation, since every call spends provider money. Any match
returned is offered, never auto-run, at any confidence: TASK-3137's replay
of this same provider's attention question set found no validated
threshold, so nothing here invents one for this question either. A 404
(no provider configured), a "none" choice, or any other error falls back
silently to today's slug/trigger-only behavior.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* chore(mcp): de-indent padItemToolDescription's continuation lines (TASK-3120)

Whitespace-only — git diff -w on this file is empty. The description's
continuation lines carried 18 leading spaces purely for visual alignment
under each action's "  name — " bullet in the Go source; that padding is
emitted verbatim into the tools/list JSON, where it conveys nothing an
LLM reading the tool description gets any value from.

De-indented by exactly 14 spaces on every line that had >= 18 (all 116 of
them, uniformly, since the file carries only three indentation levels:
0, 2, 18 — no deeper nesting to preserve a relative offset for).
Continuations now land at 4 (bullet's 2 + 2), so a reader can still tell
which action a line belongs to; a 1-space collapse would have inverted
that (continuation LESS indented than its own bullet).

tools/list: 55290 -> 53666 bytes (-1624). initialize: unchanged at 24530
(it carries no tool descriptions). Frees headroom against the 55296
budget for the pad_playbook.match action landing in the next commit —
budget constants themselves are untouched, per standing instruction.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(mcp): pad_playbook.match action, ToolSurfaceVersion 0.39 -> 0.41 (TASK-3120)

PLAN-3114 unit 5's MCP half, landing on top of the whitespace-only
catalog_item.go trim (previous commit) so this stays within the
tools/list budget with room to spare.

- catalog_playbook.go: new `text` param, new `match` action
  (passThrough — the CLI's positional arg is named "text", matching
  this tool's param, so no custom shaping is needed) plus a
  description bullet in padPlaybookToolDescription.
- tool_surface.go: `match` marked read-only (nothing stored, no job
  enqueued).
- dispatch_http_routes.go: `playbook match` routeSpec (POST, flat
  {text} body) so the remote /mcp transport can dispatch it in-process.
- version.go: ToolSurfaceVersion 0.39 -> 0.41. 0.40 is reserved for
  PR #1427 (the 0.32/0.33 precedent — a gap is cheaper than a
  collision). 0.41's own entry: pure ADDITIVE bump, same disposition
  as 0.13/0.28/0.11 — nothing existing moved.
- instructions.md, README.md, CLAUDE.md: version headings + the
  pad_playbook bullet/table-row/changelog-sentence updated per the
  drift tests (TestInstructionsMDCoversEveryCatalogAction,
  TestReadmeTableCoversEveryCatalogAction, the two version-match
  tests). instructions.md's addition is intentionally terse ("`match`
  finds a playbook from text.") — it's embedded in the `initialize`
  handshake payload, which has its own 24576-byte budget and had only
  46 bytes of headroom; a fuller explanation lives in the tools/list
  param/action docs instead, which have budget to spare after the trim.
- catalog_readonly_test.go, dispatch_http_parity_test.go: new
  `pad_playbook.match` entries in three hand-built fixture maps
  (expected cmdPath ×2, a hand-rolled cmdhelp stub, and the shared
  parity-drive fixture input) so the existing bijection/parity/dispatch
  guards actually exercise the new action instead of skipping it.

Bytes measured (go test ./internal/mcp -run Budget -v):
  tools/list:  55290 (baseline) -> 53666 (after the trim commit) ->
               54805 (after this commit) — 491 bytes of headroom left
               against the 55296 budget.
  initialize:  24530 (baseline, unchanged by the trim) -> 24574 (after
               this commit) — 2 bytes of headroom against the 24576
               budget. Flagged in the implementer report: this is now
               as tight as tools/list was before the trim, and nothing
               in this unit addresses it.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(server,decision): don't leak provider errors, classify oversized requests, fix none's wording (TASK-3120)

Lead's shallow-pass findings on cd7a41ed, applied after the MCP commit.

F1 (disclosure): the provider-error path answered with err.Error()
verbatim. typesafe.go's client surfaces the provider's raw 4xx body in
that string (APIError.Error()), which is the INSTANCE's own account
state with typesafe.ai — key validity, quota, billing wording — not
anything about the caller's request. This endpoint requires only
viewer access, so any workspace member could read it. Now logged
server-side in full (slog, workspace + err) and answered with a fixed
message plus a coarse details.reason ("timeout" for a context
deadline, "upstream" otherwise) via a new writeDecisionProviderError
helper. Test constructs a fake error containing a secret-looking
string and asserts it never reaches the response body.

F2 (misclassification): the request budget is 32000 tokens
(~80000 chars). At a couple hundred active playbooks with realistic
summaries, the Choice question's options alone can exceed it before
any state is even added — or leave no room for the input text. Both
were plain fmt.Errorf with no sentinel, so they fell into the generic
"provider failed" 502 path, telling a caller "try again" for a
request that will never fit no matter how many times it's retried.
decision/typesafe.go gains ErrRequestTooLarge (wrapped with %w onto
both pre-send refusals; existing message text unchanged, just
appended-to) alongside the existing ErrMaxTokensExceeded (the
provider's own after-the-fact refusal of a request fitState let
through) — both now map to a distinct 400 match_request_too_large.
New decision-package unit test builds a realistic 254-option Choice
(title + near-cap summary + trigger + slug per option, mirroring what
playbook-match could plausibly send) and confirms fitState refuses it
before the fake HTTP handler is ever called; server-side table test
covers both sentinels through the fake provider.

F3 (nit): the "none" option's description read "...listed above",
but Options is an unordered map — nothing puts them "above" it.
Reworded to "...any of the other options' procedures"; the pinned
exact-prompt test updated to match.

Also (lead's separate MCP-round instruction): decision.go's
maxChoiceOptions is now exported as MaxChoiceOptions, and the
server's over-limit refusal reads that constant instead of a copied
`255` literal, so the two ceilings cannot drift apart.

go test ./internal/decision/... ./internal/mcp/... ./internal/server/...
./cmd/pad/... ./internal/cli/... — all green. golangci-lint run ./...
— 0 issues. Full `go test ./...` run separately; final line in the
report.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* chore(mcp): cut pad_playbook.match's tools/list text to the approved +292 variant (TASK-3120)

The lead's shape-(a) approval was for the measured "+292 documented"
prototype, not the fuller wording ae38353c actually shipped
(+1139 bytes) — the trim commit exists to pay for the approved
action text, not to fund arbitrary new prose. Cut the `text` param to
one sentence (the 404-fallback advice lives once, in the action
bullet, not repeated in the param) and the `match` action bullet down
to four lines at consistent bullet+2 continuation indentation,
matching the rest of padPlaybookToolDescription's style (the previous
wording had drifted to 20-space continuation on its last two lines).

tools/list: 53666 (post-trim baseline) -> 54023 (+357, close to the
~300 target — wording differs from the original prototype's exact
text, so an exact match isn't expected). initialize: unchanged at
24574 (this file's bullet is tools/list-only; instructions.md is a
separate file, untouched here).

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(server): don't overclaim writeDecisionProviderError's timeout reason (TASK-3120)

The doc comment said "timeout" covered a cancelled caller request too,
but context.Canceled is a distinct error from context.DeadlineExceeded
and errors.Is(err, context.DeadlineExceeded) does not match it — that
case actually falls through to "upstream", contradicting what the
comment claimed. Narrowed the comment to deadline-only (the preferred
fix per review): a cancelled caller's own request also never reads
this response body, so the distinction was moot as well as wrong.
Code was already correct; only the comment overclaimed.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(cli,skill): pad playbook match needs -- before dash-led text (TASK-3120)

Codex round 2 finding on f31d5d64. Text starting with "-" (a dash-led
sentence, or literal text like "-ship it") is parsed as a flag by
cobra's default scanner, on both the CLI and stdio MCP (which shells
out to this same command). Pre-existing and class-wide — `pad item
search "-x"` fails the same way — so the general stdio fix across
every command is filed as BUG-3142 rather than done here; BuildCLIArgs
is untouched.

This command's own help now shows the workaround: an Example section
with `pad playbook match -- "-ship it"`. skills/pad/SKILL.md's rung-3
routing entry calls it the same way, with a one-line reason, and notes
the MCP form is unaffected (a structured `text` param, not a CLI
token).

New CLI test asserts BOTH directions: `playbook match "-ship it"`
(no --) fails at cobra's flag parser and never reaches the server;
`playbook match -- "-ship it"` succeeds and the server receives the
text with its leading dash intact. Ran both legs — see the test.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(skill): local stdio MCP is not exempt from the leading-dash flag parse (TASK-3120)

Codex round 4 finding. The rung-3 routing entry claimed the MCP form of
match was "unaffected" by BUG-3142, but that's only true for remote
/mcp, which keeps `text` a structured param. Local stdio MCP shells
out to `pad playbook match`, turning `text` into a CLI positional via
BuildCLIArgs (dispatch.go:435) — the same flag parser, the same bug.
Corrected: remote /mcp is safe as-is; local stdio MCP still hits it and
should prefer the CLI's `--` form when the text starts with `-`.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(cmdhelp): the example validator's flag scan must stop at "--" (TASK-3120)

CI red on #1429 (a2a5a853), Go/Go-PG/Nix all failing the same way:
TestRealPadTree_ExampleDriftValidator flagged the new `pad playbook
match -- "-ship it"` example's own dash-led TEXT ARGUMENT as an
unknown flag `--ship it`. Cobra stops flag parsing at a lone "--" —
that's the whole point of the token — but validateExample's scan
treated "--" as just another flag-shaped token: TrimLeft reduced it
to an empty name, which fell into the "skip, keep scanning" branch
instead of stopping the scan. Everything after "--" kept getting
flag-checked, so the terminated example's own positional text was
misread as a flag.

Fixed by checking `t == "--"` BEFORE the dash-stripping and breaking
the scan there, per review. A lone "-" (the stdin convention, e.g.
`pad item import -`) is a different token — it still reaches the
dash-stripping, still reduces to an empty name, and still just
`continue`s past — so it does not terminate the scan.

Three new unit tests in internal/cmdhelp cover all three legs: (a) a
dash-led positional after "--" produces no finding, (b) an unknown
flag BEFORE "--" is still caught — the break cannot hide drift ahead
of the terminator, and (c) a lone "-" does not stop the scan (an
unknown flag after it is still caught). Manually re-ran (a) with the
break removed to confirm it goes red without the fix, then restored
the real fix — ran, not just described: FAIL "a dash-led positional
after -- should not be reported as an unknown flag" without the break,
PASS with it.

The playbook match example itself is unchanged.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 11:28:57 -04:00
xarmian 442dcbdc6a feat(decision): instance-admin settings for the decision provider (TASK-3121) (#1426)
console/admin/settings gains a Decision provider section with a provider select,
an enable toggle, a write-only API key stored encrypted, a model pin, and the
privacy line. It is backed by GET/PUT /api/v1/admin/decision-provider.

- The admin setting is a third source to decision.ResolveNamed:
  config file < admin setting < environment, per field. Resolve is now
  implemented on ResolveNamed, so the page's "set by environment" comes from
  the same pass that builds the provider.
- Changing the setting rebuilds the provider and swaps the runner in place.
  The tick and the write doors' enqueue resolver both follow the swap, so no
  restart is needed. A swap cancels any pass in flight, so a disabled provider
  is not called again; the resolver is installed before the runner is
  published; and no tick can start after Stop().
- The key is encrypted through the PAD_ENCRYPTION_KEY path
  (store.SetSecretPlatformSetting). It is never returned by any read, masked
  or otherwise, and it is not writable through /admin/settings. The store
  refuses a secret write without an encryption key (`pad server` never
  reaches that state), and it refuses to READ a secret row that is not
  ciphertext.
- A PUT to a field the environment sets is refused with 409
  set_by_environment. On Pad Cloud the environment is the only source and PUT
  answers 403 managed_by_operator.
- `pad server info` still resolves only the file and the environment on the
  CLI host. It now says so, in an additive `sources` JSON member and in its
  human line.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 07:34:33 -04:00
xarmian 5fe478b7d0 fix: refuse a token-guarded content write over unflushed tab edits (BUG-3133) (#1423)
expected_seq guards the row, and a browser tab's typing is not in the row
until the tab flushes. So a content PATCH carrying a current token replaced
those edits: through the applier's setContent with a tab open, and with no
tab open the direct path's PruneItemOpLogTx deleted them, and they existed
nowhere else.

A content write that carries expected_seq or expected_updated_at is now
refused with 409 content_pending_flush while content-bearing op-log rows sit
above the flush watermark. The check is composed into the existing precheck,
so the direct, applier and collab-disabled paths all inherit it, and runs in
the write's own tx after the token check (a stale token still answers
update_conflict). It has its own code because re-reading returns the same
row and seq. overwrite_pending_edits lifts it on the API, the CLI
(--overwrite-pending-edits; item edit --force sets it on the save) and MCP
on both transports, with a shared hint that does not say re-read and retry.

A tokenless write is unchanged, except that a direct write now reports
warnings.pruned_pending_edits when its prune deleted such rows (the CLI
prints it to stderr).

MCP ToolSurfaceVersion 0.39: a behavior bump with one additive param.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 00:42:02 -04:00
xarmian 1681be74f5 feat(decision): content-aware attention on the dashboard and item page (TASK-3118) (#1421)
* feat(decision): attention question set on the dashboard (TASK-3118)

Registers the `attention` set (needs_human_decision and blocked verbatim
from the day-73 eval, plus waiting_on_external) through a production
registry. A new QuestionSet.Eligible gate, checked by the runner at
evaluation time, keeps terminal items and system collections from being
sent. The dashboard reads the latest answers to the current questions in
one batch query: needs_human >= 0.7 becomes a "needs_human" attention entry,
and blocked >= 0.7 joins the blocked section only where the dependency
graph is silent. A failing provider marks attention.decisions degraded.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(web): attention chips on the item page, dashboard icons (TASK-3118)

DecisionChips fetches the item's decisions itself (the item object is
also fed by projections that do not carry them) and shows only CURRENT
attention answers, emphasised at the dashboard's 0.7 threshold. It renders
nothing without a provider. A request token keeps a switched-away item's
late response from painting. It is listed in the BUG-3095 child
population, and stubbed in the ItemDetail identity harness like its
self-fetching siblings.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* test(server): threshold edge and below-threshold blocked legs (TASK-3118)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* docs: attention set in CLAUDE.md; registry comment no longer says empty (TASK-3118)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): dashboard attention applies the item page's currency rule (TASK-3118)

Codex round 1: the dashboard read the latest answer without checking it
against the item's present state, so an item edited or commented since
its last evaluation kept a stale "needs a human" entry, while the item
page, which hides non-current answers, showed nothing. WorkspaceFlags now
returns only CURRENT answers at or above the threshold. It does one state
read per flagged item, so the cost is bounded by the flagged items rather
than the workspace. Also adds a leg for an item made terminal by a
collection schema edit, which changes no item state; mutant M11 survived
without that leg.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): an answer is current only while its set still applies (TASK-3118)

Codex round 2: a collection schema edit can make an item terminal without
changing its state hash. /decisions then kept calling its attention
answers current, and the item page kept showing chips the dashboard had
dropped. Evaluate, Decisions and WorkspaceFlags now share one appliesNow
rule, and the dashboard's separate done-skip is gone.

Declined from the same round: a TOCTOU between the dashboard's state read
and its response. An edit landing after the read is reflected on the next
load, which holds for every read in the handler.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(store): renumber the decisions workspace index to 092/069 after #1419 took 091/068 (TASK-3118)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 23:55:54 -04:00
xarmian 3e099423f8 fix(store): content_state counts only op-log rows that can change the document (BUG-3124 unit A) (#1419)
* fix(store): content_state counts only op-log rows that can change the document (BUG-3124 unit A)

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* test(store): BUG-3124 — the backfill's twin must be an EARLIER row

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* docs: BUG-3124 prose sweep — content_state counts content-bearing rows only

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(store): BUG-3124 — renumber migrations to 091/068 after #1417 took 090/067

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 22:02:48 -04:00
xarmian 1144fe8060 fix(cli): item edit refuses a stale body, guards its save with expected_seq, and keeps a recovery file on failure (BUG-3035) (#1418)
* fix(cli): item edit refuses a stale seed, guards the save with expected_seq, and never loses the edit (BUG-3035)

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* docs(cli): BUG-3035 prose sweep — edit refuses by default, the warning is the forced path's

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(cli): BUG-3035 codex round 1 — help states the unflushed-typing gap (BUG-3133); a nonzero editor exit aborts and says so

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 21:03:05 -04:00
xarmian 304673faa1 feat(decision): typed-decision storage and async runner (TASK-3117) (#1417)
* feat(store): item_decisions + decision_jobs schema (TASK-3117)

Answers keyed on (item_id, question_set, question_key, state_hash) with
item_seq kept for the audit; a private per-(item, question_set) job queue
with generation-based coalescing and lease reclaim, in place of the
public event outbox. NUL census: three columns excluded with reasons,
fifteen id/timestamp/enum columns baselined.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(decision): job queue store layer + Runner with state-hash idempotency (TASK-3117)

Item create, item update and comment create enqueue a coalesced job per
applicable question set in their own transaction; nothing waits on the
network. The Runner builds one serialised state (title, collection,
fields, bounded body, last RecentTrailWindow comments), hashes those
exact bytes, and sends the same bytes as json.RawMessage.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* test(decision): queue, runner and read pins; move enqueues (TASK-3117)

Move is a door (ruling 3, keyed on the target collection's sets). The
three system fan-out rewrites are named as non-doors at their sites.
Tests pin: each door enqueues and coalesces; nil resolver writes no row;
lease reclaim; mid-evaluation re-enqueue survives completion; backoff;
latest-per-key read; hashed bytes == wire bytes; a comment re-evaluates
at an unchanged seq; reorder / parent link / role assignment move seq
but do not re-evaluate; current flips false after a comment.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(server): decision tick, read API, decisions on item GET (TASK-3117)

Boot builds the provider from one shared resolver (also used by
`pad server info`); a nil provider means no resolver, no job rows, no
tick. GET /items/{ref}/decisions returns the latest answer per question
with current=true only when computed from the item's present state; the
single-item GET and the agent view carry `decisions` when non-empty. A
shutdown releases in-flight claims without counting a failed attempt.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* test(decision): cancelled pass releases; agent view carries decisions (TASK-3117)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(store): comment edit/delete enqueue; mid-call cancel test (TASK-3117)

Comment edit (when the body changed) and comment delete change the
hashed trail, so they are doors under ruling 3's rule. Adds the
mid-call shutdown test that mutant M20 showed was missing, and the
decisions endpoint to CLAUDE.md.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): never send an item in a soft-deleted workspace or collection (TASK-3117)

GetItem filters only the item's own soft delete, so a job owed for an
item whose workspace or collection was soft-deleted would have sent that
content to the third-party provider during the restore window. The
runner now checks three-way liveness (the reminder tick's rule) before
building state, and drops such a job.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): drop is generation-matched; inapplicable sets are not asked (TASK-3117)

Codex round 2. P1: DropDecisionJob deleted the row whatever its
generation, so a write that landed while a run was hitting its retry
limit (or a gone item) was lost; it now deletes only the claimed
generation and otherwise releases, as CompleteDecisionJob does. P2: a
job owed before a move still named the old collection's scoped set;
Evaluate re-checks applicability against the item's current collection
and the job is dropped.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(store): idempotency counts only the asked question keys (TASK-3117)

Codex round 3. The completeness check counted every stored key at the
state, so a RENAMED key kept the total equal and the set read complete;
the new key was never asked at an unchanged state. The comment claiming
over-counting was only possible in the safe direction was wrong and is
replaced.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): claim one job at a time; restore enqueues; recheck before ask (TASK-3117)

Codex round 4. Claiming a whole pass up front let the last job wait out
its lease behind earlier provider calls (worst case ~330s per job) and be
re-claimed elsewhere; jobs are now claimed one at a time and the lease
comment carries the arithmetic. Restore is a door: a job dropped while
the item was deleted left it stale with nothing scheduled. Liveness is
re-checked as the last statement before the send, with the residual
window stated. Collection rename / field migration not enqueuing is the
ruled fan-out gap and stays.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): hold jobs of a soft-deleted workspace for its restore (TASK-3117)

Codex round 5: a job processed while its workspace was soft-deleted was
dropped, and a workspace restore (a fan-out, so it enqueues nothing)
never brought the work back. Such jobs are now held: the claim scan
skips them and they resume on restore; a purge cascades them away.
Collections have no restore path and still drop. Liveness is one guard,
the last statement before the send; the earlier copy in State was
redundant (mutant M26 survived its removal) and is removed.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): answers are keyed on question+model; outages never drop work (TASK-3117)

Codex round 6. An answer is now current only for the question definition
and model that produced it (question_hash, per key): rewording a question
or re-pinning the model at an unchanged item state re-asks. Jobs are
dropped only after repeated PERMANENT refusals (a 4xx other than 429);
transient failures back off to an hourly cap and are never dropped, so a
provider outage loses no owed evaluation. Adds the RunOnce-level test for
a workspace deleted mid-job (mutant M35 survived without it). The empty
production registry is the contract ("ships the rail with an empty
registry"), not a defect.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* docs(store): name workspace import as a non-door for decisions (TASK-3117)

Codex round 7. Import is the one item-creation path that bypasses
createItemTxWithID; it stays off the decision queue for the same
fan-out reason SPEC-3 keeps it off events/1. Named at the site and in
CLAUDE.md, and raised to the lead as an open policy question.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 20:46:41 -04:00
xarmian b7e0d6ec05 feat(cli): item update reports the body outcome on its success line; show flags a stale body (TASK-3132) (#1416)
* feat(cli): item update's success line carries the body outcome; show's table output flags a stale body (TASK-3132)

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(cli): TASK-3132 round 1 — "may" not "will" for an unguaranteed flush; byte, empty-stale and json/agent legs

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(cli): TASK-3132 — the body outcome is the LAST stdout line, so it survives | tail -1 past the field summary

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 19:53:23 -04:00
xarmian 98213b0334 fix(cli): refuse a blank --stdin body on item create/update; add --clear-content (BUG-3100) (#1415)
Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 18:52:28 -04:00
xarmian 096584c349 feat(decision): pluggable typed-decision provider with a typesafe.ai backend (TASK-3116) (#1411)
* feat(decision): pluggable typed-decision provider with a typesafe.ai backend

Unit 1 of PLAN-3114 (TASK-3116). Foundation only — no product behaviour, no
storage, no runner, no call sites.

internal/decision exposes three primitives (Choice, Score, Noul) answered in
one round trip, a pinned-model typesafe.ai backend, and one configuration
resolver. Unconfigured returns a nil Provider and no error, so every later
caller branches on nil and behaves exactly as today.

Three wire-shape facts are pinned to measurements rather than to the vendor
docs, which are silent or wrong on each. Receipts are on TASK-3116's trail.

- A Noul answer carries NO confidence and NO probabilities. Confidence is
  therefore a *float64: 0.0 is not a neutral stand-in for absent, it is the
  most extreme value the scale has, and a caller following the vendor's own
  recommended banding would read every Noul answer as minimum confidence and
  route all of them to a human.

- Score is the probability-weighted MEAN over level indices, not a level
  index. Measured: probabilities {"0":0.86,"1":0.14} returns score 0.14.
  Answer.TopLevel() is the correct way to ask which level was picked;
  int-casting Score gives the wrong level for any distribution whose mass
  does not sit on index 0. Score's request criteria is an ordered ARRAY and
  its probability keys are stringified level INDICES, where Choice's are
  option NAMES — same field, two vocabularies, decided by Kind.

- An over-budget request is refused with HTTP 400 and
  {"detail":{"error_type":"max_tokens_exceeded"}}, not the 422 the task
  contract predicted. It is matchable with errors.Is(err,
  ErrMaxTokensExceeded) rather than by matching prose. 422 keeps its own
  passthrough, with the body verbatim because the vendor's validation
  messages name the offending question.

The token budget is measured, not cited: bisecting on the API's own
usage.input_tokens, 32,476 tokens was accepted and ~33,600 refused, and the
budget covers state AND questions together. maxRequestTokens is 32,000, under
the measured accepted figure. Truncation is a conservative char-based bound
and says so — it cannot be a guarantee for pathological input, which is why
the provider's own refusal is surfaced as a typed error instead of swallowed.

Config resolution has exactly ONE reader so that TASK-3121's instance-admin
setting adds a SOURCE rather than a second reader. The environment is passed
last because it overrides the admin setting (ruled day 73). That is why
ProviderNone exists: with an empty value meaning "this source has no
opinion", an environment that wants to disable what an admin enabled must say
so with a value. internal/config carries the config-FILE fields only and
documents why its env block deliberately does not read these three variables.

`pad server info` reports config.decision_provider as {name, model}, always an
object with name "none" when unconfigured rather than alternating between an
object and a bare string. It sits under `config` because that is what it
describes — this host's resolved settings; that command does not inspect
remote server internals, so against a remote server it is the CLI host's view.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z

* test(decision): sweep the truncation cut offset; pin the measured constants; add a live-provider leg

Three additions, each closing a hole an instrument found rather than one I
reasoned my way to.

The rune-boundary assertion moves out of the truncation test and into
TestFitStateCutsOnRuneBoundaries, which sweeps the cut OFFSET by varying the
questions' size. A mutant deleting the rune-boundary loop entirely SURVIVED the
original single-offset assertion: that one offset happened to land on a
boundary, so the green could not go red. Eight offsets over two-byte runes now
land mid-rune several times and the mutant dies.

TestBudgetConstantsStayInsideTheMeasurement guards maxRequestTokens and
charsPerToken, whose correctness is a fact about the live provider that no
behavioural test can check. Without it, raising either to "use more of the
window" gives a green suite and a production 400.

TestLiveProviderAllThreePrimitives calls the real API, skipped unless
DECISION_LIVE=1, so CI and `make test` never reach it. It exists because the
httptest fake cannot answer its question: the fake replays bodies I wrote and
asserts shapes I chose, so an encoder and a fake sharing the same wrong field
name agree with each other and both pass. Run live, it confirms choice
probabilities are keyed by option name, score's legend echoes the levels sent,
score really is the probability-weighted mean of the level indices (asserted
against the live numbers, not a recording), usage.input_tokens decodes, and —
the assertion the whole pointer design rests on — a noul answer carries NO
confidence on the live wire.

Mutation matrix, after re-expressing the two mutants that did not compile
(a BUILD-FAIL is not a kill): 21 mutants, 21 DETECTED, each by its own NAMED
expected test, against a baseline stated green first. Harness restores by file
copy and verifies the restore before the next mutant.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z

* wip(decision): round-1 review fixes — six findings fixed, matrix not yet re-run

WIP stand-down commit (Dave, day 73: account at its usage limit). Not pushed,
no PR. Gates and the mutation matrix have NOT been re-run against these fixes.

Six P2 findings from review round 1 (backend: opencode/gpt-5.6-luna), each
confirmed mechanically before accepting:

1. The backoff ignored context cancellation, so a cancelled Ask waited out the
   full wait before a doomed retry. The sleep hook now takes a context and
   returns its error; production installs ctxSleep (timer vs ctx.Done()).
2. A body read that failed partway was discarded, so a caller could not tell an
   absent detail.error_type from an unread one. APIError.BodyReadErr keeps it.
3. Exhausted retries wrapped a *transientError, which is not an *APIError, so
   errors.As could not reach the status. Verified false in a standalone
   program; transientError now has Unwrap.
4. Truncation was sized on RAW string length while encoding/json escapes <, >
   and & to six-byte sequences — measured expansion exactly 6.00x — so a state
   of '<' passed the budget check and then drew max_tokens_exceeded. The cut is
   now sized on marshalled length, shrinking by the observed expansion ratio.
5. An answer whose kind differed from its question's was accepted, and fromWire
   then left the requested kind's member at a plausible zero: a noul question
   answered with a score read as Noul == 0.0, a confident "false" the provider
   never said. Ask now refuses a kind mismatch.
6. TopLevel ordered only on the parsed index, so distinct keys sharing an index
   ("1" and "01") or several unparseable keys sharing the sentinel were
   tie-broken by randomised map iteration order. The sort is now a total order.

Two things found while fixing, not by the reviewer:
- runeBoundaryAtOrBefore indexed s[len(s)] and would panic; it returns early now.
- The first cancellation test never reached the backoff it claimed to test:
  cancelling during the response makes http.Client.Do fail, so Ask returned
  from the transport branch having never slept. Split into two instruments —
  one pinning that Ask honours an interrupted wait, one pinning that the wait
  installed in production is interruptible.

State: internal/decision tests pass (go test exit 0, run before the stand-down).
NOT done: re-running make test / lint / test-pg, and the 29-mutant matrix, which
was killed mid-run. It had mutated typesafe.go in place; the tree was restored
from the harness backup and all eight fix sites verified present before this
commit.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z

* test(decision): make two mutants die by their own tests

M17 (missing-answer check removed) survived: the answer-kind check also
refuses a missing answer, since it decodes with an empty Kind, and also
names the key — so the test could not tell which check fired. It now pins
the "no answer" diagnosis, which is the difference a caller sees.

M23 (uncancellable ctxSleep) was only caught by go test's global timeout,
which panics the whole binary after 90s. The newTypesafe leg now runs
under its own 2s deadline and fails as a named test.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): review round 2 — check every answer against its question

Round 1 added the answer KIND check; round 2 found the rest of that
population. An answer is now refused if it is missing a member its kind
requires, names a choice or a level key that was not sent (including
non-canonical keys like "01"), or carries a score, noul, confidence or
probability outside its range. Pointers on the wire answer make an absent
member distinguishable from a zero one. Unsolicited extra answers are
dropped: the result is one entry per question key.

Also:
- The envelope reservation is the marshalled request with an empty state,
  not a fixed 512 bytes, so a long model name cannot push a fitted request
  over budget; the first cut leaves room for the quotes, so an unescaped
  state fits exactly rather than 5% short.
- APIError unwraps to BodyReadErr, so errors.Is sees a partial read.
- server info: a test through collectServerInfo and printServerInfo, the
  two doors the direct-call tests could not see.
- Prose made true: APIError's doc (it is also reachable through the retry
  wrapper, and printing the body is measured safe), fitState and
  marshalledLen (bytes are a heuristic for tokens, not a bound), Legend,
  TopLevel on hand-built answers, and "one round trip".

Declined, reasons on TASK-3116's trail: key-character limits (a live probe
accepted "q/na me\n" as a key); a score-level ceiling (none documented,
none measured — the provider's own refusal still surfaces); body-in-Error()
leaking state (three refusal shapes probed, none echoes the request); and
Close errors on an already-read body.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* test(decision): widen the rune-boundary sweep to 64 offsets x three rune widths

The round-2 quote-room change made a mid-rune first cut fail the size
check on its own (json.Marshal turns the lone lead byte into a six-byte
�), so the 8-offset sweep stopped catching a deleted boundary loop.
Measured with the loop deleted: invalid UTF-8 escaped at 18/64, 29/64 and
43/64 offsets for 2-, 3- and 4-byte runes; the old sweep caught none.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): review round 3 — no empty state below two bytes of room

With 0 or 1 bytes left for the state, fitState returned "" — which still
marshals to two bytes, so the request went one or two over budget. Below
two bytes it now refuses, the same as below zero. A sweep walks the room
across that edge one byte at a time and holds the budget at every step.

Also: runeBoundaryAtOrBefore's doc no longer claims to produce valid UTF-8
from invalid input (json.Marshal rewrites invalid bytes on any send, cut or
not, and marshalledLen measures the rewritten form); the server-info test
prints a report whose values the environment does not hold, so a printer
that re-resolved the environment would fail.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): review round 4 — the two-byte refusal is for string states only

Round 3 put the "no room below two bytes" refusal before the string/
structured split, so a structured state as small as `0` (one byte) was
refused although it fits, and the message claimed the questions alone
exceeded the budget, which was not true at 0 or 1 bytes left. It now sits
after the split, applies only to a string that does not fit, and says what
it means. The quote-edge sweep gains a structured leg.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 17:31:25 -04:00
xarmian 4abc8ad6c3 mcp+cli: ToolSurfaceVersion 0.38 for claim/release; refuse sub-second lease ttl on both transports (#1405)
Follow-up to #1238. Review: two CONVE-735 2a rounds (backend: claude-agent), both CLEAN; trail on docapp HT-2860.
2026-09-18 21:47:38 -04:00
David Barkhausen 828c9ba2cf feat(items): execution lease — atomic claim/checkout with expiry (#1238)
Implements #1221 (IDEA-2815). Contributor: b4rk13. Closes #1221.
2026-09-18 12:25:38 -04:00
xarmian cc1b917b5c test(cli): pin agents dispatcher commands against cobra tree (#1392)
* test(cli): pin agents dispatcher commands against cobra tree (TASK-3096)

* test(cli): harden dispatcher drift pin against false passes (TASK-3096)

dispatcherSplit is strict: unterminated quotes and trailing
backslashes fail instead of silently tokenizing. Placeholders are
stripped only from trailing args after Find resolution, plus a
re-resolution check so a placeholder interleaved in the command
path fails instead of vanishing.

* test(cli): close dispatcher drift-pin false passes, pre-path flags plus fence count (TASK-3096)

* test(cli): reject emptied dispatcher bash blocks per-block, add tokenizer and placeholder-path negative pins (TASK-3096)

* test(cli): reject typo'd dispatcher subcommands via unconsumed-trailing-token check (TASK-3096)

checkDispatcherLine accepted any Find target except root, so
'pad item commment TASK-5' false-passed as 'pad item'. Fail on
non-placeholder, non-flag, non-flag-value trailing tokens when the
resolved target is a command group; leaf positionals keep passing.
Adds TestDispatcherLineRejectsTypoSubcommand negative pin. P2
shell-operator handling stays deferred.

* test(cli): reject placeholder-on-group dispatcher lines, pin leaf placeholders pass (TASK-3096)

* test(cli): pin dispatcher command inventory per block, prove deletion/substitution fails (TASK-3096)

* test(cli): pin inline dispatcher prose commands, prove typo fails (TASK-3096)

---------

Co-authored-by: Hermes Agent <hermes@localhost>
2026-09-17 00:33:37 -04:00
Matt Faltyn dba74e8bc0 Fix macOS session test portability
Signed-off-by: Matt Faltyn <faltyn.matthew@gmail.com>
2026-09-16 12:00:11 -04:00
Matt Faltyn a2e75ec511 fix(mcp): preserve compact results in Cursor 2026-09-16 12:00:11 -04:00
Matt Faltyn 6b9eafde2c feat(mcp): add structured-only results 2026-09-16 12:00:11 -04:00
Matt Faltyn 07c00e008e feat(agent): add compact item view 2026-09-16 12:00:11 -04:00
Matt Faltyn 8e5b43ded5 fix(agent): align compact dispatcher guidance 2026-09-16 12:00:11 -04:00
Matt Faltyn 328e0a4706 feat(agent): add on-demand guide 2026-09-16 12:00:11 -04:00
xarmian 44ba666b5c fix(schema): a grandfathered reserved-key FieldDef never reaches an item-field path (BUG-2685) (#1377)
`validateNoReservedFieldKeys` (BUG-2674) stops a collection schema NEWLY
declaring `implementation_notes` / `decision_log` / `github_pr` / `convention`,
but deliberately grandfathers an existing declaration — so the shape survives in
any workspace that had it before that gate landed, and every path that reasons
about fields BY CONSULTING A SCHEMA then meets a FieldDef it should not honour.

Such a declaration is wrong in both directions at once, which is what rules out
refusing the write:

  - it REFUSES the system's own value. `pad item note` / `pad item decide`
    append an entry and PATCH the whole fields blob back; a legacy
    `implementation_notes: text` fails that ARRAY on validateFieldType, so the
    collection's items cannot be annotated at all.
  - it ACCEPTS a caller's junk. The same declaration waves a bare string
    through, where the append helpers then refuse to touch it (BUG-2627 part 3).

And a legacy `required` declaration refuses ordinary creates over a field no
client surface offers.

ONE PREDICATE, AT THE DECODE. A type-resolved pass over the tree counted 76
sites that consult a `CollectionSchema.Fields` and only 38 that DECODE one. A
rule applied at the consultations is 76 patches that every future consumer has
to remember, which is the shape of the four review rounds this bug was filed out
of — each found another site. Every consultation inherits whatever the decode
produced, so `models.UnmarshalItemFieldSchema` strips reserved FieldDefs there,
and 23 item-field decode sites now call it. 9 move/copy decodes already strip at
the point of use via `items.SchemaForMigratedFields` (whose implementation now
delegates to the same strip, so there is one). 6 keep the raw schema because the
collection DEFINITION is the subject — notably the grandfather test's own
`prevSchema`, which stripped would reclassify every existing declaration as
newly introduced and refuse every update to such a collection.

AND AN ENFORCEMENT, TYPE-RESOLVED. A predicate nothing enforces is a convention
the next consumer has to remember. `TestReservedFieldDecodeSitesAreClassified`
walks the tree with go/packages and requires every raw decode into a
SCHEMA-SHAPED type to be on an allow-list WITH A REASON; a sibling test rejects
dead entries, and a third pins the site COUNT so the guard cannot go blind while
passing. A destination counts by SHAPE — a struct with a `json:"fields"` member
that is a slice of structs — not by name, which is what catches `bootstrapSchema`
for what it IS rather than through a registry of mirrors.

The type checker was earned, not chosen. The first guard approximated types by
reading declarations with go/ast, and FIVE consecutive review rounds each named
more DESTINATION SPELLINGS it could not see: a bare pointer parameter; an
inferred-type var; new(T); an aliased models import, `var x = new(T)`,
`dst := &s`, an inline element type; a type alias, `dst := helper()`, a closure
parameter. Fixing them one at a time was measuring how many spellings a reviewer
could think of rather than whether the guard was sound (CONVE-35). go/packages
answers the question the approximation was approximating, including for the
spellings nobody has thought of yet.

Cost, measured rather than predicted: `go mod tidy` moves golang.org/x/tools
from go.mod's indirect block to its direct one and leaves go.sum BYTE-IDENTICAL
— the module set does not change, so nix/package.nix's vendorHash is unaffected.
x/tools was already in the graph with its full module zip hash in go.sum.

The 17-spelling fixture under internal/models/testdata/decodeshapes outlived the
guard it was written for and is kept as a REGRESSION TEST on the resolver: it
fails if a future change to the shape test, the callee test, or the
destination-argument rule narrows what can be resolved. It earned its keep
immediately — it caught that a Go type ALIAS materialises as *types.Alias and
walked straight past an unwrap loop that handled only Pointer and Named.

The guard skips VENDORED paths, and that is not a tidiness rule. It passed
locally and FAILED the Nix job, which builds from a `vendor/` tree that `./...`
then walks: 57 sites instead of 20, every extra one a dependency's own decode
caught by the interface arm. A guard whose verdict depends on whether the
checkout happens to be vendored is not a guard. Reproduced locally with
`go mod vendor` before the fix and re-run after, rather than inferred from the
CI log.

Out of scope, stated rather than implied: a full `fields` blob reaching item
CREATE is still a mint site for a hand-written reserved key (undeclared keys are
ACCEPTED — BUG-2850 chose accept-and-name after a census found 168 live values
under 14 such keys). That is a different class, whose remedy is refusal and
whose risk is the system writers; a test pins that this change does not silently
alter it. Collapsing the now-idempotent `SchemaForMigratedFields` calls is left
alone too — the copy preflight distinguishes the raw source schema from the
stripped one in its reporting, so that is a second change with its own surface.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-15 00:50:12 -04:00
xarmian 7dc4931323 fix(db): a finished SQLite→Postgres migration marks its source so the abandoned file refuses writes (BUG-3072, BUG-3077) (#1361)
* probe(BUG-3072): measure SQLite auto-reprepare against a deferred append

Not a keeper — the three legs become the proving tests.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* wip(BUG-3072): snapshot + marker + refusal triggers, with BUG-3077 folded in

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs(BUG-3072): sweep the prose the snapshot and the marker falsified (CONVE-23)

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(BUG-3072): correct the snapshot justification to what was measured

The design was argued on pooled reads diverging under a held transaction.
Measured: they do not — SQLite has one write lock, so while BEGIN IMMEDIATE
is held nothing else commits and a pooled read sees what the tx sees. The
*Q threading stays, on the narrower and true grounds, and the property the
argument now rests on is pinned by a test.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(BUG-3072): cover the two marker arms nothing else reached

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(BUG-3072): measure the outcome of a failed commit instead of asserting it

codex round 2. Every other failure arm can honestly promise the source is
unchanged because a rollback undoes the work; the commit arm cannot, and it
was claiming so anyway. It now reads the marker on the pool and distinguishes
landed / not-landed / genuinely-unknown.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-14 08:51:45 -04:00
xarmian 5e869b285e fix(export): mark bundle bodies that were stale when written, and refuse the migration that would abandon them (BUG-3032) (#1356)
* wip(items): mark bundle items whose body is behind the live doc (BUG-3032 doors 1-2)

* wip: doors 4 and 6 (BUG-3032)

* wip: store test for the gate predicate (BUG-3032)

* wip: door 3 import stale-body count + tests (BUG-3032)

* docs(backup): the migration refuses on unflushed collab edits; bundles mark stale bodies (BUG-3032)

* docs: discharge BUG-3032's open-decision conditional in the artifact door and the population guard

* fix(export): the migration gate's population cannot diverge from the bundle's (BUG-3032)

* docs(claude): Go build caches never go under /tmp — it is a tmpfs, so it is RAM

* wip: codex round 1 P1 + P2 fixes (BUG-3032)

* fix(db): refuse a migration whose bundle went stale after the pre-flight check (BUG-3032, codex r1 P1); print the import's stale-body count (P2)

* fix(db): refuse to migrate while a server can append, and parse the stale-body header (BUG-3032, codex r2 P1+P2)

* docs: narrow the running-server claim to what the probe measures (BUG-3032)

* wip: codex r3 fixes + web parity test (BUG-3032)

* fix(db): fail closed when the running-server check cannot run; match Go's header parsing on the web (BUG-3032, codex r3)

* fix(cli): the Go and web readers of the stale-body header accept exactly the same values (BUG-3032, codex r4)

* docs: correct two comment claims about integer representability and Atoi's leniency (BUG-3032, codex r5 nits)
2026-09-14 01:45:30 -04:00
xarmian d2973b24d7 feat(items): expected_seq — an OCC token that can tell two same-second writes apart (BUG-3037) (#1351)
* feat(items): expected_seq — the strong OCC token (BUG-3037)

* test(items): the same-second race, with each token (BUG-3037)

* test(cli): the summary shape carries the seq token (BUG-3037, matrix hole)

* test(web): the pane sends the seq token, with the cached-row arm (BUG-3037)

* test(web): the OCC retry re-reads the token through tokenOf (BUG-3037, matrix hole)

* docs: CLAUDE.md carries the v0.34 tool-surface entry (BUG-3037)

* test(e2e): the pane's field write is guarded by seq (BUG-3037)

* fix(mcp,webmcp): forward the OCC token and refuse a fractional seq (codex round 1)

* test(items): the reproduction no longer races the wall clock (BUG-3037)

* fix(items): seq decides when both OCC tokens are sent (codex round 2)

* fix(cli): --expected-seq is sent by presence, so an explicit 0 is not dropped (codex round 3)

* fix(cli): render a seq conflict with the token the caller sent (codex round 4)

* docs(mcp): agent instructions recommend expected_seq (codex round 5)
2026-09-13 22:38:15 -04:00
xarmian 70e3659526 fix(items): twelve item-field writers send a patch instead of replacing the blob (BUG-3049 / BUG-3055) (#1349)
* wip(items): convert full-blob item writers to fields_patch (BUG-3049)

* test(items): interleaving tests for the CLI and bulk doors (BUG-3049)

* test(webmcp): pad_item update merges instead of replacing (BUG-3055)

* test(web): per-door guards for the browser full-blob writers (BUG-3049)

* docs(web): correct the BUG-2273 note the patch conversion falsified (BUG-3049)

* style: gofmt the new server test (BUG-3049)

* test(webmcp): repoint the dispatch update leg at the patch contract (BUG-3055)

* test(e2e): status move preserves a concurrently written field (BUG-3049)

* test(e2e): hold the stale-copy window open with the stream blocked (BUG-3049)

* test(webmcp): label the non-detecting leg as such (codex round 2)

* fix(items): refuse an unreadable fields snapshot in the bulk door (codex round 3)

* fix(items): re-decide auto-populated dates under the write lock (codex round 4)

* docs(types): ItemUpdate.fields is a full replace; updates use fields_patch (BUG-3049)
2026-09-13 21:14:57 -04:00
xarmian 80e32b4e0a fix(items): eleven hand-built doors carry the stale-body marker (BUG-3033) (#1344)
* docs: make test-pg is not safe against itself in one worktree (BUG-3033)

CLAUDE.md said the target is safe across worktrees, which is true and
incomplete. TEST_PG_PROJECT is basename + cksum of the absolute path, so
two overlapping runs in ONE worktree share a compose project: the second
`up -d --wait` attaches to the first run's container, and whichever run
finishes first executes the unconditional `down -v` and destroys the
other's database mid-test.

Measured on the BUG-3000 trail, where the survivor's connection-refused
failures were first read as evidence about the code. The note also records
that a PASSING run carries ~41 redis connection-refused lines, so that
noise is not the diagnostic — the failure text is.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): six hand-built doors carry the stale-body marker (BUG-3033)

BUG-3000 put content_state on models.Item, so every door that serialises
the struct inherits it. These build their own shape and inherit nothing:

  - PlaybookRunResponse.Body (handlers_playbooks.go)
  - AgentBootstrapConvention.Content (handlers_bootstrap.go)
  - BootstrapIncludeItem.Content, bodies mode (handlers_bootstrap.go)
  - the artifact export's markdown+YAML (handlers_artifact_export.go)
  - the MCP item resource's composed markdown (mcp/resources.go)
  - `pad playbook show` / `pad playbook run`, which decode narrow structs
    of their own and print the body

The last two are in the unit because an agent reads what the CLI prints,
not the wire: a marker the server sets and the CLI drops is a fix its
reader never sees — the same gap this bug describes for the MCP resource,
whose fetcher reads stdout and cannot surface a successful command's
stderr.

BootstrapIncludeItem is a fifth server door the bug does not name, found
by this unit's sweep and folded in under CONVE-18 rather than filed: same
file, same handler, same shape as the conventions door beside it.

The artifact export is a portable format, so the marker goes into the
existing provenance block as an additive omitempty key with NO
format_version bump. Decode uses a plain yaml.Unmarshal with no
KnownFields, so an older binary ignores the key; format_version is
compared with exact equality, so a bump would reject artifacts in both
directions for an added optional key. A current item's bytes are
unchanged. It signals rather than refuses; BUG-3032 owns the
signal/refuse/accept decision for the bundle format, and an additive key
forecloses neither of the other two.

Every marker is emitted only where a real body is — the includes site
sets it inside the same wantBodies guard that sets Content — so a
metadata-only projection cannot claim its absent body is stale.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): both-directions coverage for the six BUG-3033 doors

One test per door, both directions on one item, ABSENCE ASSERTED FIRST.
The order is the instrument: a marker stamped unconditionally satisfies
every positive assertion in these files, so a presence leg proves
something only after the same door has been shown to stay silent about a
current row.

Assertions are on the raw wire form — a decoded map, the exported bytes,
the rendered markdown — rather than on a Go struct, because a struct
round-trip passes even when the field is never serialised. That is
exactly the failure this bug is: a value computed correctly in the store
and dropped by a projection on the way out.

Four legs beyond the plain both-directions pair:

  - PlaybookShowResponse inherits the marker through an embed nobody
    tested. Pinned, because a refactor to an explicit projection — the
    shape BootstrapCollection and BootstrapRole already took for size —
    would drop it silently.
  - A metadata-mode include must carry NO marker even when its item is
    stale, since it serves no body; the premise (that the group really
    withheld the body) is asserted before the conclusion.
  - A current artifact must not merely lack the value but lack the KEY,
    and a marked one must differ by exactly one line and still decode
    with body and fields intact. Those are the compatibility claims made
    testable rather than asserted in a comment.
  - An UNRECOGNISED content_state must not render as this unit's
    specific claim about a live editor. The vocabulary is shared with
    BUG-2995's content_outcome and is expected to grow.

The CLI and MCP legs drive the command and the resource rather than the
helper and the formatter (CONVE-19): a direct call vouches for the
function while leaving the narrow decode struct — where the defect
actually lives — free to omit the key.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs: the read-side content_state marker (BUG-3033, CONVE-23 sweep)

Three consumer-facing artifacts described the write-side warning and
said nothing about its read-side counterpart, so an agent reading them
would still conclude a stale read is silent. That was already true
before this change — BUG-3000 shipped the marker without documenting it
outside the field's own doc comment — and adding six more doors is what
makes it worth closing rather than inheriting.

  - CLAUDE.md gains a read-responses bullet beside the three write
    warnings: what the value means, that it is emitted only where a real
    body is, that the window is UNBOUNDED because nothing server-side
    flushes, and that the correct response is re-read later, never
    re-send.
  - internal/mcp/instructions.md had a sentence saying a get before the
    flush "answers with the PREVIOUS content" — true and now incomplete,
    since the get says so itself. It also gains the case an agent cannot
    infer from its own write: someone else's tab holds the edits and you
    wrote nothing. pad_playbook's entry says a marked body may be
    superseded, and that this is a reason to re-read, not to refuse to
    run.
  - internal/mcp/version.go gains a POST-0.31 NO BUMP entry. The
    disposition is BUG-2304's, argued at length in the BUG-2995 entry
    above it: nothing is dropped or retyped, and a current item renders
    exactly as before. The item resource is the half worth naming — a
    text resource has no additive key, so the marker is a line of
    markdown, and a consumer diffing that text sees a new block when and
    only when the body it is about is stale.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs: contract comments name the marker they now emit (BUG-3033)

formatItemAsMarkdown's doc listed the blocks it composes and this change
added one; the artifact's compatibility claim now cites the instrument
that proves it rather than asserting it (testdata/*.golden.md pin the
encoded bytes exactly and were not touched to land the key).

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(cli): pad item edit warns before seeding $EDITOR from a stale body (BUG-3033)

A seventh door, found by re-sweeping the population, and the only one
whose consequence is not a confusing read. `pad item edit` is a
read-modify-write over the WHOLE body: it seeds the editor from the row
and PATCHes the entire edited text back with no concurrency token. When
the row is behind the live collaborative document, the editor is seeded
with content that predates edits which exist and are durable in the
op-log, and the save replaces them with a version derived from a state
before them — visible to neither the person editing nor the person whose
tab holds them.

Unlike an ordinary lost update this needs only ONE writer plus an open
tab; the other side is the unflushed state of a document nobody is
typing in.

The warning is printed BEFORE the editor launches, which is the only
point at which it can still change what the user does — afterwards it
would be read beside an "Updated TASK-5" line. Its wording differs from
its two siblings for the reason a third one exists at all: the
consequence is that saving destroys something, not that the text is old.

It warns and proceeds. Whether `edit` should refuse, prompt, or send a
DIFF instead of the whole body is a behaviour decision with its own item
(BUG-3035, which also asks whether `item update --stdin` joins any gate
`edit` grows), not something to settle inside a sweep for missing
markers.

Two tests, and the second is the load-bearing one: the gating table
vouches for the helper, and the end-to-end leg vouches for the BINDING —
a fake editor announces itself on its own stderr, which OpenInEditor
wires to os.Stderr, so the ordering claim is an assertion about one
stream rather than about presence. Both mutants (drop the call; move it
after the editor) are killed by that leg alone.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): playbook summaries carry the marker; two tests stop passing wrongly

Codex round 1, three findings, all valid.

P2 — an eighth door, and this unit's population table missed it with the
precedent already in the tree. A playbook `summary` is the body's first
paragraph truncated, so a stale body makes a stale summary, and an agent
ROUTES on that description. BUG-3000 put the marker on
cli.ItemSummary.ContentPreview on exactly this reasoning ("a
content_preview is content"); calling the same bytes a summary does not
change where they came from. Carried on AgentBootstrapPlaybookMeta, which
both the bootstrap blob and the /playbooks endpoint render, and through
both CLI renderers — one stderr line naming the stale refs rather than a
per-row marker, since a per-row marker on a long list is noise and a bare
count sends the reader back to diff it by hand.

Set only when a summary was actually emitted: no summary, no claim. It
also does not mark the other metadata, which comes from the fields blob
that the collaborative document does not hold.

P3 — the edit tests accepted an always-on warning. They supplied only a
stale response, so a call site forced to warn unconditionally passed
them, and the helper's gating table cannot see a wiring error. Now both
directions at the COMMAND boundary, with two premises asserted before any
conclusion: the fake editor ran, and the edited body was really PATCHed.

P3 — the artifact test accepted a corrupted body. It compared line counts
and asserted only that the decoded body was non-empty, so an export that
replaced the body with unrelated text passed. Now the EXACT body on both
exports, plus byte equality after removing the marker line and
normalising exported_at — with a check that the normalisation removed
something, so the comparison cannot pass by stripping both sides.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): backlink + search snippets carry the marker; add the class guard

Codex round 2: two more doors and two test gaps, all valid.

P2 — backlink snippets. models.Backlink.Snippet is a window cut from
the SOURCE item's body, so a stale body makes a stale snippet. This unit's
sweep could not see it: its vantage point was internal/server,
internal/mcp and internal/cli, and this shape is built in the STORE. The
predicate needed a non-default table alias (the backlink queries call
items `s`), so contentStateSQL became contentStateSQLFor(alias) with the
old constant as its "i" case — a function rather than a second copy,
since two hand-maintained spellings of this predicate would be two
chances to drift. The marker describes the SOURCE of each link, never the
target the caller asked about, and a test asserts marking the target
changes nothing.

P2 — `pad item search` and `pad item backlinks` printed body-derived
snippets and dropped the marker the wire already carried. Both now use
one shared renderer with the playbook summaries, so the surfaces cannot
word the same fact three ways.

P3 — the edit test asserted only that a PATCH occurred, though it claimed
to exercise saving; it now asserts the exact content that reached the
server. P3 — the summary test had one stale row, which cannot tell a
correct renderer from one naming every ref or only the first; it now
interleaves stale and current entries, asserts the complete set and
EXACTLY ONE warning line, and covers the bootstrap renderer that had no
test at all.

CONVE-35 — three rounds each found another member of this class, which
means the reviews were measuring my enumeration rather than the code. So
the table is now a test: every site turning an item body into text under
another name is listed with its ruling, keyed on the exact call LINES
with multiplicity, and anything else fails closed.

Its first draft keyed on file+helper and a negative control caught the
hole: a SECOND call site in an already-listed file passed silently — and
handlers_bootstrap.go, which is listed, is exactly where a new projection
would be written. Four controls now run: a new compiling call site goes
red, a broken walk root goes red, a removed site goes red, and comments
mentioning a helper stay green. The guard states what it cannot see — a
door built on a helper it does not know — and its failure message asks
for the helper, not just the site.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): title-only search results drop the body marker; guard parses Go

Codex round 3, six findings, all valid.

P2, and a defect this unit INTRODUCED: Store.Search's direct-ref and
numeric paths set Snippet = Item.Title and clear the body, but kept the
BODY's content_state — so the new CLI renderer announced that a TITLE was
derived from a stale body. Cleared at the source rather than patched in
the renderer, which restores models.Item.ContentState's documented
invariant and fixes every other consumer of those results at the same
time. The FTS path beside it keeps the marker deliberately: there the
snippet IS cut from the body, so the marker still qualifies text the
result serves. A test asserts both directions, because "clear it
everywhere" would delete the signal this unit exists for.

P2 — the enumeration guard was itself fail-open. It passed a one-line
function calling a helper, a helper assigned to a local and called
through it, a comment inside the call expression, and an argument changed
below the opening line of a multiline call, while FAILING on prose and
strings that merely mentioned a helper. Wrong in both directions is the
signature of a scanner not reading the language it claims to read, so it
now parses Go: call expressions matched on the AST, where comments and
line breaks are not facts. A helper NAME used as a value is reported too
and fails closed, since the call through an alias is invisible. All four
of codex's bypasses now go red and both false-positive cases stay green.

P2 — FTSSnippet was missing from the helper list entirely, so four
existing derivation sites were outside the table. Added and ruled; the
stated boundary was understating itself and now says so.

P2 — browser renderers (BacklinksPanel, CommandPalette) drop the marker.
The wire and the TypeScript type now carry it; where a stale badge
belongs in a panel or a palette is a UI decision, filed as BUG-3050 on
the same grounds BUG-3033 was split from BUG-3000.

P3 — cross-workspace backlinks had no coverage: clearing ContentState on
every result of that query left the same-workspace test and the existing
cross-workspace tests all green. P3 — the snippet tests had ONE stale row
while their comment claimed interleaving, the same gap fixed for
summaries one test earlier; a renderer reporting only the first stale
source passed them. Both now use multiple stale rows separated by current
ones, and that mutant dies on all four legs.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): close the guard's helper-named bypass; seven tests stop passing wrongly

Codex round 4, enumeration-prompted. No new Go door — its census of 434
non-test files found the two remaining Go omissions are already filed
(workspace export on BUG-3032, cross-workspace copy attached to it) and
the browser set belongs to BUG-3050. Everything below is in-class.

The guard had a real bypass. A declaration whose own NAME was a helper
got a special traversal that checked CallExprs and returned false, so the
value-use arm never ran inside it: a compiling, callable
`func (p ReviewProjection) PlaybookSummary() string { render :=
contentPreview; return render(p.Content) }` served unmarked stale prose
while the guard passed, and renaming the method made it fail. The arm was
redundant as well as wrong — notAValueUse already marks every
FuncDecl.Name — so it is gone. It also skipped a `build` directory it
never disclosed, which hid a real file at internal/server/build/.

Three doc claims it made were false and are corrected rather than
softened: "anywhere under internal/ or cmd/" (it skips four directories,
now named), "helper values fail closed" (they did not), and that the
browser surface is a pure rendering choice (the share paths do field
copies that discard the marker before any rendering decision). It now
states plainly that its claim is narrow — no UNRULED USE of four helpers
— and must not be cited as proof that every door carries the marker.

Seven mutations survived the tests, all the same species: they asserted
the MARKER without asserting the BODY it qualifies. Replacing a served
body with "WRONG BODY", dropping it entirely, or suppressing stale
summaries and snippets while keeping the warnings all passed. Every door
test now pins the exact text, and the backlink premise finally sees the
snippet it is about — cli.Dim writes to color.Output, so an os.Stdout
swap missed it and an earlier draft recorded that as a limit and asserted
row headings instead, which is precisely what let that mutation live.

Also: the numeric direct-ref path had no coverage (the test queried a
prefixed ref only), backlink source-correlation passed when replaced with
"any op-log row exists anywhere", and the FTS leg never asserted a
snippet was produced. Search's surviving-marker comment claimed it always
qualifies served text; an FTS title match on an empty-bodied item is the
counter-example, and the comment now says so.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-13 18:34:16 -04:00
xarmian d5085bba00 feat(items): multi_relation — an ordered list of references (TASK-2999 / PLAN-2857 U4) (#1342)
* feat(items): multi_relation shape validation, DSL target, and rename propagation (TASK-2999 / PLAN-2857 U4, rows 1/17/18)

The first three rows of U4's population table, chosen because two of them are
the rows the task's own 5-row scope table did not contain and both fail
SILENTLY.

The table came first (posted on TASK-2999). A mechanical enumeration of every
site keyed on the literal "relation" finds 37 decision points in 21 files — 21
Go sites in 9 files, 16 web sites in 12 — against a scope table naming 5. By
CONVE-35 that is a measurement of the enumeration, not a longer to-do list, so
the deliverable under review is the table.

ROW 1 — internal/items/validate.go, `case "multi_relation"`.

Shape only; this package is DB-free, so whether an element names a live item
stays the store's question, exactly as for a scalar relation. Two rules
DIVERGE from the scalar case, ruled day 64 as NEW rules rather than inherited
ones: an empty or whitespace-only element is REFUSED (the scalar resolver skips
such a value), and `[]` is accepted as a shape while `required` is enforced
against RESOLVED elements elsewhere. Skipping is how scalar relation ended up
with three stored spellings for "no target"; inside an ORDERED array it would
also silently change the element count.

There was nothing coherent to inherit, which is BUG-3028, filed with line
references: a required scalar relation is satisfied by "", because
validateFieldType only asserts val.(string) and the resolver then skips the
empty value at relation_referents.go:232-235 and 1504-1507. Its fix is
convergence on U4's rule, deliberately ordered AFTER U4.

ROW 18 — internal/collections/dsl.go.

`multi_relation` joins the relation branch rather than falling to the Options
arm. Cardinality changes how many targets a VALUE names, never how many a FIELD
declares. Left out, `tags:multi_relation` parses happily into Options=["people"]
with no target — the exact unwritable field the v0.31 bump existed to stop the
DSL minting, one type later. Error messages now name the type the caller typed.

ROW 17 — internal/store/collections.go, retargetRelationsInSchemaJSON.

BUG-2873 rename propagation. A multi_relation declares its target the same way,
so a rename that skipped it left the field pointing at a slug that names
nothing: no error, the schema still parses, and every later write refused with
`target_missing` for a rename nobody would connect to it. This function had NO
test coverage before now (verified by grep across internal/**/*_test.go), and
it is the highest-consequence row in the table.

EVERY new test was run against the unfixed code and fails there:

  * dsl.go reverted to `relation` only -> 3 of 4 new DSL tests FAIL
  * the `case "multi_relation"` block deleted  -> all 7 refusal legs FAIL
  * the retarget condition reverted           -> MovesBothRelationTypes FAILS,
    naming the orphaned field

The accept-legs deliberately carry no weight on their own: an unknown field
type falls through validateFieldType with no check, so "accepts an array" stays
green against a tree that has never heard of multi_relation. The refusal legs
are the instrument, and the counterfactual above is what says so.

Three counterfactual legs guard the other direction, where a fix goes too far:
multi_select still takes its options list and gets no Collection; scalar
relation still accepts "" (BUG-3028's territory, not U4's to change here) and
still refuses an array; and a non-relation field carrying a `collection` key is
NOT retargeted, which would otherwise pass every assertion in that test.

Verified by reading rather than assumed: U5 already did U4's index half.
relation_links.go handles `case []any:` with ordinals and its schema matcher
already names multi_relation; migration 065/088 carries `ordinal` explicitly so
U4 needs no ALTER. Two rows there stay OPEN in the table with that stated — the
SQL backfill's array handling, and what a scalar->array retype reindexes.

Gates on this tip: make test 0 FAIL / 30 ok packages, make lint 0 issues,
gofmt clean. web/build generated in this worktree by `npx vite build` so the
Go gates embed this tree (CONVE-34 corollary); no web/src change in this
commit.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(store): multi_relation resolution by extracting the one relation ladder (TASK-2999 / PLAN-2857 U4, rows 2/14/15)

Rows 2, 14 and 15 of U4's population table. The shape of the change is an
EXTRACTION, not a second implementation: `ResolveRelationReferentsQ`'s
per-value UUID→ref→title ladder becomes `resolveRelationValueQ`, and both the
scalar path and the new `resolveMultiRelationField` call it.

One place knows the ladder, one place decides `VisibilityChecked`, one place
tells `ambiguous` from `wrong_collection` from `not_found`. That function holds
two separate rulings on `VisibilityChecked` — one of them a window deliberately
left to BUG-3012 — and a second copy of it would look correct while diverging
the moment either gained a case.

Rows 14 and 15, the two server doors, need no change of their own: they call
the store function and inherit the array handling. Stated rather than left
implicit, because "no diff" and "not covered" look identical in a table.

THREE DECISIONS, none of them visible from the code alone:

  * ORDER AND LENGTH ARE PRESERVED. An unresolvable element stays VERBATIM in
    the output; only resolved elements are rewritten to their canonical id.
    Dropping one would make the stored length depend on which elements were
    malformed, and the ratified model is an ordered list whose identity IS the
    array — the same thing U5's `ordinal` column already commits to.
  * NOTHING IS WRITTEN BACK UNLESS EVERY ELEMENT RESOLVED. The doors turn any
    issue into a refusal, so a partially canonicalised blob is a write the
    caller never asked for on a request that is about to fail.
  * A FIELD WITH NO TARGET COLLECTION REPORTS ONCE FOR THE FIELD, not once per
    element. Every element fails for the identical reason, and N copies bury
    the one thing the caller can fix.

DUPLICATES get a new reason, `duplicate_referent`, with its `Message()` case
and its `RelationIssueReasons()` entry. Detected AFTER resolution, which is why
it cannot be a shape check: the duplicated elements are usually DIFFERENT
STRINGS naming one item — a UUID and a ref, a ref and an exact title — so
nothing that cannot resolve them can see the duplication. The issue names the
SECOND occurrence, the one the caller can remove. Refused rather than silently
de-duplicated: a caller who sent a target twice either erred or thinks
multiplicity means something, and storing one copy answers neither honestly.

THE PARITY GATE DID ITS JOB. `TestCopyPreflightDropReasonsAreRenderedByTheDialog`
went red on `duplicate_referent` the moment the constant existed, and stayed red
until the reason was in the TS type union, in `COPY_DROP_REASONS`, and in the
`MESSAGES` map. That is BUG-2674's failure — a reason shipping and rendering to
a user as a raw enum — prevented instead of repeated.

`copyDropReasons.ts`'s header says "The test cannot catch a reason added to Go
and not added here". True of THAT module's vitest test, and it reads as though
the direction is uncovered: I believed it and was part-way into building a
second instrument before finding the Go gate. A paragraph now names the gate.

MUTANTS — four, each killed by exactly the test that should kill it:

  * multi_relation routing removed  -> all four resolver tests FAIL
  * duplicate check removed         -> DuplicateAfterResolution only
  * write-back made unconditional   -> OneBadElementRefusesAndWritesNothing only
  * output order reversed           -> CanonicalisesEveryElementAndKeepsOrder only

`EmptyArrayIsNotAnIssue` survives all four. Recorded rather than counted: it is
a CONTROL against a resolver that invents an issue for `[]`, not a detector of
any of these.

The risk this commit carries is the extraction moving SCALAR behaviour, and the
U1/U6 suites are the instrument for it: full `internal/store` 0 failures, full
`internal/server` ok in 436s, 0 failures.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
svelte-check 0 errors · copyDropReasons vitest 3 passed.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* refactor(models): IsRelation / IsMultiRelation predicates, and the field-level sites that join them (TASK-2999 / PLAN-2857 U4, rows 8/12/13 + 16's predicate)

Rows 8, 12 and 13 of U4's population table, plus the instrument that makes the
table maintainable.

WHY A PREDICATE AT ALL. The contract for this unit found 37 decision points
keyed on the literal "relation" with NO constant behind any of them, so "which
code decides relation behaviour?" was a question only a string search could
answer — and the string-search answer is wrong the moment a second relation
type exists, which is what this unit adds. `FieldDef.IsRelation()` and
`FieldDef.IsMultiRelation()` make it a symbol search, and `RelationFieldTypes()`
makes the vocabulary enumerable rather than greppable.

The two predicates are deliberately different questions. FIELD-level code asks
IsRelation: does this field have a target collection, should a rename touch it,
is it worth hydrating. VALUE-walking code has to ask IsMultiRelation too,
because the shapes differ — and a single predicate answering both the same way
would make that distinction inexpressible.

WIDENED (field-level, body already shape-agnostic):

  * relationTargetsUnavailable — availability is a property of the FIELD
  * relationTargetSlug         — returns the declared target; cardinality is
                                 not part of that question
  * relationSchemasForWorkspace — which schemas are worth hydrating at all
  * RelationKeysPresent        — presence only; it never reads the value

NOT WIDENED, and this is the more interesting half. `HydrateRelationTargetsQ`
keeps `def.Type != "relation"` with the reason written at the site. Its body
assumes ONE value per field — `perItem[item][key]` holds a single
`relationWant`, and `raw.(string)` skips an array silently — so widening the
predicate there would read as "multi handled" while hydrating nothing. I made
that widening, read the body, and reverted it.

Hydrating a `multi_relation` needs a WIRE-SHAPE decision first: whether
`relation_targets[key]` becomes a list or gains a parallel key. That is a v0.31
contract change, not a predicate edit, so it stays an OPEN row on the table
rather than being smuggled in as a one-liner.

The same trap is why `models.RelationFieldTypes`'s doc comment says explicitly
that the migration and default-resolution sites name their types by hand on
purpose: widening them with IsRelation alone would treat an array as a scalar.

TESTS. The predicates are pinned against every other field type this repo
ships, listed explicitly rather than derived so a new type forces a decision
instead of inheriting one, plus a leg asserting RelationFieldTypes agrees with
IsRelation — two expressions of one fact drift, and nothing else can notice.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
full internal/store ok (157s), internal/items, internal/collections,
internal/models all ok.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(mcp): multi_relation hydration, migrate/default sites, and the v0.32 surface bump (TASK-2999 / PLAN-2857 U4, rows 4-7/9-11)

Closes the rest of U4's Go half. Rows 4-7 (MigrateRelationReferentsQ), 9-10 (the
two default-resolution passes) and 11 (read-side hydration), plus the surface
bump the read shape owes.

ROWS 4-7 AND 9-10 — the four migrate sites and the default passes asked
`_, isStr := raw.(string)`, which is the right question for exactly one of the
two types. Asked of a `multi_relation` it calls every legitimate array
malformed — and since those sites DELETE what they judge malformed, the answer
decides whether a valid value survives. Three small helpers now carry the
type-aware questions:

  * relationValueShapeIsValid — string for `relation`, array of strings for
    `multi_relation`
  * relationValueIsCleared    — the "no referent" form, which for an array is
    `[]` or an array of blanks. The origin split skips a cleared value so it
    lands in no bucket; without this an array the caller deliberately emptied
    would be bucketed as a referent, resolve to nothing, and be reported as a
    drop nobody could act on
  * relationIssueValue        — which value a FIELD-level issue names

`relationIssueValue` returns a SCALAR value UNTRIMMED, deliberately. The call
site it replaced read `raw.(string)` and reported it verbatim, and BUG-2870
settled that a caller's value bytes are carried as sent — so trimming would
quietly change what an existing scalar drop reports, a behaviour change
belonging to no row of this table. The multi case trims per element because it
is CHOOSING which element to name, not reporting one it was handed.

The cross-workspace carry now drops a `multi_relation` once for the whole field,
never per element, because the ruling is that the value drops WHOLE — so an
element count is not something the destination learns and an import cannot
change it.

ROW 11 — hydration, on the lead's ruling: `relation_targets[key]` carries EITHER
the scalar object v0.31 emitted OR a JSON ARRAY of those objects in stored order.
ONE map, not a parallel key: a consumer rendering "what does this field point
at?" should not have to ask two questions and merge them, and the field's type
already says which shape to expect.

`models.RelationTargetSet` holds either shape with a custom marshaller. THE
COMPATIBILITY CLAIM IS THAT A SCALAR ENTRY IS BYTE-IDENTICAL, and the test pins
it against a LITERAL rather than against another call into this code — comparing
it to itself would pass against any self-consistent change, including one that
renamed every key. It unmarshals both shapes too, because the CLI decodes item
JSON the server produced, and a marshaller without a matching unmarshaller is how
a shape change reaches a consumer as a decode error instead of as data.

Position is preserved: every stored element gets an entry, including one that
names nothing, which hydrates ID-only — the same honest shape a dangling scalar
gets. Dropping it would silently shorten the list for exactly the legacy rows
that need explaining, and a consumer could no longer line the list up against
`fields`. Visibility redaction is element-wise: a list with one visible and one
invisible target redacts only the second, since redacting the whole list
withholds what the caller may see and redacting none discloses what they may not.

An EMPTY list marshals as `[]`, distinct from the key being absent: absent means
"nothing hydrated this field", `[]` means "this field holds no references".

v0.32, AND A VERSION COLLISION STATED RATHER THAN RESOLVED SILENTLY. PR #1337
also claims 0.32 and was opened first; `gh pr view 1337` says OPEN and main is
still 0.31, and the standing instruction is to take 0.33 only once it HAS merged.
So this takes 0.32 and flags the contest: whichever merges SECOND owes a
renumber, in the constant and in the entry. 0.33 is free if the reviewer would
rather not have the contest at all.

The bump is owed by the read shape, not by the type. Every value rule here is
reachable only through a field whose schema says `multi_relation`, which no
existing schema can contain — but a strictly-typed client parsing
`relation_targets` will fail to decode an array where it expects an object, the
moment its workspace acquires such a field.

DOCS SWEPT, all three consumers: `internal/mcp/instructions.md` (the agent-facing
contract, with the four rules that are NOT guessable from scalar behaviour),
`README.md` (heading, `tool_surface_version`, changelog sentence), and CLAUDE.md —
which the 0.27 unit missed while sweeping the other two, and which says so in its
own text.

Test adaptations: `scalarHydrated` / `scalarTarget` helpers in the store and
server suites unwrap a scalar entry and FAIL on a list rather than returning a
zero value, because a scalar field hydrating as a list is precisely the
regression this type makes possible and a quiet zero would surface it as a
confusing assertion two lines later.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues (it caught an
unformatted file in internal/cli that would otherwise have been pushed) ·
internal/mcp drift gates green on all three documents.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* test(store): pin the reverse index's array handling, and renumber to v0.33 (TASK-2999 / PLAN-2857 U4, rows 20-22)

Closes the last three rows of U4's Go half — all three as QUESTIONS rather than
work, and the answer in each case is that U5 already built it right. That is not
a reason to leave them unpinned: reading a `case []any:` arm is not the same as
knowing the index holds the rows, and the whole point of the population table is
that a row's state is recorded rather than assumed.

ROW 20 — a `multi_relation` write indexes one row per element with the array
position in `ordinal`. The control is the part worth having: reversing the stored
array must reverse the ordinals, so an extractor emitting a constant 0 cannot
pass by the fixture happening to be sorted its way.

ROW 21 — the BACKFILL covers arrays, not just the write hook. Worth separating
because the two paths reach the index differently (per-write vs every live item
from a clean table) and because migration 065's own comment says a derived query
"on a multi_relation array silently matches nothing", which is easy to misread as
being about this path. It is not — the backfill reaches the same Go extractor via
`relationKeysFromSchemaJSON`, which has matched `multi_relation` since U5.

The test clears the rows by hand AND clears the once-only
`platform_settings` marker first. Both are preconditions without which it proves
nothing: the write hook has already populated those rows, so a backfill that did
nothing at all would pass, and a backfill that reported `Skipped` would too. It
asserts `!res.Skipped` for the same reason.

ROW 22 — a scalar->array RETYPE reindexes against what is STORED, not against
what the new schema says the shape should be. The ruling called a garbage retype
a U4 defect; it is not one, and this records WHY rather than leaving the row open.
The extractor is VALUE-shaped — it switches on the JSON type it finds — so after
a retype the still-scalar blob indexes as one row at ordinal 0, which honestly
describes what the item holds. Indexing by the DECLARED cardinality instead would
invent rows the blob does not contain.

MUTANT: disabling the extractor's `case []any:` arm kills rows 20 and 21. The
retype test SURVIVES it, correctly — its blob is a scalar, so that arm is not on
its path. Stated rather than counted, because a surviving mutant is worth
explaining either way.

VERSION RENUMBERED TO 0.33 on a lead ruling, in all four documents (version.go,
instructions.md, README.md, CLAUDE.md). 0.32 is now RESERVED for PR #1337, which
claims it and was opened first. The reasoning is in the changelog entry: version
numbers are not scarce, and two branches claiming one means whichever merges
second renumbers under merge pressure — which is exactly when a version bump is
least likely to be checked. A gap in the sequence is cheaper than that, so 0.32
may simply be skipped if #1337 is ever abandoned.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
internal/mcp drift gates green on all three versioned documents.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): the type union, the schema editor, and the five field-level web sites (TASK-2999 / PLAN-2857 U4, web rows 1-5 + 11-15)

First batch of U4's web half: the sites where the right answer is "this field
points at other items, cardinality is irrelevant". Value-shaped surfaces — the
item-pane picker, the filter bar, board/list grouping — are NOT in this commit,
because each needs a decision about what a multi-valued answer means and a
predicate widening would paper over that.

`$lib/items/relationFieldTypes.ts` mirrors the Go predicates with the same split:
`isRelationType` for field-level questions, `isMultiRelationType` for value-level
ones, `isResolvableRelation` for the type+target pairing that appears at five
sites. Placed in `lib/items/` rather than under `components/` because
non-component code (`shareView`, `copyNeedsValue`) asks the same questions, and a
predicate living under `components/` invites a second copy for everything that
is not one.

SITES, and what each would have done left alone:

  * `types/index.ts` — the FieldDef union gains `multi_relation`. Without it
    every other change here is a type error.
  * `field-editor-types.ts` FIELD_TYPES — the schema editor can now DECLARE one.
    A type the API accepts and the editor cannot offer is a half-shipped
    surface: the only route in would be the CLI's DSL or a hand-written schema,
    which is a gap with a workaround, not a feature.
  * Create/EditCollectionModal (3 sites) — these preserve `def.collection` only
    when the type matched. Left alone, a `multi_relation` saved with NO target,
    and every later write to it was refused with `target_missing` — silently
    from the user's side, since the editor had shown them the target they
    picked.
  * `shareView.isPublicGroupable` — a public share refuses to group by a
    relation because the payload carries values with no index behind them. A
    `multi_relation` is strictly worse, each value being an ARRAY of ids, so a
    shared view grouped by one would render a group per stringified array.
  * `copyNeedsValue` (2 sites) — the copy dialog's collectable/uncollectable
    split, including IDEA-2899's distinction between naming NO target and naming
    an unavailable one, which had to survive the widening.
  * `[collection]/+page.svelte` (2 sites) — the relation-keys set, and the
    DESTRUCTIVE lane-order guard that would otherwise write item ids into the
    schema as `options`.

TESTS are at two levels on purpose. The predicate is pinned against every other
field type the app ships, listed explicitly so a new type forces a decision. The
SITES are then asserted through their own functions, because a test on the
predicate proves nothing about whether a given site calls it (CONVE-19) — each
with the scalar control that would catch a widening gone too far: `multi_select`
still groupable on a share, a non-relation copy row unaffected, FIELD_TYPES
still carrying what it had.

Counterfactual, run: reverting all three widenings turns 4 of the 8 wiring legs
red. The other 4 are controls and correctly survive.

ONE TEST REPOINTED, and it is worth reading. TASK-2998's source guard on
`handleGroupReorder` read for the literal `type === 'relation'`. Replacing that
with the predicate made the guard STRONGER — it now covers both types — and the
test went red for a spelling change. It now reads for the predicate CALL, plus a
leg requiring the guard to test the REORDERED field (`s.fields[idx].type`) rather
than merely calling the predicate about something. A source guard reading for a
spelling fails whenever the code improves; that is the cost of the technique, and
the comment now says so at the site.

Gates: web 2546 passed / 180 files (2545 before, +1 new file) · svelte-check 0
errors · vite build clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): filtering a multi_relation is MEMBERSHIP (TASK-2999 / PLAN-2857 U4, web rows 8 + 12)

U7 deferred this to U4 in as many words, in both the function's doc comment and a
test, rather than guess: a `multi_relation` stores an ARRAY and no amount of
trimming makes `===` match one, so equality would have returned EMPTY for every
filter, forever, and silently.

Defined now as MEMBERSHIP: an item matches when its list CONTAINS the target. It
is the only reading of "filter by this target" that is true of a list.

Membership, not POSITION. An item whose second reference is Red matches a filter
for Red. Order is part of the stored value — it round-trips, and the reverse index
keeps it in `ordinal` — but it is not part of this question.

Elements are trimmed individually, on the scalar case's own reasoning: the write
path has refused padded values since TASK-2878, so there is nothing to migrate,
only old rows to read correctly. A non-string element is ignored rather than
thrown on; a corrupt blob must not take out the whole view, and an element that
is not a reference cannot match anyway.

`FilterBar` now offers BOTH relation types. Its picker needs no change — it still
returns ONE target id; what differs is the question asked of the stored value.

A U7 TEST WAS REWRITTEN, not deleted. `refuses a non-string, including the
multi_relation ARRAY shape` asserted that an array matched nothing, and explained
that the deferral was deliberate. That assertion now states the OPPOSITE of the
shipped behaviour. It keeps its non-string legs under an honest name, and the
comment records that U4 closed the deferral — the deferral was real and the record
of it belongs somewhere, which is the U4 block the array legs moved into. This is
the shape where a prior unit's prose asserts the absence of the thing you just
built; the prose has to move when the absence does.

Counterfactual, run: removing the membership arm turns 3 of the 6 new legs red.
The other three are the scalar control and the negative cases, which correctly
survive — a change that only ever looked at arrays would pass everything else and
break every existing relation filter, which is what that control is for.

NOT in this commit, and it needs a ruling rather than a guess: GROUPING by a
`multi_relation`. The same "membership" answer would put one item in N lanes,
which breaks the invariant U7 worked to preserve — `bucketByColumn` puts every
item in EXACTLY ONE lane, and U7's proving row asserts it by placing six items and
counting. So grouping is either a different rule (an item appears once per
target, which is a rendering change, not a predicate widening) or a refusal like
the one public shares already give. Raised on the trail; BoardView and ListView
are untouched meanwhile.

Gates: web 2551 passed / 180 files · svelte-check 0 errors · vite build clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): grouping by a multi_relation is REFUSED, with the reason said out loud (TASK-2999 / PLAN-2857 U4, web rows 6 + 9 + 10)

Lead ruling, option (2) of the three I put up: refuse, same disposition a public
share already gives relation grouping, and never fall back silently.

WHY REFUSE rather than render. `bucketByColumn` puts every item in EXACTLY ONE
lane, and U7's proving row asserts that by placing six items and counting. An
item with three references belongs to three. The alternatives were rendering the
item once per target — which leaves a drag between lanes meaning something
undefined (add? move? replace?) — or grouping by the FIRST element, which
silently answers a different question than the user asked. The multi-lane
rendering is IDEA-3034 if it is ever wanted.

`relationGroupingRefusal` returns two distinct reasons, and the distinction is
the point: `no_target` is fixable by editing the schema, `multi_valued` is not.
One message for both would make a distinction the type draws invisible to the
person reading it, so a test asserts the two sentences DIFFER.

THE "NEVER SILENTLY" HALF IS MOST OF THE WORK. Before this, a `multi_relation`
group field fell through both views' scalar predicate, was treated as an ordinary
field, and — having no `options` — put every item in UNCATEGORIZED. An
Uncategorized lane with no explanation reads as "none of these items has a
value", which is false and unactionable.

  * ListView already had the mechanism: `relationWithoutTarget` drops it to
    ungrouped. That flag now derives from the refusal, so it covers both reasons
    instead of one, and a notice renders above the list.
  * BoardView cannot be "ungrouped" — a board IS lanes — so its fallback stays
    the single lane it already produced. What changes is that it says why.

The notice is plain and inline rather than a dismissible toast, deliberately: it
describes a standing property of the view's CONFIGURATION, not an event, so it
has to still be there after a reload.

ROW 6 also lands here: the schema editor's per-field "Relates to" picker now
shows for both relation types. FIELD_TYPES already offered `multi_relation` in
the type dropdown, so without this the editor let a user pick the type and then
gave them no way to say what it points at — every field declared through the UI
would have been refused on its first write with `target_missing`.

ONE THING WORTH RECORDING that needed no change: the board/list GROUP-BY PICKER
(`DisplaySettingsEditor`, fed by `selectFieldKeys`) only ever offered `select` and
`multi_select` fields. It never offered relation fields at all, so "the picker
does not offer a multi_relation" was already true by construction, and U7's
relation grouping is reachable only through a saved view or a hand-set
`board_group_by`. Verified rather than assumed — I went looking for the picker to
change it and found it already correct, which is the more useful half of the
ruling to have checked.

Counterfactual, run: removing the `multi_valued` arm turns exactly the leg that
should go red, red. Two control legs — a relation WITH a target still groups,
every ordinary type still groups — guard the opposite failure, which would
silently un-group every relation board U7 shipped.

Gates: web 2556 passed / 180 files · svelte-check 0 errors · vite build clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): the item-pane picker edits a multi_relation (TASK-2999 / PLAN-2857 U4, web row W7)

W7 is the last of the 29 population rows and the one the rest of U4's web half
exists to make usable: a type the schema editor can declare but no item pane can
EDIT is still half-shipped.

A PARAMETERISATION, not a widening. The four resolution steps — resolve the row,
name its state, format its ref, build its href — were $derived values closing
over the single scalar `value`. They are now functions of ONE raw reference, and
the scalar path calls them with its single element, so there is one
implementation rather than a scalar one and a multi copy that drift. The chip
snippet takes the reference the same way. `relationValues` is the list both types
render from: one element for a `relation`, N for a `multi_relation`, none when
empty.

The 40 existing scalar tests are the instrument that this moved nothing; they
were not touched. Negative control run before trusting them: forcing
`relationHrefFor` to null turns one red, so they do read the parameterised path.

What the list behaviour is, all of it ruled in U4 and none re-litigated here:

  * ORDERED. Chips render in stored order, a choice APPENDS to the end, and
    Remove drops by POSITION — the user pointed at a row, not at an id.
  * `[]` is the one spelling of none. The last Remove writes the empty array, not
    the scalar's `''` (which this type's validator refuses); normalising it to an
    absent key is the write door's job and stays there.
  * DUPLICATES are prevented by not offering them: the picker is handed the
    field's own elements to hide. The exclusion set is the RAW stored elements
    because the write door canonicalises every value to its target's UUID, so a
    stored element IS the id — which also covers the element `localIndex` cannot
    resolve, the one whose target the cold picker can still hand back from the
    server.

A write-side duplicate guard was written and removed. It compared against the
same set the picker is given, so nothing the picker could offer could reach it
and no mutant could kill it — an unfalsifiable branch reading as rigour. The
server refuses a duplicate outright (`RelationTargetDuplicate`) rather than
de-duplicating, so the backstop behind the exclusion is a refusal, not a silent
one-copy write. A resolved-id half of the exclusion set went the same way for the
same reason: under canonicalisation it is the same set, and the mutant that
narrowed it away survived.

Affordances are per-element (Remove) plus one for the list (+ Add). The scalar's
Change/Clear pair does not translate — "Change" would have to mean "replace
everything". The picker's open/closed rule IS the scalar's, unchanged: open when
the field is empty or when asked for, closed once a choice lands.

Instrument: 22 new tests in `FieldEditor.multiRelation.svelte.test.ts`. Run
against the PARENT COMMIT's FieldEditor: 21 fail, and the one that passes is the
scalar CONTROL, which is what a control is for. That run is also why one leg
grew an assertion — "an empty field opens the picker with no Add button" passed
unfixed, because an empty value used to fall into the SCALAR branch, which also
opens a picker and also has no Add button. It now asserts the SHAPE the picker
writes, which is what tells the two branches apart.

Mutation matrix, each mutant compiled and each killed by the test that should
kill it:

| mutant | died in |
|---|---|
| append -> replace | APPENDS + inline-create-appends |
| last Remove writes `''` | the `[]` leg only |
| `excludeIds` dropped | all three duplicate legs |
| element order sorted | the order leg + 5 others |
| blank/non-string elements kept | the dropped-elements leg only |
| chip handler ignores its own row | the pane-open leg only |
| non-array value read as one reference | that leg only |
| empty list no longer auto-opens the picker | both picker-visibility legs |
| multi falls through to the scalar branch | 10 legs |

Two mutants SURVIVED a first pass and are the reason two things above are
smaller than they were: the blanks filter (the titles assertion could not see
extra EMPTY elements — the test now counts rendered units) and the write-side
duplicate guard (unreachable, removed). Both are recorded rather than patched
around.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* test(server): the Q2 round-trip proving test for multi_relation (TASK-2999 / PLAN-2857 U4)

The design row asks for one property end to end: write a `multi_relation` value
as a list of REFS through the write door, read it back hydrated, export the
workspace and re-import it, and assert the stored array is identical and every
element still resolves. Negative leg: one unresolvable element refuses the WHOLE
write, naming that element, never a partial store.

AT THE DOOR, not in internal/store, and that is the point of the row. Every
component underneath already has direct tests — the resolver's canonicalisation
and ordering, the hydrator's list shape, the export remap. Those vouch for the
components and say nothing about whether the doors are BOUND to them: a create
handler that never reaches the resolver, or an import that never rewrites an id
inside an array, passes every one of them. A round trip is a claim about the
seams, which is exactly what no component test can make.

The export/import half is the VERIFICATION the design asked for rather than a
rebuild: `remapFieldIDs` substitutes quoted JSON tokens, so it reaches ids inside
arrays without knowing the schema. Nothing was added there. This is the test that
would go red if someone replaced that substitution with a scalar-shaped walk.

Three targets, not two, and supplied in an order that is neither sorted nor
reverse-sorted by id, ref or title — with two elements a reversed array and a
sorted one are the same observation, and "order preserved" would be satisfied by
a resolver that sorted.

Mutants, each compiled, each killed by the test that should kill it:

| mutant | died in |
|---|---|
| export remap skips values containing an array | the round-trip test |
| resolved array reversed before write-back | the round-trip test |
| a multi_relation hydrates as a SCALAR (first element) | the round-trip test |
| an unresolvable element reported as no issue | the refusal test |

Stated rather than counted, because the difference matters: the "no item was
created" assertion in the refusal leg is a REGRESSION GUARD, not evidence. The
refusal happens before any insert today, so no mutant short of reordering the
handler turns it red and I did not build one. It guards a future
create-then-validate ordering, which the 400 alone would not distinguish. The
comment at the site says so.

The third test is a CONTROL: a door that refused EVERY array satisfies both
assertions of the refusal leg, and this file would then read as proof of a
working feature.

Gates: `make lint` 0 issues · gofmt clean · `internal/server` ok (364s) ·
`internal/store` ok (165s) · `internal/items` ok. All on SQLite — `testServer`
calls `storetest.NewSQLite` unconditionally, so nothing built on it is
Postgres-backed regardless of which make target runs it.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(items): every site that dispatches on field type now knows multi_relation (TASK-2999 / PLAN-2857 U4)

Codex round 1 on the U4 tip returned five findings. All five were real, and
enumerating the class they belong to found NINE. They are one defect wearing
nine costumes: a site that dispatches on FIELD TYPE and was never extended for
the new one. I built the WEB population table for exactly this reason and never
built the GO one.

The instrument is two greps, and the second is load-bearing:
`grep -rn '"relation"' --include=*.go internal/ cmd/` finds 13 sites, but two of
the gaps live in switches that enumerate field TYPES and never mention
`relation` at all, so a relation-literal sweep cannot see them. The class is
"dispatches on field type", not "mentions relation".

## The nine

**Visibility (the P1).** A `multi_relation` element was never visibility-checked.
The store resolver takes `canSee`, but only its TITLE rung consulted it; the
UUID and REF rungs were judged by the SERVER's post-resolution pass, which is
gated on the field type. So an editor with no access to the target collection
could put a hidden item's ref inside an array and receive 201 with its canonical
UUID — the existence oracle U1 exists to close, reopened by the array door.

Per the lead's ruling the check moved INTO the shared per-element resolver,
where both types already resolve, rather than being duplicated in the server
pass — gating a security check on a type was the defect, so the fix is one site
and not two. Order matters there and has its own test: the visibility answer
comes BEFORE the wrong-collection answer, because that reason's message names a
live item and would disclose existence first. `VisibilityChecked` is now set on
both arms, which supersedes the note saying the server re-resolves a ref-derived
`wrong_collection`; that also closes BUG-3012's TOCTOU window for values
reaching this rung, since there is no longer a second lookup to race.

**The empty list** (lead ruling, P17). `validate.go` said normalising `[]` to an
absent key was "the write door's job". No door did it. It now happens at the one
entry every write passes through, in the two spellings the two write shapes
need: a FULL write's `[]` becomes an absent key, and the ordinary required
machinery refuses it for free; a PARTIAL write's `[]` becomes the patch path's
nil deletion sentinel, because deleting the key from a patch would turn "empty
this field" into "do nothing" while reporting success.

**Required** was enforced for scalars only, so a required `multi_relation` had
no enforcement anywhere: the key is deleted after validation has passed, and
nothing re-checks it.

**Defaults**, twice. `ValidateFields` assigns a schema default and skips its own
type check, so the late pass is the ONLY thing between an injected default and
the blob — and it skipped arrays, so a schema defaulting to `42` stored 42, and
one defaulting to refs stored refs. The invisible-default drop skipped them
twice over: once on the type gate and again on a `.(string)` that fails on an
array, which is an accident rather than a decision.

**The CLI could not write the type at all.** Both coercion sites JSON-parse only
`json` and `multi_select`, so `--field 'members=["PEOPL-1"]'` arrived as a raw
string and the validator refused a shape the caller never sent.

**The copy preflight** reported `collection_unavailable: false` for a
multi_relation aimed at a deleted collection, so the dialog offered a picker it
could never fill and explained nothing. Codex did not name this one.

**A refused board ate every drag.** `isRelationGroup` is `type === 'relation'`,
so a multi_relation board took the scalar arm: `currentValue` is the stored
ARRAY, `targetColumn` a lane string, never equal, so every drop fired a write
that would replace the list with a scalar. The server refuses it and the reorder
is silently dropped — while the refusal notice rendered correctly the whole
time, which is what hid it.

## Instruments

25 new tests across four packages, each fix with its own. Mutation matrix, one
mutant per fix, every mutant compiled, every one KILLED by the test that should
kill it:

| mutant | died in |
|---|---|
| visibility block removed | all three visibility legs |
| visibility moved AFTER the collection check | the seen-target control only |
| required gate reverted | the required leg only |
| full-write normalisation removed | both full-write legs |
| partial spelling made a delete | both partial legs |
| late-default gate reverted | all four default legs |
| invisible-default multi branch removed | that leg only |
| preflight gate reverted | that leg only |
| coerce case reverted | the coercion leg |
| CLI case reverted | the CLI leg |
| board refusal gate removed | the board guard |

Controls throughout, because most of these legs assert a refusal and a fix that
refused EVERYTHING would satisfy them: a visible element still resolves, a nil
predicate still means "not asked" rather than "deny", a `wrong_collection` a
caller may see keeps its specific message, a key present before validation is
still the caller's value and not a default, an empty `multi_select` is untouched,
and a scalar relation still takes a bare string at both coercion sites.

Prose swept where this falsified it (CONVE-23): validate.go's arm no longer
claims a door does the normalising, `ValidateFieldsDetailed`'s doc now says it
runs one mutation before its traversal, and `cmd_item.go`'s "all other types …
relation — string is correct" no longer names relation flatly.

## Gates

`make test` 0 FAIL / 30 ok packages · `make lint` 0 issues · gofmt clean ·
`svelte-check` 0 errors · web vitest 2580 passed · `vite build` clean, and
`web/build` regenerated so the Go gates embed this tree. `make test-pg` NOT run
for this commit: no SQL changed, only Go-side type dispatch.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store): three defects in the previous commit's own fixes (TASK-2999 / PLAN-2857 U4, codex round 2)

Round 2 found three, and all three were in round 1's CORRECTIONS rather than in
the original code. That is the shape worth naming: I was reviewing the defect
and trusting the fix.

**A lenient element reader.** My `relationDefaultList` mapped a non-string
element to `""` on the reasoning that one bad element should not make the value
un-listable — and the resolver SKIPS a blank element, so `default: [42]` was
accepted, stored as `[""]`, and reported to nobody. A list is ONE value: an
element nobody can resolve makes the default unusable, exactly as a non-array
does. It is now strict about both non-string and blank elements, which is the
same shape rule `ValidateFields` applies to a caller's array, differing only in
disposition (dropped, not refused).

**An empty default on a required field, deleted in silence.** Validation had
just been satisfied by the PRESENCE of that default; deleting it afterwards with
no issue left a required field absent with nothing to refuse it.
`RequiredRelationIssues` promotes by key, so an issue is the only way a door
hears about it. Reported only when the field is REQUIRED: on an optional one an
empty default is a sensible way to say "starts with nothing", and a warning
there would be a false alarm on a schema doing nothing wrong.

**The order bug, and the sharpest of the three.** Every door captures relation
provenance with `RelationKeysPresent` BEFORE `ValidateFields` runs, so that
function saw the `[]` the new normalisation was about to remove. Counting it as
caller input marked the key "not a default"; validation then injected the schema
default into the hole normalisation had made; and the late-default pass skipped
the key on the strength of that mark. A caller sending `members: []` against
`default: 42` stored 42 — a value nobody sent, through the seam between two
correct-looking passes. `RelationKeysPresent` now reads an empty list as absent,
by the same rule, which is the one-site fix: the key is then a DEFAULT to every
later pass, which is exactly what it became.

Instruments: 5 new tests. Mutants, each killed by the test that should kill it —
lenient elements, blanks tolerated, the required report removed, the empty-list
rule reverted, and the empty-list rule LEAKING onto the scalar type (whose empty
spelling has its own meaning, BUG-3028).

One mutant SURVIVED a first pass and is recorded rather than quietly re-rolled:
"map a non-string element to empty string" is not a faithful negation of the
non-string rule, because its own output then trips the BLANK guard two lines
later and dies for the wrong reason. The faithful mutant stringifies instead —
`42` becomes `"42"`, which is neither blank nor resolvable — and that one is
killed by the non-string leg alone.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store): three more seams the empty-list rule opened (TASK-2999 / PLAN-2857 U4, codex round 3)

Two of round 3's three were again in the previous round's fixes, and they are
the same class as round 1's: a SECOND path that does not share the rule. The
lead's ruling put the empty-list normalisation at the one validation entry every
write passes through; these are the passes that run BESIDE it and had their own
idea of what a value is.

**A cleared list survived a move.** `MigrateRelationReferents` SKIPPED a cleared
relation value so it would be neither refused nor reported — correct for a
scalar `""`, which validation accepts and which is a legal stored spelling. For
a LIST it is not enough: the migrate doors validate AFTER resolving, so an array
of blanks stayed in the map and failed the shape check. Reachable as a
destination-schema DEFAULT, since `ValidateFields` assigns one without
type-checking it — so the same schema default returned 400 on an ordinary move
and was quietly dropped on a create. The list is now DELETED there; the scalar
is untouched, and a control pins that asymmetry.

**The bulk door resolved a default as though it were typed.** `applyBulkOp`
snapshots relation provenance and then validates, which injects defaults; its
relation pass collected "the keys `changes` names, read out of the
post-validation map". Those are different sets the moment normalisation removes
an empty list before the injection: the map then held a DEFAULT under a name the
caller had used. An unresolvable default must be dropped and reported, never
refused — one bad default in a schema would otherwise fail every bulk update
into that collection. It now gates on the snapshot that was already being taken
three lines above.

**Hydration depended on the rest of the batch.** A stored `multi_relation`
holding no resolvable reference got no `relation_targets` entry when read alone,
and an empty list when read beside an item that carried one — identical stored
bytes, two answers, decided by the batch. An absent key says "nothing hydrated
this field" and `[]` says "this field points at nothing"; those are different
statements and a caller cannot be handed either at random. The early return is
gone rather than special-cased: falling through costs nothing (the chunk loop
does not run on an empty id list) and duplicates nothing, where a second render
path is exactly how the two answers would drift apart again.

Instruments: 5 new tests. Mutants, each killed by the test that should kill it —
the cleared list skipped rather than deleted, the deletion LEAKING onto the
scalar spelling, the hydration early return restored, the bulk gate removed, and
the bulk gate widened to skip every relation key. The last two are a pair: one
proves the gate fires, the other proves it still lets a genuinely supplied value
be refused.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean · web
vitest 2580 passed.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* refactor(store): one shape reader composes the next, and the three questions are named (TASK-2999 / PLAN-2857 U4)

CONVE-35's move rather than another review round: rounds 1, 2 and 3 each found a
new member of a class, which measures the enumeration and not the code. The
class rounds 2 and 3 belong to is THREE READERS OF ONE SHAPE — `relationArray-
Elements`, `relationDefaultList` and `relationListShape`, plus a fourth across
the package boundary in `normalizeEmptyRelationLists`. Both rounds found the
wrong one being asked in the wrong place.

`relationDefaultList` was a second parser with the same two cases as the general
reader. It now COMPOSES it and adds exactly one rule — no blank elements —
because that one rule is the entire difference and a line saying so cannot drift
from a copy that repeats the parsing.

`relationListShape` stays separate and that is deliberate, not an oversight: it
must admit an array with non-string elements, since a caller's malformed array is
still a value they supplied and only an EMPTY one is the absent spelling. The
three questions are now written down together where the first reader is defined,
so a fifth reader has to declare its question before it gets written.

Mutants on the composition, both killed: the blank rule dropped, and the shape
result ignored.

Gates: `make lint` 0 issues · `make test` 0 FAIL.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store): a caller's malformed override is refused, a schema's is dropped (TASK-2999 / PLAN-2857 U4, codex round 4)

Both of round 4's findings were in round 3's fix, which is the third round
running where the review found my CORRECTION rather than the original code.

Round 3 added a deletion for a cleared `multi_relation` on the migrate path, and
put it BEFORE the origin split — so it treated a caller's malformed override
exactly like a schema's malformed default. Those get opposite dispositions on
purpose: a value someone ASSERTED is refused, a value nobody asserted is dropped,
and "silently clear the field" is neither.

**`[" "]` is not a cleared field.** It is a list of ONE reference that happens to
be blank, a shape every write door refuses. Counting it as cleared made a move
with `field_overrides: {"members":[" "]}` return 200 with the field absent: the
caller's own malformed value vanished instead of being refused. "Cleared" now
means the EMPTY list and nothing else, which is the same spelling the validators
normalise — the analogy with the scalar `" "` was the mistake.

**A mixed default `[validID, " "]` still 400'd a copy.** The destination-default
bucket used the LENIENT shape reader ("is this an array of strings"), so it
passed, resolution skipped the blank, and the shape check afterwards refused it —
on an optional field, for a default the caller never wrote. That bucket now uses
a strict reader, and the strict/lenient split has a named helper explaining why
the two origins are held to different standards: a refusal is visible to the
person writing the request, a drop is not.

Instruments: 6 new tests. Mutants: blanks counted as cleared again (killed by the
supplied-override leg), the default bucket back to the lenient check (killed by
both default legs), and the empty list no longer clearing (killed by its own leg).

ONE MUTANT SURVIVED and it found a coverage hole older than this unit. Applying
the strict list reader to the SCALAR type as well — which would drop EVERY scalar
relation default on a copy or move — survived the entire `internal/store` suite,
first under a `-run` filter narrower than its population and then unfiltered too.
Nothing covered the scalar arm of that bucket at all. Two tests now do, and the
mutant dies in the first of them.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store,web): the third origin and the second view (TASK-2999 / PLAN-2857 U4, codex round 5)

Both findings were incomplete ENUMERATIONS of mine — the same failure round 1
was about, twice more, on smaller sets.

**Three origins, and I had settled two.** Rounds 1-4 gave SUPPLIED the refuse
disposition and DESTINATION DEFAULT the drop disposition, and left CARRIED on
the lenient shape check. A carried value is asserted by nobody either —
TASK-2878's rule is that it is dropped, never refused — so a carried
`[validID, " "]` passing the lenient check meant resolution skipped the blank
and the shape check afterwards REFUSED an ordinary same-workspace move on an
optional field, which is the exact outcome that rule exists to prevent. The
helper is renamed from `relationDefaultShapeIsUsable` to
`relationDroppableShapeIsUsable`, because naming it for ONE of its two origins
is what let the other keep the wrong check for a round.

**Two views implement grouping, and I fixed one.** ListView had both of the
defects BoardView had: the drop handler compared the stored ARRAY against a lane
string and fired `onStatusChange(item, "")` on every reorder, and the status chip
was withheld only for `isRelationGroup`, so a refused multi_relation board left
it live to write a scalar into a list.

THE BOARD'S CHIP GATE WAS REMOVED AFTER BEING WRITTEN. A mutant reverting it
survived, and the reason is structural rather than a weak test: a refused
grouping takes the `field?.options ?? []` branch of `columns`, a multi_relation
declares no options, so `ItemCard.statusCyclable` (which needs
`statusOptions.length > 1`) is already false. The gate was unreachable by
construction — the same disposition W7's write-side duplicate guard got. ListView
genuinely needs its gate, because its `statusOptions` is a CALLER PROP rather
than its own lanes, and its mutant dies. Both facts are written at their sites.

TableView needs neither: no grouping, and its chip is gated on
`field.key === 'status' && field.options`, which a multi_relation cannot satisfy.

Instruments: 8 new tests. Mutants: carried bucket back to lenient (killed),
carried bucket dropping every list (killed by the control), ListView drag gate
reverted (killed), ListView chip gate reverted — BOTH halves, since either half
alone suppresses the chip and a one-half mutant dies for the wrong reason
(killed), and the chip withheld ALWAYS (killed by both views' ordinary-grouping
controls).

A VACUOUS TEST was caught by its own control, which is why the control was
written first: my ListView chip leg omitted `statusOptions`, which is a prop of
that view and not something it derives, so the chip never rendered and the
assertion held against any build. The control failed, which is the only reason I
looked.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues (run three times — one
earlier run exited 3 inside a piped command that swallowed its message; three
clean unpiped runs since, and no explanation offered for the one, since I cannot
reconstruct it) · gofmt clean · web vitest 2585 passed · `svelte-check` 0 errors
· `vite build` clean.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web,server): the guard I deleted was reachable, and an empty override is not "your value" (TASK-2999 / PLAN-2857 U4, codex round 6)

**Round 5's reasoning was wrong and this restores what it removed.** I added a
refusal gate to BoardView's status chip, saw the mutant reverting it SURVIVE, and
concluded the gate was unreachable: a refused grouping takes the
`field?.options ?? []` branch of `columns`, and a `multi_relation` declares no
options, so the chip cannot cycle anyway.

Every step of that is true of the schemas anyone would WRITE and none of it is
enforced. Nothing strips `options` when a field's type changes, so a
`multi_select` retyped to `multi_relation` in the schema editor keeps them,
`columns` is non-empty, and the chip cycles — writing a scalar into a list. A
claim about schemas is not a claim about code; the preflight's own
`collection`-on-a-`select` comment says exactly that about a neighbouring field,
and I had read it earlier the same day.

So the surviving mutant was evidence about my FIXTURE, not about the guard. The
test now builds the retained-options schema and the mutant dies. The lesson is
narrower than "mutants lie": a survivor is a question, and the first question is
whether the fixture can reach the case — which is the same
check-the-faithfulness step that caught two survivors earlier in this unit and
which I skipped on this one because the conclusion was convenient.

**An empty override is not the caller's value.** The preflight already treats an
explicit `null` override as "leave this unset": it drops the key from `final` AND
from `origin`, so the destination default validation injects is reported as a
default. An empty `multi_relation` list is the same statement in the other
spelling — normalisation removes it, the default lands in its place — and it took
the other branch, so the preflight labelled that default `from: "override"`. On
the one surface whose entire job is to predict what a copy will do, attributing a
value to a caller who never sent it is the whole failure.

`items.IsEmptyRelationList` is now exported and both the normalisation and the
preflight ask it, rather than the preflight re-deriving a rule that lives in
another package.

Instruments: 4 new tests. Mutants: the preflight branch reverted (killed), the
preflight dropping EVERY override (killed by the control), and the board chip
gate reverted in BOTH halves (killed — where round 5's identical mutant survived,
which is the measurement that matters here).

Gates, each run unpiped after an earlier piped one swallowed a lint failure:
`make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30 ok · gofmt 0
files · `svelte-check` exit 0 / 0 errors · web vitest 2585 passed · `vite build`
exit 0.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(items,server,web): every spelling of none, every refusal-gated affordance, and a lost update in my own picker (TASK-2999 / PLAN-2857 U4, codex round 7)

Round 7 was asked to ENUMERATE rather than confirm, and returned five. Three
were siblings of round 6's fix — which is what an enumeration round is for, and
what six confirmation rounds had not produced.

**One sweep replaces round 6's point fix.** An empty `multi_relation` is about to
be removed by normalisation, and the destination default lands in its place, so
whatever `origin` says about the removed value gets said about the default
instead. Round 6 put that check in the preflight's OVERRIDE loop. That loop sees
the caller's RAW bytes, so `"[]"` is still a string there and only becomes an
empty list once `CoerceFields` runs; and it sees only overrides, so a CARRIED
empty list kept `from: "migrated"` by the same mechanism. One sweep over the
coerced map covers the override, the carried value and both string spellings,
because by that line they are all the same value.

**NULL is the third spelling.** On a full write the traversal treats a
present-but-nil key as absent for required-ness and defaults but does not REMOVE
it, so `{"members": null}` was stored — a third representation of "no targets"
beside the absent key and the empty list. Normalised on the full path only: on a
patch, nil is the deletion sentinel and the spelling being normalised TO.

**The lane create controls are refusal-gated too.** A `multi_relation` that
retained `options` produces NAMED lanes, so neither `isUncategorized` nor
`isRelationGroup` withheld the lane "+" or the lane menu's add: creating there
sent the lane string as the relation value. Third member of the affordance class
after the drag and the status chip — everything that writes the GROUP VALUE has
to ask the refusal.

**A lost update in W7's own picker.** Every list edit is a WHOLE-LIST write, and
they all derived from `value`, which only catches up after the server round trip:
remove A then B from [A,B,C] sent [B,C] and then [A,C], and the parent's 409
refetch-and-retry can persist the second, putting A back. The component now holds
the list it last SENT as the base for the next edit, released as soon as the prop
agrees by CONTENT.

THE FIRST VERSION OF THAT FENCE ONLY WORKED IN THE TEST. I cleared the hold from
an `$effect` reading `wsSlug` and `field.key` directly, which re-runs on every
prop assignment whether or not anything changed — so an ordinary parent
re-render mid-write released the hold and the stale prop came back as the base.
The two-click test never saw it, because nothing re-renders between two clicks.
A surviving mutant ("never release the hold") is what pointed at it, and the leg
that now pins it re-renders with IDENTICAL props mid-write. The hold is stamped
with the identity it was sent for and compared, which asks the question the fence
is for instead of one that merely correlates with it.

Instruments: 12 new tests. 12 mutants, all killed, including the four that
survived earlier passes and were each chased to a cause: a fixture that could not
reach the case, a fixture whose two outcomes coincided, a guard masked by its
neighbour, and the effect-timing one above.

Gates, unpiped: `make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30
ok · gofmt 0 files · `svelte-check` exit 0 / 0 errors · web vitest 2592 passed ·
`vite build` exit 0.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store,server): two more spellings, and a shape answer that depended on the route (TASK-2999 / PLAN-2857 U4, codex round 8)

Round 8 returned five. Two are members of classes already closed in this unit and
are fixed here; the other three open NEW classes and are deliberately NOT fixed —
they are the first rows of the resume table on TASK-2999, per the lead's
disposition for this round.

**The coerced NIL spelling.** `CoerceFields` JSON-parses a `multi_relation`
string, so the override `"null"` reaches the preflight's post-coercion sweep as a
nil — a spelling `IsEmptyRelationList` deliberately does not claim, since nil is
the PATCH path's deletion sentinel and that rule belongs to the validators. At
the sweep the question is only "will this value survive to be described", and a
nil will not, so the destination default that replaces it was again labelled
`from: "override"`.

Scoped to the list type, and a CONTROL pins that: a `json` field's `"null"`
coerces to nil too, and there nil is a legitimate stored value rather than a
spelling of absence. A mutant dropping every coerced nil survived until that
control existed.

**A shape answer that depended on which door asked.** `CoerceFields` runs BEFORE
`ValidateFields` injects a default, so an injected default is uncoerced at the
create door and coerced at the migrate door, where the copy coerces after
merging. A default written as the JSON TEXT `["<id>"]` was therefore stored by
one path and dropped as `invalid_shape` by the other — the same schema, the same
value, two answers chosen by the route. Droppable-shape judgements now run the
value through the real coercion first; the control is that text parsing to a
NUMBER is still not a list.

Mutants, all killed: the raw-value judgement restored, the shape check made
unconditional, the nil arm removed from the sweep, and the nil arm widened past
multi_relation.

Gates, unpiped: `make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30
ok · gofmt 0 files · `svelte-check` exit 0 / 0 errors · web vitest 2592 passed ·
`vite build` exit 0.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): two whole-list writes to one field can no longer lose the newer one

Codex round 8, R8-1 and R8-2, fixed as one unit because they are two halves
of one question: which write to this field is current.

R8-1, the lost update. Every multi_relation edit is a WHOLE-LIST write, so
two of them in flight are not deltas the server can merge — each asserts the
complete value. Remove A then B from [A,B,C]: both PATCHes carry the same
expected_updated_at, the second commits first, and the first then 409s,
refetches and REPLAYS [B,C]. A is back on the row, with no error raised
anywhere. Round 7 fixed where the second edit got its BASE; this is the
ordering, which is a different mechanism.

R8-2, the hold outliving its write. The hold released only on prop
AGREEMENT, and a value the server REFUSES never comes back — so a rejected
clear of a required field kept showing the rejected list forever and ignored
every later server value.

The shared model is `$lib/items/fieldWriteOrder`: two counters per target, a
ticket at dispatch and a high-water mark of what WROTE, because each
single-counter version answers a different question than the one being asked
(harness note ticket-plus-high-water-request-ordering). The dispatch counter
gates the RETRY — the one place counting dispatches is right, since a retry
is a new send of an OLD body and nothing here discards a response. The
applied mark gates every write of `item`, giving newest-wins among responses
that resolve out of order. The field editor takes the same tickets so an
older write settling cannot release a newer one's hold, and the hold now ends
when the write is OVER: prop agreement, or the consumer's own settlement
where it gives one (`onchange` may now return a promise; returning nothing is
unchanged and still supported, which is what the consumers with no server
behind them do).

The pane's OCC retry loop is EXTRACTED rather than guarded in place:
ItemDetail is ~7,900 lines and not renderable, so the defect site would
otherwise only ever be grepped for spellings. It is now driven directly,
conflict by conflict, through the interleaving that loses the update.

Instruments — 23 tests, three files:
- fieldWriteOrder.test.ts drives the real loop, with a NEGATIVE CONTROL per
  single-counter alternative: no supersession question at all, and counting
  applies instead of dispatches, each of which puts A back.
- FieldEditor.multiRelation: a refused write stops holding and shows server
  truth, the next edit bases on the prop again, an older write settling does
  not release a newer one's hold, and a CONTROL that a consumer answering
  nothing still holds (round 7 preserved).
- itemDetailOrdersFieldWrites.test.ts guards the binding (CONVE-19), with its
  own limits written down.

Mutation matrix, 7 mutants. Killed: settlement release disabled (3 legs),
release without the ticket check (1), the catch's supersession check, one
claim gate, the `order` wiring, a per-item instead of per-field target.
SURVIVED and each chased rather than re-rolled: moving `take` to just before
the first send is behaviour-preserving and not a defect, so that leg was
REMOVED rather than kept as rigour nothing can falsify; dropping the flush
before release survives the whole file and is KEPT and labelled, because
unlike W7's duplicate guard it is not unreachable by construction — the
signature permits a consumer that resolves before it assigns.

The proximity bound in the binding guard has its receipt: assignments sit at
35/35/60/156/207 characters from their gate, deleting one gate pushes two to
359 and 410, so the bound is 300. It was 400 first, which caught only the
farther of the two and by ten characters.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a board that refuses to group stops grouping (R8-3)

Codex round 8, R8-3. Round 7 gated every affordance that WRITES the group
value and left the LANES reading it, so the board went on bucketing under a
notice reading "Showing everything ungrouped."

The route is the same false premise round 6 corrected one derivation away:
nothing strips `options` when a field's type changes, so a `multi_select`
retyped to `multi_relation` keeps them — and a lane key is the STRINGIFIED
array, so `['id-red']` and `['id-red','id-blue']` match options `id-red` and
`id-red,id-blue` and the two cards land in two scalar-labelled lanes.

`columns` is now empty when grouping is refused, which sends every item to
UNCATEGORIZED and leaves exactly the one lane the notice describes. One lane
rather than keeping the notice and dropping it: it is the smaller change, it
makes the sentence true, and it matches the day-64 ruling that this unit
refuses grouping rather than reinventing it. IDEA-3034 holds the multi-lane
alternative. Taken as the lead's stated DEFAULT — no ruling on the product
question had reached this window.

WHAT THIS DID TO THE ROUND 5/6/7 GUARDS, measured rather than assumed. With
no named lanes, three of the four affordance gates are subsumed by the lane
they now cannot reach: deleting `groupingRefusal` from `cardStatusOptions`,
from the lane "+", or from the lane menu's add leaves all 63 tests in that
directory green. The drag one is NOT subsumed — its mutant still dies. The
gates are KEPT, because each affordance asking the refusal directly is what
made round 7's class enumerable; what is not kept is the pretence, so both
legs that stopped discriminating say so in place, naming the leg that does
fail when this regresses.

A forcing term that showed the lane even when empty was written and then
REMOVED: `items.length === 0` renders EmptyState instead of the lanes, so no
mutant could reach it. W7's disposition, not a comment explaining a guard
nothing can falsify.

Instruments: one leg with the finding's own fixture — two cards, retained
options matching their stringified values — asserting ONE lane holding BOTH,
with the refusal and both cards as preconditions so "one lane" is neither
"not refused" nor "a dropped card". Reverting `columns` turns it and the
round-7 lane-control leg red.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2616 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the round-8 enumeration's in-class findings, including two this unit introduced

ONE enumeration-prompted codex round on the frozen tip, per the lead's
disposition: not "is this right?" but "enumerate every remaining way a write
can leave the client and the server disagreeing". It returned 36 items across
four categories and said no category was empty — which is CONVE-24 working:
an enumeration is the only round that can return "none left" as a finding,
and this one did not.

Fixed here: the members of classes this unit had already closed, and the two
defects the unit's OWN round-8 fix introduced.

MINE, both in the open-children path, both from gating too widely:

1. The DECLINED branch took a claim. Declining writes nothing — it re-props
   the value the client already had — but claiming advanced the applied mark,
   so a write that had genuinely COMMITTED and was still in flight lost its
   claim on return. Server changed, pane showing the old value, nothing left
   to correct it. A branch that asserts no new server truth must not take the
   mark for one.
2. A successful FORCED write was discarded on supersession. If the newer
   write then failed without writing, the server held the confirmed value and
   the pane held the old one. `claim` asks the question that matters — has
   anything newer actually WRITTEN — and supersession does not.

IN-CLASS, four more:

3. The confirm callback SENDS, and asked about supersession only after it
   returned. The dialog can sit open indefinitely; checking afterwards
   suppresses the display of a write that already reached the server.
4. `submitOrderedOCC` asked only about supersession after its refetch, so
   switching items during that await still produced another PATCH at the item
   the pane had left — invisible, because the caller discards the response,
   and landing on the row all the same. Both questions now, both times.
5. The hold's release: prop AGREEMENT is a coincidence test, not an answer.
   Remove C from [A,B,C] and add it straight back, and the second write holds
   a list equal to the prop nobody has changed — the effect read that as
   confirmation and released mid-write, the first write's answer became the
   base, and C was lost by the mechanism built to stop that. Settlement now
   owns the release where it exists; agreement keeps it where it does not.
   A synchronously-throwing consumer no longer strands the hold, and the
   agreement path checks the identity stamp it had been ignoring.
6. The lane menu's bulk "Move all to" is the FIFTH affordance that writes the
   group value; round 7 enumerated four. It offers `statusField.options` as
   destinations, so a `status` retyped to `multi_relation` with its options
   retained offers lanes that do not exist and sends a scalar into a list.

A SOURCE FILE WAS BINARY, and that is worth its own paragraph because it made
this unit's own verification lie. `fieldWriteTarget` was written with a real
NUL BYTE where the source should carry the two-character escape for one —
identical at runtime, but `file` reports "data" and grep then returns NOTHING
AND EXIT 1 for every pattern, which is indistinguishable from "not present".
A restore-and-verify grep over that file came back empty for a symbol four
lines below it. It shipped in a9f58ccb; git still shows that path as `Bin` in
this diff.

INSTRUMENTS — 9 new legs, every one shown red against the unfixed code:
the agreement release (its first fixture gave the same answer either way and
was rebuilt so it discriminates), the retarget stamp, the synchronous throw,
the post-refetch item switch, the bulk move (its first version passed against
a menu that never opened — it now proves the menu opened, with an entry that
is not gated on the refusal), and four source-guard legs pinning the two
dispositions above, the claim-as-GATE spelling codex showed was passable with
the `if` removed, and the callback's guard-before-send order. Two error
assertions now compare IDENTITY rather than class, which they were shown to
need.

NOT FIXED, filed as the lead ruled — new classes go into the package as
follow-up items, not another round: BUG-3036 (whole-item responses unordered
against each other), BUG-3037 (OCC cannot separate two writes inside one
second — the client guard never even runs), BUG-3038 (a whole-list retry
replays instead of re-deriving), BUG-3039 (a debounced edit cancelled by the
previous keystroke's response), BUG-3040 (a refused write leaves the rejected
value in scalar inputs), BUG-3041 (a conventional key typed multi_relation
crashes card rendering), BUG-3042, BUG-3043, BUG-3044, BUG-3045 (`npm test`
can exit 0 having run no .svelte.test.ts at all), BUG-3046, BUG-3047,
BUG-3049 (full-fields writes without OCC undo a field patch ON THE SERVER),
TASK-3048.

One existing test now describes a KNOWN LOSS and says so: the
ordinary-conflict leg asserts that a third party's addition disappears, which
is what the code does and not what it should do (BUG-3038). Invert it when
that lands.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2625 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* test(web): stop the deliberate uncaught error failing the whole vitest run

CI's Web check went red at ab1bc203 with every test passing:

    Test Files  183 passed (183)
    Tests       2625 passed (2625)
    Errors      1 error
    ##[error]Process completed with exit code 1.

The error is the one my own leg provokes on purpose. "a consumer that throws
SYNCHRONOUSLY does not strand the hold" has `onchange` throw; the component
RETHROWS, which is the behaviour I want — the error belongs to the consumer,
not to the picker — and a throw escaping a DOM event handler surfaces as an
uncaught error on the window, in jsdom exactly as in a browser. vitest counts
that at the RUN level and exits non-zero however green the tests are.

The leg now installs a window 'error' listener scoped to that one message and
removes it in a `finally`, so a genuine uncaught error anywhere else still
fails the run. The product is unchanged: the rethrow stays, and the mutant
that removes the hold's release still turns this leg red.

HOW IT REACHED CI, which is the part worth keeping. I ran the web suite as
`npx vitest run | grep -E "Tests "` and read "2625 passed". The pipe hands the
exit status to grep — CONVE-30, gates are never piped — and "Errors 1 error"
is not on the line the pattern matched. So the evidence package's "web vitest
2625 passed" was true about the tests and false about the gate, which is the
only thing the gate was being asked.

Every Web CI step re-run individually since, each exit code read from a
redirect rather than a pipe: `npm run build` 0 · `npm run check` 0 (0 errors,
6 pre-existing warnings) · `npm run test` 0 · `npm run check:tiptap-pins` 0 ·
`npm run audit:ci` 0. Go side: `make lint` 0 / 0 issues · `make test` 0, 0
FAIL / 30 ok · gofmt 0 files.

Second false green in this window from reading a summary line instead of an
exit status; the first was the source file that had gone binary, where grep
answered "nothing, exit 1" for every pattern.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 16:01:12 -04:00
xarmian 185194caf6 feat(items): content_state marks a body the server knows is stale (BUG-3000) (#1343)
* feat(items): content_state marks a body the server knows is stale (BUG-3000)

A read of items.content could serve a body the server KNEW was behind the item's
live collaborative document, with no way for the caller to tell. BUG-2995 fixed the
write response; this is the read side.

The window is UNBOUNDED, which is what makes this a marker rather than a
documentation note. Nothing server-side moves applier content into items.content:
PruneSweep prunes and never flushes, and its candidate query requires
MAX(op-log.id) <= content_flushed_op_log_id, so an item in exactly this state is
EXCLUDED from the sweep. The content is durable (those rows are retained BECAUSE
they are unflushed) and the row stays behind until a tab next opens the item.

ON THE ITEM, NOT THE DOOR. Reads return a bare item or array — there is no warnings
envelope on the read side — so a per-door marker would be N changes across a
population that grows whenever someone adds a handler returning a models.Item. An
additive omitempty field on the struct is inherited by every door by construction,
and a consumer that does not know it sees byte-identical responses.

EXISTS, not MAX: the op-log's (item_id, id) index makes this a seek that stops at
the first qualifying row, where the sweeper has to aggregate across the whole
per-item range. Same index, different cost class — which is why the sweeper is the
wrong precedent to copy here even though it is the obvious one.

The marker tracks the CONTENT COLUMN, not the row. Four queries select an
empty-string literal for content (dashboard and child batching, the index and
changes readers); marking those would be a false signal about a body they never
return, so they are exempt and say so at the site. ListItems builds both columns
together for the same reason, so NoContent can never mark an omitted body.

The population is 15 SELECT sites, not the 10 an items.go-only sweep finds: search.go
and item_stars.go carry content too, and ListStarredItems shares the scanItems helper
across that boundary — which is how the first pass failed, at runtime rather than at
compile time. Summaries carry content_preview, so ItemSummary gets the field too; a
preview is content.

RECEIPT in the code: measured with a counterfactual arm, because a one-armed timing
cannot attribute cost. 200 items on SQLite, best-of-5: with the predicate 2.44 /
4.00 / 3.81ms at 0 / 2k / 20k op-log rows, without it 3.58 / 2.86 / 3.27ms. The arms
are indistinguishable and at 0 rows the arm WITHOUT was slower, so the honest
statement is "below the noise floor at this scale", not "free". Untested scales are
named in the comment.

Tests assert both directions on the same item — a marker that is always on is
indistinguishable from one that is always right. Mutation-tested: an always-true
predicate is killed only by the clearing leg, a NoContent leak only by the
omitted-body leg, and a never-set marker by all three. Both backends; the Postgres
leg verified to RUN, not skip.

Also corrected under CONVE-23: warnContentPendingFlush's comment said the flush
question was "not established". It now is, and it says so.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs(items): restore an orphaned doc comment; name the export door (BUG-3000, codex r1)

Two corrections from round 1, one of them mine to the population claim.

The contentStateSQL const was inserted between getItemScanQ's doc comment and its
func, orphaning that comment and attaching it to the const. Moved above it.

POPULATION CORRECTION. Codex flagged content reads outside the 15 projections, and
one is real: Store.ExportWorkspace selects content straight into models.ItemExport,
so an export taken during a pending-flush window captures the stale body — and
ImportWorkspace writes it back as canonical in the destination, where the op-log
that held the real content does not exist. Filed as BUG-3032 rather than folded in:
ItemExport is a portable bundle schema, not models.Item, so marking it is a format
change the import path has to tolerate, and its query does not even use the i. alias
contentStateSQL requires.

Checked and correctly OUT: wiki_links.go's cascade re-read and items.go's in-tx
re-read (internal rewrites, never returned to a caller),
RemapAttachmentReferencesInWorkspace (same), the documents/templates/versions tables
(different subjects), and item_versions (BUG-3031, split out by ruling).

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): cover every spliced door; mark the public share DTO (BUG-3000, codex r2)

Round 2's P1 and the coverage gap behind it, plus a correction to my own comment.

THE COVERAGE GAP IS THE IMPORTANT HALF. The content-state tests exercised two of
the fifteen spliced queries, so "the suite is green" was never evidence the other
thirteen were column/Scan aligned — and a mismatch there is a RUNTIME error that
leaves go build green, which is exactly how the first implementation pass failed.
TestEveryContentBearingReadDoorCarriesTheMarker now drives all thirteen reachable
store doors against one stale item. It is an alignment guard first and a behaviour
test second: a door returning the right marker necessarily has a matching
destination. ListStarredItems gets its own leg by name — it reaches the shared
scanItems helper from outside items.go, which is the boundary that broke.

P1, folded in rather than filed: publicShareItemDTO is an explicit allow-list, so it
does not inherit the marker. A public share is the door whose reader is least able
to notice on their own — an anonymous viewer has no editor, no op-log, nothing to
compare against. Added only when set, so the key set stays byte-identical in the
common case and TestMovedTo_ShareLinkNeverCarriesPointer's pin is undisturbed.

MY OWN ERROR, corrected: the scripted exemption comment landed on ItemsModifiedSince
instead of GetChildItemsForParents, so a comment reading "NO contentStateSQL here,
this query selects an empty-string literal" sat directly above a query that has the
predicate and selects i.content. A non-unique anchor edited the wrong match. Both
exemptions are now on the functions they describe, verified by a check that reads
what is actually below each one.

Four further doors build their own shape and are filed as BUG-3033 rather than
folded in, because each needs a rendering decision rather than a field copy:
artifact export, playbook run, bootstrap convention bodies, and the MCP item
resource (verified directly — the JSON it fetches now carries the field, and
formatItemAsMarkdown re-renders without it). The playbook and bootstrap halves rank
first there: an agent ACTS on those bodies.

make test 0 FAIL; make test-pg exit 0, 0 FAIL, no banner, all 13 door legs PASS on
Postgres too; make lint 0 issues.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): close two coverage holes in the coverage guard (BUG-3000, codex r3)

Both P2s are holes in the test built to close holes, which is the part worth saying.

ResolveItemIncludeDeleted was called with a SLUG. That falls through to
GetItemBySlugIncludeDeleted, so the leg passed while measuring a different function
and this one's own ref SELECT — a separately spliced query — stayed unexercised. Now
driven by REF, with ResolveItem added alongside.

The enumeration ran SQLite only. The FTS branches are DIALECT-SPECIFIC: ListItems
(search) and Search take different SELECTs per backend, so a SQLite-only run left
the Postgres variants of those spliced queries uncovered — exactly the
runtime-mismatch hazard the test exists for. The whole enumeration is now
parameterised over both backends: 14 door legs each.

Two P3s:

The CLI warning fired inside the empty-body check, so an item whose STORED body is
empty while its document holds the real text — this bug's own shape — printed
nothing and warned nothing. It now warns before the check, which is the case where a
reader has least to go on.

The TypeScript type was `string`, broader than the server's vocabulary. It is the
literal 'applied_pending_flush', matching ItemWriteWarnings.content_outcome.

Gates VERIFIED at this commit: make test 0 FAIL, make lint 0 issues, gofmt clean,
and all 14 door legs passing on BOTH backends. make test-pg is being re-run alone:
the previous attempt overlapped a second run of the same target in this worktree,
which shares a compose project, so its teardown killed the other's database
mid-run — 47 connection-refused lines and failures confined to pre-existing
Postgres tests. That run is INDETERMINATE, not red, and is not being reported as a
pass either.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(share): the public COLLECTION share carries the staleness marker too (BUG-3000)

Night 19's P2, and it is the same door class I folded in for the single-item share
one round earlier — I fixed one allow-list and missed its sibling twelve lines away.

Both public-share doors build an explicit allow-list rather than serialising
models.Item, so neither inherits the marker the way a struct-serialising door does.
Both serve an ANONYMOUS reader, who is the least able to notice staleness
unaided: no editor, no op-log, nothing to compare the body against.

Set only when non-empty, mirroring the sibling, so the key set stays byte-identical
whenever the row is current — which is what the existing exact-shape pin on the item
DTO depends on, and what a marker that were always present would break while
satisfying every positive assertion.

The test covers BOTH doors in BOTH directions on one item: absence asserted FIRST so
the presence assertions cannot be read as an always-on marker. Mutation-tested
independently — removing the collection half kills only the collection leg, removing
the item half kills only the item leg. No cross-talk, so neither door is riding on
the other's coverage, which is exactly how this one was missed.

A note left at the site rather than in this message alone: if a third allow-list over
item bodies ever appears, these should become one helper instead of a third copy.

make test 0 FAIL; make test-pg exit 0, 0 FAIL, no banner (run alone — an overlapping
run of the same target in one worktree shares a compose project and its teardown
kills the other's database mid-run); make lint 0 issues; gofmt clean.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-13 10:51:03 -04:00
David Barkhausen 1a041b33ff feat(cli): pad token rotate — new secret in place, old one dies with the write (#1237 follow-up) (#1330)
The rotate endpoint (POST /auth/tokens/{id}/rotate) existed server-side
with no CLI in front of it; the #1237 review asked for it as its own PR.

- exact id only, same discipline as revoke: a typo is the server's 404
  surfaced, never a wrong token rotated (which kills a live secret
  somewhere)
- the new secret prints exactly once (count-pinned) with the store-it-now
  notice, and the output says plainly that the old secret already stopped
  working — RotateAPIToken replaces the hash in place, no grace window,
  so the wording must not imply one
- --expires-in <days> posts a new expiry (server-capped by max lifetime);
  without it no expires_in is posted and the original expiry is preserved
  (test-pinned both ways)
- README documents rotate beside create/list/revoke, including that
  rotate shares create's session_required gate (#1267)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 18:05:46 -04:00
David Barkhausen e712ca12be fix(cli): pad token create --scopes validates and JSON-encodes (#1237 follow-up) (#1329)
The flag passed its raw string into APITokenCreate.Scopes: the store
writes it verbatim and tokenScopeAllows requires a JSON array, so
'--scopes read' minted a token every request refused, with only a
server-side slog warning the CLI user never sees (#1237 review).

- comma-separated values, trimmed, validated against the server's
  recognized vocabulary (*, read, write, pad:read, pad:write,
  pad:admin) — an unknown or empty entry is refused locally, naming
  the allowed list, before any request
- valid values JSON-encoded: --scopes read posts ["read"]
  (test-pinned against the stub's recorded body)
- help text no longer says 'optional scopes string'

Also makes TestTokenList_RendersMetadataWithoutSecret falsifiable
(the review's minor note): the list stub now carries a token field
the real server never sends, so the no-secret assertion fails if the
renderer ever surfaces it — verified by reintroducing exactly that
leak and watching the test fail before reverting.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 18:05:43 -04:00
Matt Faltyn 42c08b30bb fix(cli): refuse SQLite self-restore (#1332) 2026-09-11 17:13:05 -04:00
xarmian 63adbebb76 feat(relations): materialised reverse index and Referenced-by (PLAN-2857 U5 / TASK-2997) (#1328)
PLAN-2857 unit U5, closes TASK-2997.

A MATERIALISED reverse index for `relation` field values —
`item_relation_links` per the ratified Q3 shape — plus a write hook, a
startup backfill, a viewer-scoped query, an endpoint, and the
"Referenced by N" section.

THE HOOK LIVES AT THE STORE'S FIELD-WRITING SITES, not at the doors the
row named. A door-level hook is a per-door wiring claim over a population
that GROWS; a store-level one cannot be bypassed by a door that does not
exist yet, and it is the shape migration 061 set for the content index.
The sites were enumerated with a grep rather than by eye, and two of them
are not in U1's resolver population at all.

Three placements are not obvious from the call sites: UPDATE re-reads the
blob FROM THE ROW, because a field write is a server-side MERGE and
indexing the submission would drop every key the caller did not mention;
MOVE indexes against the DESTINATION collection, the one write where blob
and schema change together; and the attachment remap calls the hook even
though it cannot change a relation value, because omitting it would be a
standing claim about what that function can touch.

A SCHEMA CHANGE REINDEXES THE COLLECTION (lead ruling), inside the
schema-update transaction and under the workspace lock, gated on the
schema or the values actually moving — an icon edit must not pay it.
Measured: 947ms-1.8s for 10k items, which is why it is inline and not a
job. The backfill batches by workspace in bounded chunks: 10k items in
1.06s across ~20 transactions, where one-per-item would have been 10,000.

THE COUNT IS THE VIEWER'S. ResolveBacklinksVisibility reused verbatim, so
"Referenced by 3" means "by 3 you can see" — a true count leaks the
existence of items the viewer cannot open. Filtering is in SQL so LIMIT
counts visible rows.

EIGHT CODEX ROUNDS, ELEVEN P1s; the first seven each found something
real. The ones worth keeping:

  - the backfill's guard was "does the table have any row", and rows
    commit per item, so an interrupted pass left it non-empty and
    INCOMPLETE and skipped forever. Completion is recorded explicitly,
    after the work.
  - RESTORE is an eighth hook site. I enumerated writes of one COLUMN;
    the index depends on (blob, schema, LIVENESS). That miss produced the
    input matrix on TASK-2997's trail, which round 4 then validated row
    by row.
  - the backfill SKIPPED zero-edge items as an optimisation — precisely
    the case that preserves a stale row and then marks the pass complete.
  - a source in a SOFT-DELETED COLLECTION was disclosed; its items stay
    live and an Unrestricted viewer has no collection filter to catch it.
  - rounds 3 and 4 were the same miss at two scopes: I locked what I was
    READING and not what whose change invalidates the read. Round 3 also
    refuted a claim I had put in a commit message, and the record was
    corrected rather than left standing.
  - the field-label lookup added in round 6 BECAME round 7's P1: a
    per-collection SELECT while the rows cursor was open, which is
    BUG-2409's deadlock class. One query now, with a MaxOpenConns(1)
    guard that the restored shape kills.

Three times a comment described code that had changed under it,
including one claiming a counterfactual TEST that does not exist — the
counterfactual is a mutation, recorded per round with its verdict.

Q3'S COUNTERFACTUAL, re-run after every rewrite: hook removed, mutant
COMPILES, all legs fail. Per-site too, because "all sites wired" is
otherwise untested.

NOT COVERED, said plainly: the Postgres interleavings are argued from the
isolation level and the lock order, not demonstrated — SQLite serialises
writes and cannot express the race. Two hook sites are uncovered, one
unfalsifiable by construction and one with no caller.

BUG-3017 files the pre-existing attachment-remap lost update this
branch's workspace lock closes, so it is on the record rather than
absorbed here.

Gates: go test ./... 0, vet 0, gofmt clean, lint 0 issues, svelte-check 0
errors, make test-pg 0 with all 17 relation tests confirmed run BY NAME
on Postgres. All 8 CI checks green on 8c0fe775.

Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
2026-09-11 00:57:47 -04:00
xarmian 162eccb42b feat(relations): exact-title resolution and hydrated reads for relation fields (PLAN-2857 U6 / TASK-2996) (#1326)
PLAN-2857 unit U6, closes TASK-2996.

U1 made a relation value have to name a live item. U6 adds the third
spelling a human actually knows — the item's TITLE — and the read side
that lets a client render any of them.

  - a value may be a UUID, an issue ref, or an EXACT TITLE, in that
    order, so an item literally titled COLO-3 is unreachable by title
    while the ref COLO-3 resolves;
  - title matching is scoped to the collection the field declares: a
    title unique only workspace-wide REFUSES naming the collection
    searched, and two or more matches inside it are `ambiguous` rather
    than `not_found`, which would state the opposite of what happened;
  - reads carry `relation_targets` — field key to {id, ref, title} —
    BESIDE `fields` rather than inside it, so read-modify-write keeps
    working;
  - the --fields DSL takes a relation's target collection. It used to put
    the third part into Options and build a field with NO target, which
    every subsequent write then refused with target_missing; a bare
    `owner:relation` is refused at parse time now;
  - ToolSurfaceVersion 0.31 — a behaviour bump, because that DSL refusal
    is a call that used to be accepted.

THE RULED PROPERTY: among live exact-title matches in the declared
collection, only VISIBLE ones count — zero is not_found, exactly one
RESOLVES, two or more is ambiguous — and a hidden match never changes the
answer a caller gets.

It took three attempts to put that in the right place. It was a
SERVER-SIDE second pass first, which left the cross-workspace copy —
running the resolver inside its own transaction — deciding without it, so
preflight and copy disagreed about one input. It lives in the resolver
now, taking the store.RelationVisibilityFunc this codebase already had
for the shape, and which resolveRelationsForWrite's own comment had named
as the missing piece. nil means no requester and every live match counts.

CARRIED values are exempt by provenance: nobody asserted them, and
judging them by the mover's visibility is what MigrateRelationReferentsQ's
comment calls out as making the stored bytes depend on who moved the
item. Supplied values and injected defaults are judged.

EIGHT CODEX ROUNDS; seven found something real. The ones worth keeping:
a CAP on the candidate list was itself an oracle (one visible plus cap
hidden read differently from one visible plus cap+1); the paging cursor
used the NULLABLE item_number and excluded legacy rows the deciding count
included; hydration filtered on COLLECTION visibility, which is
documented as deliberately nav-lenient, so an item-level grant on one row
exposed every sibling's ref and title; and hydration scanned that
nullable item_number into an int, so one pre-006 row erased
relation_targets from the ENTIRE response.

Round 7's "P1" about changed exported signatures was REJECTED, not fixed:
internal/store cannot be imported outside this module, so the compiler
had already proved every caller updated — which is why a required
parameter is right rather than a variadic a caller can silently omit.

TESTING: every fix carries a mutation. Three verdicts were thrown away
rather than counted — two mutants did not COMPILE (a build failure
produces FAIL lines identical to a real kill) and one mutation never
applied. In one sub-task three successive tests passed for reasons that
had not been established: a field that never arrived, a fixture whose
encoder filtered the key before the wire, and a schema reshape that
silently failed. One mutation is recorded UNCOVERED rather than dropped:
the canSee==nil count-before-nil-check is a race with no seam to force,
so that fix is argued from the code path and not demonstrated.

Filed, not fixed: BUG-3012 (ref-path TOCTOU), BUG-3013 (U2 chip wording),
BUG-3014 (bundle-import title renders as dangling), BUG-3015 (the
relationsCarry posture drops silently, medium).

Gates on 29bb6e2c: go test ./... 0, vet 0, gofmt clean, lint 0 issues,
web vitest green. All 8 CI checks green.

Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
2026-09-10 21:06:40 -04:00
xarmian d201ebb87d fix(server): a successful applier-path PATCH stops answering with the previous content (BUG-2995) (#1320)
* fix(server): a successful applier-path PATCH stops answering with the previous content (BUG-2995)

The applier path writes the row WITHOUT content — the markdown travels
through a connected tab's Y.Doc and reaches items.content on the next
collab-snapshot flush — and the 200 was serialized from that row, so a
caller that PATCHed content and read `content` back saw its own update
missing.

The response now carries the content as SENT, marked
`warnings.content_outcome = applied_pending_flush` in the same vocabulary
`content_not_applied` uses. The marker is what makes the echo honest: the
echoed value is true of the apply and not of the row, and the stored form
is not byte-identical to the sent form even once the flush lands.

Measured rather than assumed (BUG-2995 trail): a jsdom harness driving the
real two halves — setContent() then the editor's markdown storage — found
11 of 14 StarterKit-owned constructs normalise (setext headings, bullet
markers, emphasis characters, list renumbering, blank-line runs).

The row deliberately still lags; this stops the response lying about it.

* test(server): the direct-path leg needs a warnings object to be wrong about (BUG-2995)

The leg asserted "no content_outcome when the content landed in the row"
and a mutant that stamps the marker unconditionally survived it: the
warnings object is only CONSTRUCTED when there is something to say, so
the marker landed inside a block that path never enters. The leg was
asserting a property whose mechanism it could not reach.

Sending an undeclared field gives that request a warnings object, so the
mutant now has somewhere to be wrong. Preconditions are asserted rather
than assumed, and the reason is in a comment so it survives a cleanup.

* test: say what each BUG-2995 guard actually establishes (codex round 1)

Two findings, both about a test claiming more than it measures.

The direct-path control leg was named DirectPath and asserted only that
items.content holds the sent value — true of the direct write, and equally
true of contentRouteFallThrough or an ordinary non-routed write. Renamed
to NoPendingMarkerWhenTheContentIsInTheRow, which is the route-independent
invariant it does pin, with the boundary in the doc comment.

The round-trip guard mixed the property (the sent form is not what comes
back) with the recorded serializer output. A tiptap bump could change HOW
a construct normalises without touching the contract and fail the property
tests. Split: per-construct inequality assertions carry the contract, one
clearly-named test holds the recorded forms and says to re-record it.

* docs+cli: tell callers where the content is when a write is pending flush (codex round 2)

The contract was implemented and typed but invisible to the surfaces
callers actually read: the MCP instructions and catalog said only that
'content' is optional, the CLI printed nothing, and the TypeScript mirror
of ItemWriteWarnings had no content_outcome, so typed code could not reach
a warning the server was already sending.

- pad item create/update now print a stderr line when the write is pending
  flush, saying the content landed in the live document, that what was
  printed is what was sent, and that a read may still show the previous
  content. Stderr, never stdout: --format json is piped into scripts.
- pad item update --help, internal/mcp/instructions.md and the catalog's
  update action say the same, including the part that matters most to an
  agent: a get inside the window answers with the PREVIOUS content, which
  is the lag rather than a failed write, so do not re-send on it.
- web/src/lib/types/index.ts mirrors content_outcome.
- CLAUDE.md documents it beside undeclared_fields / dropped_fields.

The outcome string is now one exported models constant rather than a
literal in two packages: the server writes it and the CLI compares it, and
a rename on either side would have left the warning silently never firing
— indistinguishable from the bug being fixed.

No duration is claimed on any surface. It is not established that the
flush always lands (BUG-3000), so a number would be a claim nothing here
can support; a test asserts the warning carries none.

* docs: stop the pending-flush prose claiming more than the code does (codex round 3)

Three overclaims, in the change whose whole point is not overclaiming.

- Every surface said the stored copy 'catches up afterwards'. Nothing
  establishes that: the flush belongs to a browser tab, and whether it
  ever happens is the open half filed as BUG-3000. Now 'updated only when
  that tab next flushes, and nothing guarantees it does'.
- 'a get/list answers with the PREVIOUS content' — a default list is
  summary-shaped and carries no content at all, so only get, or list with
  full=true, can answer with anything.
- 'the stored form is NOT byte-identical' — the round-trip guard's own
  fixtures include three constructs that survive unchanged, so the
  measurement contradicted the sentence. Now 'may not be', with the
  stable case named.

Two doc comments the last two commits falsified: models.Warnings
described only undeclared keys, and the TypeScript mirror said TASK-2878
'added two more members'. Both now name the KINDS rather than counting
members, so the next addition does not falsify them again.

Also records the version decision where the next person will look: a
POST-0.30 NO BUMP entry in version.go and CLAUDE.md. v0.9 bumped because
list rows LOST fields consumers read; here nothing is removed or retyped
and the one field whose value moved was answering a content PATCH with
content from before that PATCH, so there is no reliance to break — the
BUG-2304 disposition rather than v0.9's.

* docs: the same overclaim, swept as a class this time (codex round 4)

Round 3 fixed the user-facing prose and left the identical claim in the
code comments — the named instances rather than the class (CONVE-18).
Swept the branch diff for both shapes instead of patching the four sites
the review listed:

- 'is not byte-identical' as an invariant: models.ItemWriteWarnings,
  the handler comment, the Go test comment and the round-trip guard's
  comment now say the stored form MAY differ, and name the case that
  survives unchanged. The two remaining hits are correctly quantified —
  CLAUDE.md's 'may also differ', and a test asserting at least ONE
  construct differs, which is existential and true.
- 'the row is written when a tab next flushes': now 'only if and when',
  with the BUG-3000 pointer, in the constant's own doc comment where a
  reader meets it first.

The NO BUMP entry claimed 'there is no reliance to break', which is not
establishable. A consumer could have detected the applier path by
noticing the response echoed something other than what it sent, and that
stops working. Restated as no DOCUMENTED OR SUPPORTED reliance, with the
counter-case named: it was never documented, a genuinely lost write
produced the same mismatch, and warnings.content_outcome answers that
question properly in the same change.

Also asserts what round 4 asked about: content plus an undeclared field
in one applier-path request must produce ONE warnings object carrying
both members. Mutation-checked — a marker that replaces the object
rather than building on it now fails.

* docs: the flush is a collab-snapshot write, not a promise from one tab (codex round 5)

Every surface said the row is updated when 'that tab/editor next
flushes', which over-specifies the mechanism in a way an agent could act
on. The row is updated by any later ?source=collab-snapshot write: in
practice that is the tab which applied the content, but the row does not
care which caller sends it, and an open tab may be non-electable, so
'that tab' is neither necessary nor sufficient.

Swept as a class again rather than per site — CLI help, CLI stderr line,
MCP catalog, instructions.md, CLAUDE.md, the TypeScript mirror, the
models doc comment and the constant's own comment. Verified by re-running
the grep that found them: no single-tab attribution remains.

The review also cited README as carrying this text; it does not — the
grep returns nothing there, and nothing in this branch touched it.

* fix(test): the applier probe loop was flaky, and the marker is write-scoped (codex round 6)

Two findings, and the first one was real in a way I had not seen.

The review said the focused tests fail on a 429 from the shared
waitForApplierPath probe loop. My own runs were green, so I ran it the
way that would show it instead of assuming: -count=5 across both BUG-2995
tests fails with exactly that 429. One test at -count=1 stays inside the
API limiter's burst; two at -count=5 do not. The helper now waits out the
limiter rather than failing on it, and keeps the diagnoses apart — being
rate-limited throughout is inconclusive about the route, not evidence the
server wrote items.content directly.

That exposed a second flake underneath: with the probe slowed, election
lost the race in 2 runs of 5 against a 3s deadline. That deadline is a
READINESS wait, not an assertion — every millisecond is spent only while
the room is not ready — so a too-short one invents a flaky test rather
than catching a product bug. Raised to 15s, which the passing runs never
approach (they finish in about a second). Stable over four -count=5 runs,
including alongside the BUG-2840 test that shares the helper.

The second finding: the marker could be stale, since a concurrent
collab-snapshot flush can update the row between the apply and the
response. Rather than synchronise, every surface now says what the value
actually means — it describes THIS WRITE, that the content went to the
document rather than the row, and is not a live reading of the row. The
caller's action is unchanged either way: re-read rather than re-send.

* docs: the CLI line said the stored copy has not been updated (codex round 7)

Last surface still phrased as a live reading of the row. A concurrent
collab-snapshot flush may already have updated it, so the honest form is
what this request did: it applied the content to the document and did not
write the stored copy itself.

Also sharpens the probe loop's rate-limited failure message. The flag is
sticky, so some probes did complete — the branch does not suppress a real
failure, it declines to name a cause the run did not measure.

* fix(test): the applier readiness helper stops paying the rate limiter (BUG-2995, CI)

CI's Go job failed on TestApplierPathRefusalLeavesNoOpLogRow_OpenChildren
— a test green on main, untouched by this branch, and a THIRD caller of
waitForApplierPath I had not accounted for. It spent its whole budget
being answered 429.

The helper inferred the content route by PATCHing every few milliseconds
and watching whether items.content changed, with a comment saying no
exported accessor for electable conns existed. That was true when it was
written and stopped being true at TASK-2987, which added
RoomManager.HasElectableApplier so the handler could learn the route
without taking it. The helper now asks that predicate directly.

Leaving it as a write loop was not just wasteful: those writes go through
the ordinary API limiter (600/min, burst 60) and the bucket is shared
across the package, so every caller drains it for the tests that follow.
One caller stayed under the burst. Adding a second pushed a third,
unrelated test over the edge — a helper that consumes a shared
exhaustible resource makes other tests fail as a function of how many
ran first, which is the worst kind of flake to read from a log.

Asking the predicate costs nothing, cannot be rate-limited, and answers
the exact question rather than inferring it from a side effect. The three
callers now run in about a second at -count=3 where the probe loop took
multiple seconds and could fail outright.
2026-09-10 12:37:49 -04:00
xarmian a357b9f609 fix(mcp): restrict the remote transport to the protocol era pad can serve (TASK-2977) (#1310)
* fix(mcp): restrict the remote transport to the era pad can serve (TASK-2977)

mcp-go 1.0 implements the stateless protocol core from 2026-07-28 — no
handshake, no sessions, per-request identity in _meta — and its
Streamable HTTP transport advertises EVERY revision it implements by
default, serving both eras concurrently on one endpoint and deciding the
era per request. pad's construction site passed no version restriction,
so the library bump alone had main answering modern-era traffic through
server/discover while pad://_meta/version still published 2025-11-25 as
the maximum revision this server can negotiate.

NOT A PRODUCTION DEFECT, checked rather than assumed: app.getpad.dev and
mcp.getpad.dev both report commit 0e2cb06a built 2026-08-31, nine days
before the mcp-go 1.0 merge, and 0.58 has no 2026-07-28 constant at all.
The gap is in main, ahead of a hand deploy, so this lands before it can
become real.

Beyond the mismatch: pad_set_workspace pins a session default workspace
and the stateless era has no session for that pin to live in. So the
modern era is not something pad happens not to advertise, it is
something pad is not known to be able to serve. Establishing what it
would take is step 2 of this item; the honest advertisement meanwhile is
the era pad was built and tested against.

The set is DERIVED from mcp.LegacyProtocolVersions(), the SDK's own
answer to "which revisions use the handshake", so a future SDK adding a
legacy revision includes it and one adding a modern revision excludes
it, with no edit here. A hand-written list would silently mean the wrong
thing after either bump — the same shape as the defect being closed.

The option set moved into mcpserver.NewRemoteTransport so a test can
drive what cmd/pad actually constructs. An advertised set is only
correct if the option is PASSED, and a test building its own transport
would vouch for the option and not for the binding (CONVE-19).

Four tests, and the second is what makes the first mean anything:

- a well-formed 2026-07-28 server/discover against pad's transport is
  refused with code -32022, data.requested naming the version and
  data.supported carrying exactly the legacy four. Asserting "an error
  came back" would also pass on a transport that had simply broken.
- the identical bytes against an UNRESTRICTED mcp-go transport are
  SERVED, with 2026-07-28 among supportedVersions. Negative control: it
  is what says the refusal comes from pad's option rather than from a
  malformed request or a changed library default. Both requests carry
  the Mcp-Method header the modern era requires, so a refusal cannot be
  about headers.
- initialize still negotiates 2025-11-25 — the restriction must not
  break the era pad actually serves.
- AdvertisedMCPProtocolVersion equals the newest served revision, which
  is the half TestAdvertisedProtocolVersion structurally cannot see: it
  pins the literal to what the HANDSHAKE answers, and the modern era has
  no handshake.

Sweep: meta.go's two comments described the handshake cap as the whole
story. One construction site only — pad-cloud is an OAuth/billing layer
and builds no transport, so the pad binary in cloud mode is the single
place this is decided.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(mcp): the handshake test measured less than its comment claimed (TASK-2977)

A mutation found this in my own instrument. Restricting the advertised
list to a version that EXCLUDES 2025-11-25 leaves the legacy-handshake
test green, so that test cannot be evidence that the restriction
preserved the era pad serves — which is exactly what its comment said it
was.

The mechanism, read rather than inferred from the green: initialize is
answered by MCPServer through mcp.NegotiateLegacyVersion, which consults
LATEST_LEGACY_PROTOCOL_VERSION and never the transport's list. The two
are independent.

So the comment now says what the test measures (the legacy path works)
and what it does not (that the restriction preserved it), and the
independence is pinned as its own subtest: a transport advertising only
2025-06-18 still answers initialize with 2025-11-25. If that ever fails,
the handshake has become coupled to the advertised list and the
restriction has become able to refuse legacy clients — the moment this
file needs a different test.

Nothing about the fix changes; the claim about the evidence does.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-09 19:22:58 -04:00
David Barkhausen f262449b18 feat(cli): pad token create/list/revoke — CLI mint path for API tokens (#1237)
Contributed by b4rk13 (#879 follow-up). Reviewed under DECIS-212-style read: sits on the existing user-scoped /auth/tokens endpoints, no server changes; create requires a login session per #1267 and answers 403 session_required under PAD_TOKEN, list/revoke stay PAT-reachable.

Claude-Session: https://claude.ai/code/session_01W71Y4K5hGbbqqAhbVFnjB4
2026-09-09 16:09:29 -04:00
xarmian 4618876e3e fix(cli): pad server stop signals only a process it can prove is ours (BUG-2969) (#1299)
fix(cli): `pad server stop` signals only a process it can prove is ours (BUG-2969)

Measured on the merged binary before this change: a `sleep 600` whose pid had
been written into the PID file was SIGTERMed, and stop printed "Server stopped."
No pad server was running anywhere near that config.

Three things had to be true at once for that. os.FindProcess succeeds for ANY
pid on Unix. Nothing asked whether the pid belonged to a pad server. And the
confirmation loop polled the PORT — which is unhealthy from the first poll when
nothing was ever serving, so the success check was satisfied by the failure
case.

Liveness is the wrong question, and this is the trap the obvious fix falls into:
the stranger WAS alive. The question is whether the pid is OUR server.

## The discriminator

Unix takes an advisory flock on the PID file, held for the server's lifetime.
`stop` probes it non-blockingly: acquiring it proves nobody holds the file, so
the record is stale whatever the pid now names; failing to acquire proves a live
pad server holds THIS file. One implementation for Linux and macOS, no new
dependency, and the same primitive session_lock_unix.go has used since
TASK-2767.

Windows has no flock in that pattern, so it compares the process creation time
from GetProcessTimes against the one recorded at start — the attribute that
survives pid reuse, since a reused pid belongs to a process that started later.

The lead first ruled start-time comparison on every platform; I objected with
the cost (three implementations — /proc, a macOS sysctl promoting x/sys to a
direct dependency, and GetProcessTimes) and the ruling changed to this hybrid.
The cost table is on the item so the next reader sees why the shape moved.

The PID file gains a fingerprint on both platforms — pid, start time, executable
path — as JSON, with the legacy bare-integer form still parsed. A legacy record
carries no proof, which reads as UNPROVABLE, and unprovable means nothing is
signalled.

## Three races, each found by codex and each the same shape

1. Reading the record and checking ownership were separate steps, so a successor
   could claim the file between them: the lock then reported "held" — truthfully,
   about the successor — while the pid handed back was the predecessor's.
   pidFileOwner now returns the record it read from the descriptor it probed.
2. Removing the PID file after a successful stop could delete a fast successor's
   live record. It no longer removes at all there: the server removes its own on
   the way down, and a file left by a crash is handled by the next stop.
3. Removing a STALE file after the probe released the lock had the same window.
   The removal now happens inside the ownership check, while the lock is held —
   the only moment at which no replacement can have claimed the path. A claim
   arriving during that instant retries for half a second rather than losing its
   claim for the life of the process.

Windows deliberately does NOT delete a stale file: with no atomic primitive, a
check-then-remove would race a successor, and a stale file that the next start
overwrites is recoverable where a wrongly deleted record is not.

## Verified

Negative control, and it is the literal one: with the ownership check bypassed,
`go test` reports `signal: terminated` — the test binary is SIGTERMed by the
code under test, because the stale record names the test process itself.

Live, in throwaway HOMEs: a stale record naming a live `sleep` is refused and the
sleep survives (it was killed before this change); a stale record with a HEALTHY
port answering is still refused, nothing signalled, and both the stranger and the
real server survive; a server stopped through its own held record stops, and its
file is gone.

The CI smoke on windows-latest now stops the server with `pad server stop`
instead of Stop-Process, because that is the only place the Windows ownership
check runs — a smoke that killed the process directly would leave the
GetProcessTimes path unexercised on every platform.

make lint, make test green; codex CLEAN in round 4.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 19:33:15 -04:00