Commit Graph

136 Commits

Author SHA1 Message Date
xarmian 90a304765f fix(cli): copy --dry-run previews a big number with every digit (BUG-3456) (#1858)
The copy preflight decoded carried field values with plain json.Unmarshal
into `any`, so a number above 2^53 became a float64 and the dry run
previewed 9007199254740992 for a stored 9007199254740993. The copy itself
keeps the stored digits (server-side), so the preview misled exactly
where a user would check. Decode with models.DecodeJSONKeepingNumbers,
as BUG-3448 did for item show and the MCP resource.

The mutating copy's response is left on json.Unmarshal: it prints no
carried values (the item's fields are a string), and a decode error
there would misreport a committed copy.

Tests: the client keeps the digits, and the rendered dry run through the
real client shows them; on the old decode it renders
"= 9007199254740992".

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 12:37:49 -04:00
xarmian 3aff8e388f fix: pad keeps an edited /pad skill and never downgrades a newer one (BUG-3466) (#1857)
* test: pad init keeps an edited skill and does not downgrade a newer one (BUG-3466)

* fix: pad keeps an edited /pad skill and never downgrades a newer one; skill files carry a stamp, and --force replaces (BUG-3466)

* test: workspace init updates an older unedited skill, agent update reports a keep, --list names --force for a newer skill, and semver numbers have no size limit (BUG-3466, codex r1)

* fix: workspace init routes installed skills through the decision, agent update and --list report kept files with --force, and semver numbers compare at any size (BUG-3466, codex r1)

* test: --list flags an untracked edited skill, and agent update counts an untracked current one (BUG-3466, codex r2)

* fix: --list asks the decision for local skills, and every skill present after a write is recorded and counted (BUG-3466, codex r2)

* test: invalid SemVer never refuses as a downgrade; the frozen list pins a released text (BUG-3466, codex r3)

* fix: the frozen legacy list holds released output only, and SemVer is validated strictly before comparing (BUG-3466, codex r3)

* test: every unstamped release's skill output must be in the frozen list; the release workflow (which has the tags) fails loudly otherwise (BUG-3466, lead)

* test: whitespace after the stamp does not hide it; pinned real copies and per-tag formatter checks guard the frozen list independently (BUG-3466, codex r5)

* fix: whitespace after the stamp no longer hides it (BUG-3466, codex r5)

* test: a newer pad's identical text reads as current, not as a downgrade (BUG-3466, codex r6 declined with reason)

* chore: regenerate the legacy skill hashes with v0.18.0-rc.8 (BUG-3466)
2026-10-07 12:26:28 -04:00
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00
xarmian c204dd3683 fix(cli): json fields print as JSON, metadata labelled for the item's kind; MCP 0.68 (BUG-3448) (#1838)
* test(cli): json fields print as JSON, the metadata block is labelled for the item's kind, the update summary sizes structured values (BUG-3448)

* fix(cli): json fields print as JSON, the metadata block is named for the item's kind, the update summary sizes structured values (BUG-3448)

* test: large numbers, null, the MCP resource and unguessed labels (BUG-3448, codex r1)

* fix: numbers keep their digits, null prints as null, the MCP item resource prints json fields as JSON, metadata labels are not guessed (BUG-3448, codex r1)

* test(cli): the agent projection keeps large numbers (BUG-3448, codex r2)

* fix(cli): the agent projection keeps large numbers (BUG-3448, codex r2)

* chore(mcp): ToolSurfaceVersion 0.68, a BEHAVIOR bump: the item resource renders json fields as JSON (BUG-3448, lead ruling)
2026-10-06 18:54:37 -04:00
xarmian 8c8deeeb89 fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)

* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)

* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)

* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)

* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)

* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)

* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)

* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)

* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)

* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
2026-10-06 12:16:32 -04:00
xarmian 41be1bf113 feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c)

Items (the creating app), comments and version rows carry via_app (the
install id) and via_app_name (the bot's name, else the origin; kept after
uninstall) on the web API, the MCP get / list summary / history summary
projections, and the TS types, with WorkspaceApp naming the members apps
array. ToolSurface 0.65, additive.

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

* fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c)

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

* fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c)

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

* docs: README tool catalog at v0.65 (TASK-3413)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 04:49:25 -04:00
xarmian 67d06fdf81 fix(auth): a CLI session is never a browser cookie, and sign-in refuses a cross-site form (BUG-3350) (#1747)
(a) Sessions did not record how they were issued, and SessionAuth accepted
any padsess_ token from a cookie as a cookie session. isBearerAuth is
decided by transport, so an admin's CLI session sent as a Cookie picked up
the cookie-only platform-admin bypass that bearer use is denied (BUG-1616).
- Migration 105/079 adds sessions.kind ('web' | 'cli'). It backfills 'cli'
  from device_info 'cli-browser-auth' (lead's ruling: no forced re-login;
  older CLI password logins stay 'web' until they expire).
- The kind comes from the device label at creation.
- The CLI sends X-Pad-Client: cli, so its password and 2FA sign-ins mint a
  'cli' session and set no cookie. Rotation keeps the replaced session's
  kind.
- SessionAuth, validateSessionCookie and requestSessionInfo's cookie path
  accept only a 'web' session as a cookie. A CLI session still works as
  the bearer it is.

(b) /auth/login (and 2FA login-verify) were CSRF-exempt and decoded JSON
whatever the Content-Type, so a cross-site text/plain form could sign the
victim into the attacker's account. A sign-in must now be
application/json (415 otherwise), and an Origin the browser names must be
this server (its Host or base URL) or an allowed CORS origin, including the
localhost defaults the dev server uses (403 otherwise). Headless clients
send no Origin. An E2E leg pins the web app's own login form sending JSON.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 20:48:37 -04:00
xarmian a2e7768035 fix(comments): a deleted comment with replies leaves a tombstone (BUG-3252) (#1675)
* fix(comments): a deleted comment with replies leaves a tombstone (BUG-3252)

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

* test(comments): backdate so a tombstone moving updated_at is seen (BUG-3252)

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

* chore(mcp,store): v0.59 instructions heading, NUL baseline for comments.deleted_at (BUG-3252)

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

* fix(comments): reactions wait for a tombstoning delete; unreact on a tombstone answers 409 (BUG-3252, codex r1)

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

* fix(comments): unreact racing a tombstoning delete answers 409, not 404 (BUG-3252, codex r2)

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 00:41:20 -04:00
xarmian 237333681b feat(attachments): attach binds an unattached upload to an item, so its share renders it (TASK-2247) (#1652)
The July audit's share-page broken-image finding was fixed by BUG-2389 and
TASK-2637: the share byte endpoint serves variants of attachments the
shared item OWNS. What was left: an image uploaded with
`pad attachment upload -` (item_id NULL) and then embedded is never owned,
so its share shows the placeholder. A census of this workspace found 4
such refs out of 13, all from that path.

Lead ruling (option C): fix it explicitly, with no implicit binding on
content writes.

- POST /workspaces/{ws}/attachments/{id}/attach {item}. It binds a live,
  unattached original in this workspace, and its live variants, to an
  item. Every UPDATE requires item_id IS NULL, so an attached row is never
  moved, and the bind is exclusive with the never-attached GC claim. The
  item is pinned like CreateAttachmentForLiveItem.
- Authorization: the caller can see and edit the item, and uploaded the
  attachment or is a workspace owner. An attached row answers 409 to a
  caller who can see its owner, and the shared 404 to anyone else.
  Restricted members and guests cannot read unattached rows, so they get
  the 404. That closes the case where a guest binds an id they know to an
  item they can edit and publishes it through its share.
- The attachment_attach capability. `pad attachment attach <id> <item-ref>`
  refuses against a server that does not advertise it, because an older
  build's bare 404 would read as "attachment not found".
- The `upload -` help text, the attachment group help, both skill copies
  and CLAUDE.md say an unattached image does not render on share pages.
- No MCP surface: pad_attachment has no upload.

Tests: 12 server/store tests, including an end-to-end share leg (404
before, variant bytes after), and 9/9 mutants killed. CLI capability
gate: 2 tests, mutant killed. e2e: the share renders the image after
attach; with attach made a no-op it goes red at "attached: no
placeholder".

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-28 19:25:55 -04:00
xarmian 9b7e0832ac fix(comments): deleting a comment that has replies answers 409 comment_has_replies instead of 500 (BUG-3252) (#1608)
* fix(comments): deleting a comment that has replies answers 409 comment_has_replies instead of 500 (BUG-3252)

comments.parent_id references comments(id) with no ON DELETE, so deleting a
parent with replies failed the foreign key inside the store. Every door said
"internal error", the web delete button included, and nothing was deleted.

Per the lead's ruling (reversible with Dave's veto), the store now refuses
by name. The DELETE carries a NOT EXISTS on replies, so the refusal is part of
the statement itself, and a zero-row result is told apart by counting.
Nothing is deleted. Every door reports it:

- Both server routes answer 409 comment_has_replies with details
  {comment_id, reply_count}.
- The CLI root writes a structured marker, so stdio MCP reports the code
  and hint rather than server_error.
- Remote MCP passes it through the 409 allow-list with the same hint.
- The web timeline shows the server's message in place of "An internal
  error occurred".

MCP 0.58 (behavior bump, on the v0.45 grounds). Cascade versus tombstone
stays an open product decision; this refusal is the floor under either.

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

* fix(comments): a delete whose replies vanish between statements retries instead of reporting not-found (BUG-3252, codex r2)

Under Postgres READ COMMITTED the conditional DELETE and the reply count take
separate snapshots, so a reply the DELETE saw could be gone by the count, and
a comment that still existed was reported missing. The zero-row branch now
rechecks that the comment exists and, if it does, runs the conditional
delete again, at most three times.

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

* test(comments): pin the replies refusal on Postgres (BUG-3252)

The server and MCP tests run on SQLite, so the conditional DELETE had never
executed on Postgres. This test runs there (make test-pg). Against the bare
DELETE mutant it fails with comments_parent_id_fkey, which is the original
defect.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 09:11:48 -04:00
xarmian 0f1069e9df feat(comments): agents can edit and delete comments from the CLI and every MCP transport (TASK-2695) (#1607)
Web users could edit and delete comments since PLAN-1662. Agents, who write
most comments, could only append corrections.

- Server: new item-scoped routes, PATCH/DELETE
  /workspaces/{ws}/items/{ref}/comments/{id}. They answer 404 unless the
  comment is on {ref}, then run the existing handlers unchanged (edit is
  author-only, delete needs item-edit). GET /server/capabilities advertises
  them as item_scoped_comment_writes.
- CLI: `pad item comment-edit <ref> <comment-id> [message|--stdin]` and
  `pad item comment-delete <ref> <comment-id>`. Both refuse to send to a
  server without the capability, whose bare 404 would otherwise read as
  "comment not found". `pad item comments` now prints each comment's id,
  plus "edited" once its body has changed.
- models.Comment serialises a derived `edited` bool, true when
  updated_at > created_at (the web timeline's rule).
- MCP 0.57: pad_item edit-comment / delete-comment and a comment_id param,
  on stdio, remote /mcp and WebMCP. All three use the item-scoped routes,
  so the check that the comment is on ref happens on the server for every
  transport. Remote packages a delete's 204 as the CLI's JSON.

Found along the way, and out of scope: deleting a comment that has replies
answers 500, because parent_id has no ON DELETE. Filed as BUG-3252.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 07:59:11 -04:00
xarmian 2d51308d39 fix(collab): a schema-version rebuild sets unflushed edits aside instead of deleting them (BUG-3244 A) (#1598)
* wip(collab): the schema-version rebuild sets unflushed edits aside and the item stays marked (BUG-3244 A)

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

* wip(server): set-aside rows refuse every content write and restore unless overwrite_pending_edits, which discards and reports them (BUG-3244 A)

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

* wip: set-aside read/discard endpoints and pad item set-aside; census and migrated-table entries (BUG-3244 A)

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

* wip(store): set-aside edits travel in the workspace bundle; the migrate gate asks the op-log only (BUG-3244 A)

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

* wip: set-aside advice branches at every door that names a remedy; mark-only doors share one helper (BUG-3244 A, ruling 1)

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

* test(cli): set-aside notices name the right remedy (BUG-3244 A)

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

* docs: the rebuild sets unflushed edits aside; freeze reason, API docs and op-log prose follow (BUG-3244 A)

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

* test(web): identity-gate rows re-reviewed for the set-aside reason threading (BUG-3244 A)

The edits in each covered unit are a pure pendingEditsReason(e) argument or
string branch, or a reason passed to the dialog; no await, write or fence
changed. Three anonymous-unit matchers follow the new call text, and the
keepalive failure row lists the pure classifier in may.

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

* test(server): a restore override counts the set-aside rows it discarded (BUG-3244 A)

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

* fix(store): the rebuild sets aside the rows its DELETE returned, so an append between choosing and clearing is not lost (BUG-3244 A, codex r1)

On Postgres each statement takes its own snapshot and the setup lock does
not stop a connected peer appending, so a row committed between the old
INSERT ... SELECT copy and the DELETE was deleted without being set aside.
The kept rows are now chosen from DELETE ... RETURNING. A test hook in the
gap drives a Postgres leg; a mutant that chooses before the gap loses the row.

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

* test(web): playbook save row re-reviewed after the BUG-3230 U3 rebase (BUG-3244 A)

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

* fix(store): the set-aside move's first statement is the DELETE, so SQLite never upgrades a stale read snapshot (BUG-3244 A, codex r2)

Reading the watermark first took a read snapshot on SQLite; a concurrent
append would then make the DELETE fail with SQLITE_BUSY_SNAPSHOT instead of
waiting. The watermark is now read after the DELETE, where a later value is
only more accurate.

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

* feat(mcp): ToolSurfaceVersion 0.56, content_state gains superseded_set_aside (BUG-3244 A)

The value reaches pad_item get / full list, pad_playbook run and the item
resource's line above the body, and its remedy differs from
applied_pending_flush (opening the item will not restore the edits). An
additive bump with a version.go entry; README, CLAUDE.md and the MCP
instructions follow. Read/discard of set-aside rows stays off the catalog.

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

* test(server): after a bumped rebuild the next open is granted the seed once and the item stays superseded (BUG-3244 A x BUG-3240)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 05:40:55 -04:00
xarmian a537ba84e2 fix(auth): an admin-created registration mints no session, so creating an account no longer hands the admin a live credential for the new user (BUG-3232) (#1581)
handleRegister called createAuthSession on every path, including an admin
creating someone else's account. That minted a live 7-day session for the
NEW user and returned its token to the admin. For a cookie caller it also
set that token as the admin's cookie, and since BUG-3011 that destroyed the
admin's own row. Nobody held the new session, because the new user signs in
on their own: it was an orphaned impersonation credential.

The admin-created path (no invitation, admin caller) now answers 201 with
the user only: no session, no cookie, and no token. The audit event and
autoCreateWorkspace run as before. Invitation and cloud self-serve signups
are the new user signing up and keep their session; tests pin both.

The UI symptom BUG-3232 was first filed for is unreachable: /register and
/join/[code] never post a register with a live session. Also removes the
dead cli.Client.Register, which had no callers.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-26 15:27:50 -04:00
xarmian b459892d8b fix(search): the collection filter resolves exact-first per workspace in scope, so a collection named task is no longer shadowed by tasks (BUG-2659, BUG-2839) (#1576)
* fix(search): the collection filter resolves exact-first per workspace in scope, so a collection named `task` is no longer shadowed by `tasks` (BUG-2659, BUG-2839)

/search matched its collection filter as a literal slug, so shorthand
found nothing and both clients normalised it first, which rewrote `task`
to `tasks` even where a `task` collection existed. The handler now
resolves the filter with resolveItemCollectionSlug in every workspace in
scope and filters the store on the resulting collection IDs; one slug
cannot express that, since the same input can resolve differently per
workspace. The capability endpoint advertises
search_collection_resolution, and the CLI sends the name as typed only
to a server that does. The in-process remote MCP mapper stops
normalising outright.

Also corrects cascadeTitleRename's doc comment, which named
links.ReplaceTitle for a cascade that rewrites through
links.RewriteBracketsAt (BUG-2839).

* chore(mcp): tool surface 0.55 — pad_item.search's collection resolves exact-first per workspace (BUG-2659)

Lead-ruled BEHAVIOR bump on the v0.36/v0.40 grounds: in a workspace
holding both task and tasks, the same call now answers from task.
2026-09-26 12:20:58 -04:00
xarmian 9059862431 fix(session): a headless disarm holds for as long as its session lives: an unverifiable owner fails closed and is kept, and the file names the session, not the exiting command (BUG-2771, BUG-3227) (#1574)
* ci(smoke): a headless disarm on Windows must survive later reads (BUG-2771)

Added before the fix, so the first CI run shows it red against the
pre-fix binary.

* fix(session): an arm-state file whose session cannot be verified is kept and fails closed, so a disarm is never forgotten (BUG-2771)

The unknown owner verdict (every headless file on Windows; on unix an
unexaminable /proc entry or socket) was treated as dead: the next read
reaped the file and auto_arm re-armed over an explicit disarm. It now
resolves to LocalArmUnverifiable, which is not armed and not reaped.
`pad session arm` reports the real state, `status` explains it, and a
new `pad session reset` removes the kept file so auto_arm decides again.

* ci(smoke): the headless-disarm step covers unix too, owned by the step's shell via PAD_SESSION_PID (BUG-3227)

Pushed on top of the BUG-2771 fix but before BUG-3227's, so this run
shows the unix path red pre-fix while Windows turns green.

* fix(session): a headless arm or disarm is owned by the session process, not the exiting command, and a write no session can own is refused (BUG-3227)

A headless arm-state file recorded os.Getpid(), the pid of the
short-lived `pad session arm`/`disarm` itself, so on unix it was dead on
the next read and reaped: an explicit disarm never held. It now records
CaptureSessionOwner() (PAD_SESSION_PID, then CLAUDE_PID), as the session
registry does. With no session process to name, the owner is the
command, and arm/disarm refuse rather than report a consent change that
did not happen.

* docs(session): arm and disarm --help say how a headless session is identified, and that with no owner they exit non-zero (BUG-3227)
2026-09-26 11:55:16 -04:00
xarmian e422d6b183 fix(cli,web): the CLI activity surfaces apply the change-pill display rule through a Go twin sharing one fixture with the web, and the admin audit log expands a row to its full record (BUG-2789) (#1570) 2026-09-26 09:47:55 -04:00
xarmian 547527d7dc fix(server): Content-Disposition carries a non-ASCII or unquotable filename in filename* with an ASCII fallback, so NBSP and zero-width names no longer reach clients as Go escape text (BUG-3190) (#1569)
* fix(server): Content-Disposition carries a non-ASCII or unquotable filename in filename* with an ASCII fallback, so NBSP and zero-width names no longer reach clients as Go escape text (BUG-3190)

* test(server): the Content-Disposition population carries characters the extended form must encode, and the doc comment survives gofmt (BUG-3190)
2026-09-26 09:47:51 -04:00
xarmian d4ec587de9 fix(cli): a CLI call during an install-refresh bounce waits for the refresh instead of auto-starting a server over it (BUG-3215) (#1544)
install-refresh.sh stops the server, installs, and restarts it with the
captured argv. Any pad CLI call in between found nothing answering and
auto-started a server of its own: the caller's binary, cwd and default
flags. That server held the port against the refresh's restart (day 79:
127.0.0.1:7777 went dark and the script exited 1).

- The script writes <DataDir>/refresh.lock {pid, port, deadline} by rename
  before the stop and removes it in its EXIT trap, only while it is still
  its own. The deadline is 10s + PAD_PROBE_TIMEOUT (30s by default).
- EnsureServer defers to a live marker for its own port (writer pid alive
  AND deadline not passed): it polls health and never spawns. If the
  writer is still alive past the deadline it errors naming the pid. A dead
  writer, a passed deadline, another port or an unparseable file means
  the marker is ignored.
- The script names a holder that took the port before the restart spawns
  (and does not spawn it), and refuses success when the listener after
  the probe is not the restart it spawned. A health probe answers for
  whoever holds the address.
- The script-test dependency guard now reads every install_refresh*_test.go.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 09:55:48 -04:00
xarmian fd95b79694 fix(BUG-2367): moves and copies never carry into a computed field, and a carried unique-field collision is dropped and named instead of 500ing or storing a duplicate (#1514)
MigrateFields matched fields by key and type only. On every migrate door
(single move, bulk move, cross-workspace copy and its preflight):

- a value was carried into a destination field marked `computed`. It is
  now dropped, with preflight reason `target_computed`.
- a value colliding on a destination `unique_scope` field answered 500
  on single move (invocation_slug, which has an index), leaked the SQL
  error through bulk move, and stored a duplicate on an unindexed unique
  field. Provenance decides now, as TASK-2878 did for relation referents:
  a CARRIED collision is dropped and reported as `not_unique`, and an
  override or schema default that collides is refused with 409 conflict.
  A required field emptied by the drop is refused as required.

One store check (UniqueFieldConflictsQ / DropCarriedUniqueCollisionsQ)
serves all four doors, including the tx-bound copy. The report names the
value, and names the item holding it only when the caller may see that
item. The activity row always uses the holder-free sentence.

Reporting, all additive and omitempty: warnings.not_unique on the move
response, the copy response and bulk updated[] rows; `detail` on the
preflight's dropped row; CLI copy dry run and result, plus `  dropped:`
lines on `item move` stdout; web copy dialog, move and copy toasts, and
the timeline move card. ToolSurfaceVersion 0.51 (behaviour bump).

The relation leg of the original report was already closed by TASK-2878.
Item 4 (terminal/abandoned state change) is PR 2. Out of scope and filed
separately: BUG-3200 (missing needs_value routes) and BUG-3202 (float64
precision; the migrateCopyFields comment now points there).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 13:12:32 -04:00
xarmian df5531b815 fix(cli): pad server stop keeps a live legacy PID file instead of deleting it, and clears a dead one (BUG-2970) (#1485)
* fix(cli): pad server stop keeps a legacy bare-pid file instead of deleting a live server's record (BUG-2970)

On Unix, pidFileOwner read a free lock as proof that a record was stale, and
removed it. That holds for records this build writes, because their server
holds the lock for its whole life. It does not hold for a bare-pid file written
before BUG-2969: no build that wrote that form took a lock, so a free lock says
nothing about its server, which may be live. The day-19 refresh deleted exactly
such a record.

A record without a fingerprint (zero StartedAt, which is what a bare pid
parses to) is now unprovable and left in place, the answer Windows already
gave for the same shape. Fingerprinted records are unchanged. Three existing
fixtures modelled fingerprinted records without a StartedAt, which no build
writes; they now carry one.

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

* fix(cli): a legacy PID file naming a dead process is stale and removed; start is never blocked by a kept one (BUG-2970)

The kept, unprovable state now applies only while the legacy record's pid
exists. A pid with no process behind it cannot be anyone's server, so that
record is stale by the same proof as an unheld fingerprinted one, and is
removed under the lock. Stop then says "server not running" instead of
refusing forever.

Tests pin both halves of the way out. Start is never blocked: EnsureServer
probes the port, and the new server's ClaimPIDFile takes over a kept legacy
file and writes a fingerprinted record stop can address. And the kept
refusal names its manual remedy (remove <path>).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 23:26:24 -04:00
xarmian 71de56f6de fix(relations): a relation value stored as text reads as text, not as an unavailable target (BUG-3014, ToolSurfaceVersion 0.50) (#1484)
* fix(relations): a relation value stored as text reads as text, not as an unavailable target (BUG-3014, ToolSurfaceVersion 0.50)

HydrateRelationTargetsQ treated every stored relation value as an id. A value
that is not UUID-shaped (a title a bundle import carries verbatim, which is that
door's contract; or legacy free text) resolved to nothing and hydrated as an
id-only entry, which means "the target is gone or not visible". So an imported
workspace showed relations as broken when the title might name a live item.

Such an entry now carries an additive, omitempty `stored_as_text: true`. The
predicate is syntactic ("not UUID-shaped"), decided from the stored bytes alone
and never from a lookup, so the flag discloses nothing about any item, and it is
deliberately not coupled to the resolver's ladder (lead ruling). A UUID-shaped
value, resolved or not, marshals byte-identically to 0.49, and a UUID that fails
to resolve is never marked.

The CLI renders `"<text>" (text, not a reference)`. The web chip, which resolves
locally from its index rather than from relation_targets, uses the same
syntactic predicate and says the same. The MCP instructions tell agents to
render a marked entry as text.

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

* test(relations): one shared vector table pins the Go and web stored_as_text predicates together (BUG-3014)

The web carries its own copy of the not-UUID predicate, because it resolves
relation chips from its local index rather than from relation_targets. Two
implementations of one contract rule drift. Both now read
internal/store/testdata/relation_stored_as_text_cases.json (21 cases: upper
and mixed case, braces, no dashes, URN form, wrong dash positions, length off
by one, titles, refs, blanks, and padding), and their KEEP IN SYNC comments
point at each other.

The table found a real divergence: the web trimmed with String.prototype.trim,
which disagrees with Go's strings.TrimSpace on U+0085 (Go only) and U+FEFF (JS
only). The web now trims with serverTrimmedTitle, the Go-parity helper the title
limit already uses. Swapping JS trim back in fails exactly those two vectors,
and dropping Go's TrimSpace fails the five padded ones.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 22:50:47 -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 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 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 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 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
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
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 07c00e008e feat(agent): add compact item view 2026-09-16 12:00:11 -04:00
Matt Faltyn d6e4053aca perf(agent): reuse session context 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 cacf492d95 feat(agent): install compact shared skill 2026-09-16 12:00:11 -04:00
Matt Faltyn d89587b4a8 test(agent): add prompt payload budgets 2026-09-16 12:00:11 -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 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
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 524469b7a3 test: no test request goes through the process-global default transport (BUG-3008, closes BUG-2949) (#1324)
Closes BUG-3008. Closes BUG-2949, which filed this failure on 09-07 with the
mechanism explicitly undiagnosed and stopped in the right place: it refuted the
obvious candidate, noted a repo-wide grep found no other caller, and listed
three hypotheses. This is hypothesis (1), and the caller is the standard
library rather than this repo.

httptest.Server.Close() calls CloseIdleConnections() on the PROCESS-WIDE
http.DefaultTransport — deliberately, and the standard library says so in its
own comment, calling it "not part of httptest.Server's correctness". So in a
package where many tests each stand up a server, every `defer ts.Close()`
mutates state every other test's requests depend on. CI observed a request
failing in one test while the server that closed belonged to another:

    client_stream_identity_test.go:135: request not sendable:
      Get "http://127.0.0.1:45521/api/v1/events/stream?armed=true":
      net/http: HTTP/1.x transport connection broken: http: CloseIdleConnections called

SCOPE is every test request in internal/server, not just the parallel ones.
Three revisions were needed to get the class right, and each earlier boundary
was wrong in a way worth recording:

  - Direct call sites in test bodies missed SHARED HELPERS. connectSSE,
    apiRequest and readRawSSEFramesAuthed sent through the default transport
    and have parallel callers; connectSSE holds its request open for a whole
    test. A helper cannot know which of its callers is exposed, so it no
    longer tries to.
  - "Serial tests are safe" is true but is the wrong boundary. Serial-ness is
    one t.Parallel() away, and CONVE-2086 tells the next author to add exactly
    that line.
  - A nil Transport IS http.DefaultTransport. listen_serve_test.go's health
    probe was exposed identically and appears in no grep for DefaultClient.

internal/cli is deliberately different: its tests drive the PRODUCTION client
from NewClientFromURL, which has a nil Transport by design, so those sends
cannot be isolated without changing the shipped CLI. There the boundary really
is parallel-vs-serial, and the two parallel tests that build their own requests
use srv.Client(). The asymmetry is written into isolatedTestClient's doc
comment rather than left to be inferred.

WHAT IS NOT CLAIMED: Transport.CloseIdleConnections documents that it "does not
interrupt any connections currently in use", and the idle pool is mutated under
idleMu. The interleaving by which errCloseIdleConns reached a caller's Do() is
NOT reconstructed, and the comment says so instead of inventing one. That
unknown is the argument for isolation over a narrower repair: a client with its
own transport is out of reach whichever window it was.

CONVE-2086 amended with the rule, so the convention that creates the exposure
also carries it.

14 files, all _test.go. Codex: four rounds, CLEAN on the fourth; rounds 1-3
each found a real defect in the class boundary or in the comment's claims about
net/http. Gates on 8dfd6ad3: go test ./... 0, go vet 0, gofmt clean, lint 0
issues; all 8 CI checks green.
2026-09-10 16:59:58 -04:00
xarmian 4a4a912fa6 docs: pad token create needs a session, and one code comment said otherwise (TASK-2984) (#1316)
Since #1267 the server refuses a mint authenticated by an API token with HTTP
403 `session_required` — "Creating or rotating API tokens requires an
interactive session, not an API token" — because the tokens such a mint
produces outlive the revocation of the token that made them: each has its own
name and expiry, and nothing in `pad token list` records which token minted
which. `list` and `revoke` stay reachable by a PAT deliberately, since
revocation is the response to a compromised credential and should not need a
fresh login.

Verified against `handlers_tokens.go::requireInteractiveSession` before writing
it, as the item asked: the code and the message are quoted from there, and the
guard is `isAPITokenAuth`, which is false for a session cookie AND for a saved
`padsess_` CLI bearer — a CLI session IS an interactive session.

Three artifacts a reader consumes carried the now-false implication; two are
fixed here.

- The README's PAD_TOKEN section said the override authenticates "without `pad
  auth login`" and named `pad token create` as where tokens come from, which
  together read as "you can mint under the override". It now names the one
  exception and links to the paragraph.
- The token-management section gets that paragraph: which subcommands need a
  session, the exact code and message, why, and that `pad auth login -i` is the
  headless path.
- `internal/cli/client_tokens.go` claimed the PAD_TOKEN override was "usable
  end-to-end without a browser". True when written and false after the gate.
  Corrected in place with the reason and a pointer to the guard, rather than
  deleted — a comment that was true once is worth more as a dated correction
  than as a gap.

THREE CLAIMS I HAD WRONG, all caught before merge and all by checking rather
than by rereading:

- "needs a terminal but not a browser" — `pad auth login` DEFAULTS to
  browser-based auth; `-i` is the email/password prompt. The README and the
  comment name the flag now.
- "fails when `PAD_TOKEN` is set" — too broad. `PAD_TOKEN` accepts a `padsess_`
  session token as well as a `pad_` API token (`env_token.go:21-23`), and the
  session form mints normally. The gate is on the CREDENTIAL KIND, not on the
  variable.
- The history was compressed into "#879, before #1267". #879 added the override
  and left minting web-only; #1237 (f262449b) added `pad token` so the CLI could
  mint at all; #1267 (cdc5b301) then gated it. Both commits verified by
  `git log -S` rather than taken from the review that flagged it.

Deliberately not done: no help text added to `pad token create`. The 403's own
message already names the cause and the fix at the point of failure, and the
item scoped this to documentation. `CLAUDE.md:127` describes `pad auth login`
as an email/password prompt, which is wrong about today's code — that is a
separate one-line change, not this one.

Gates: go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean, vitest
153 files / 2342 tests passed (unaffected — the only code change is a comment).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 03:49:38 -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
xarmian 5ec17a7c92 fix(cli): pad server stop stops the server that is running, or says it is (BUG-2965) (#1298)
fix(cli): `pad server stop` stops the server that is running, or says it is (BUG-2965)

`StopServer` read the PID file and, on any read error, answered "server not
running (no PID file)" — without asking whether anything was listening. The file
was written in exactly one place, EnsureServer's auto-start branch, so a server
started any other way held the port with no file to find: a service unit, a
human running `pad server start`, the seats' refresh recipe relaunching with the
killed process's argv. A stop command that says "not running" about a running
process leaves the caller believing they stopped something, and the next thing
they do rests on that belief.

Two halves, per the item's property and corollary:

  - A missing PID file now asks the port. Only an unhealthy address earns "not
    running"; a healthy one earns a message naming the address, the missing
    file, and what to do instead. Deliberately NOT "find the listener and kill
    it" — resolving a pid from a port is platform-specific, and the process
    holding it may not be ours. A stop that kills by port can kill a stranger.
  - `pad server start` claims the PID file itself, so the file exists for every
    start path rather than only the auto-started one.

The second half took four codex rounds to get right, and each round found the
previous shape reintroducing the defect it was fixing:

  1. Write-then-defer-remove let a duplicate start overwrite a running server's
     entry and then delete it on the way out, leaving a healthy server
     unaddressable.
  2. Refusing to replace a live pid fixed that and opened its mirror: the start
     that LOST the port could still own the file, so the winner was unaddressable.
     The fix is ordering, not arbitration — BIND FIRST, then claim, so the file
     always names the process that owns the address. internal/server grows
     Listen and Serve for that; ListenAndServe is now the two together.
  3. With the bind first, EnsureServer's parent-side write became the stale
     mechanism (it records a child that may never bind) and the live-pid refusal
     became actively wrong (no live process can be serving an address we just
     bound). Both removed, along with processIsAlive, whose only remaining
     callers were its own tests.
  4. Cleanup is a read-then-remove, so running it AFTER the listener closes let
     a successor bind and claim between the two steps and lose its file to us.
     It now runs before the listener closes, while nothing else can legitimately
     own the file. The cost is a drain-window where a healthy server has no PID
     file and `stop` says so — a true message in place of a silent wrong one.

Verified live against the built binary, in a throwaway HOME, in both shapes:
start writes the file naming the serving process; a second start against the
held port fails at bind and leaves the first server's file intact; stop then
stops it and removes the file; a further stop reports "not running". The first
live run also caught a flaw in my own method — `stop` reads the config's port,
so the probe answered about 127.0.0.1:7777 (this box's dev server) until it was
re-run with PAD_PORT set. Re-checked after the restructure.

Mutants: the health check removed, the health branch still answering "not
running", an empty PID file, a cleanup that does not remove, and a cleanup that
removes a successor's file are each killed by a named test. The call site itself
is wiring a unit test cannot vouch for (CONVE-19) — that is what the live runs
cover, and the Listen/Serve split is pinned in internal/server.

make lint, make test green.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 18:26:32 -04:00
xarmian 7659ad3cd3 feat(server,web,cli): say when a relation's copy target is unusable, instead of offering a picker that cannot answer (IDEA-2899) (#1262)
* feat(server): the copy preflight says when a relation's target is not usable (IDEA-2899)

TASK-2869 made a `needs_value` relation row collectable as soon as it names
a target collection. Naming one is not having one: the slug can name a
collection that has been DELETED, or one this caller cannot READ. The dialog
then mounts a picker that can return nothing and, because the row is not
blocked, Confirm stays disabled carrying only the generic required-field
message — the user is told a value is missing and never told that no value is
reachable.

`collection_unavailable` on the needs_value row is the server saying so.

THE CLIENT CANNOT COMPUTE THIS, which is why it belongs here. The dialog's
destination collection list is filtered through `canEditCollection`, because
it drives the copy-INTO picker; a relation TARGET needs only READ access, so
a perfectly usable target routinely does not appear in that list. Testing
against it would refuse rows the user could have filled in — over-blocking,
which is the worse failure and invisible to whoever hits it.

`visibleCollectionIDs` is the read-scoped view, and its NAV-LENIENT shape is
right here rather than merely tolerable: it includes a collection reachable
only through an item-level grant, and the question is "could a picker here
return anything at all". One granted item is a picker with one row.

DELETED and UNREADABLE are deliberately not distinguished. Same consequence,
no client branch would differ — and separating them would tell a caller who
cannot read a collection that it nonetheless exists.

`omitempty` on a BOOL drops `false`, so the field is phrased NEGATIVELY.
Present-and-true means the server checked and the target is unusable; ABSENT
means available, or a server that does not report. A client must block only
on an explicit true, so absence stays "no information" rather than becoming a
value — the rule `access_epoch` follows on the item doors, and the one whose
violation cost two review rounds on IDEA-2898 this morning.

Costs nothing on the common path: a destination schema declaring no relation
field runs no query at all.

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

* test(server): pin the type gate on collection_unavailable (IDEA-2899)

Found by a surviving mutant rather than by inspection: dropping the
`def.Type == "relation"` gate left every other test in the file green.

Nothing stops a schema declaring `collection` on a field of another type — the
validator does not police keys it has no use for — and such a field would then
pick up a flag whose meaning is defined only for relations. The dialog would
block a perfectly collectable `select` because some relation elsewhere in the
same schema points at a collection that happens to be gone.

The fixture is the discriminating one: ONE deleted collection, TWO required
rows that name it, and only one of them means anything by it.

Six mutants on this half, all killed: flag never set, flag always set, deleted
target not flagged, unreadable target not flagged, type gate dropped, and the
nil-visible-set case (an admin's "no filtering" read as "nothing visible",
which would flag every target for the callers who can see everything).

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

* feat(web): block a relation whose target is unavailable, and stop advising a command that cannot work (IDEA-2899)

The client half. `isCollectable` now refuses a relation row the server has
flagged, so the row lands in `blockedFields`, Confirm is disabled with a
reason, and no picker mounts that could only come back empty.

`collection_unavailable !== true` is STRICT on purpose. The field is absent
when the target is fine and absent from a server that predates it, so absence
must read as "no information". (Over the domain the type admits — `boolean |
undefined` — the truthiness spelling is EQUIVALENT and a mutant swapping it in
survives; that is recorded in the source rather than papered over with an
off-contract fixture. The strict form is kept because it states the contract
where the next edit will read it, and the inverse spelling would block every
row against an older server.)

THE PART THAT IS NOT WIRING: the existing blocked-field notice said the field
"is a required <type> field. This dialog can't collect a value for that type
safely" and then printed `pad item copy … --field key=value`. Both halves are
FALSE here. The type is perfectly collectable; the TARGET is gone. And the CLI
runs as the same user against the same referent validation, so the command it
prints is refused for exactly the reason the user is already stuck — advice
that sends someone to do work that cannot succeed is worse than no advice.

So the message branches on `uncollectableReason`, names the collection and the
destination workspace, and the CLI line is now gated on `cliFillableField` —
the first blocked row the CLI can ACTUALLY fill. `blockedFields[0]` was
correct while every blocked row was type-shaped; with an unavailable relation
sorted first it named the one field `--field` cannot set either.

Eleven unit tests on `copyNeedsValue`, plus a source pin on the dialog whose
own measured limit is in its docblock. Client mutants: 7 real, 6 killed, 1
recorded as equivalent with the domain argument that makes it equivalent.

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

* feat(cli): the copy preview marks an unavailable relation target and stops suggesting it (IDEA-2899)

Caught by `TestItemCopyMirrorsMatchServerShapes`, not by me. The CLI keeps a
mirror of the preflight response, and adding a field server-side without
mirroring it fails that test by design — a mirror that silently lags is a
mirror that lies. Working exactly as intended, and the reason this half exists
at all.

Mirroring the field turned out to be the smaller part. The CLI already prints
`target collection: people` for a relation row, and it builds an
`Add: --field owner_ref=<value>` suggestion from every unsupplied row. Both
are wrong when the target is unavailable: the first sends a user looking for a
ref in a collection they cannot read, and the second hands them a command the
referent validation refuses for exactly the reason they are already stuck.

So the target line is marked NOT AVAILABLE, and the row is excluded from the
suggestion with a sentence saying why — modelled on the empty-key branch,
which was written for the identical reason (a `--field =<value>` nobody can
run) and is three lines away.

That the same defect had to be fixed in two places is the shape worth naming:
the dialog and the CLI independently built "here is how to supply it" from
"here is a field needing a value", and neither had a notion of a field that
CANNOT be supplied. The empty-key case was the first instance and was fixed
locally; this is the second.

Five mutants on this half, all killed: suppression removed, suppression
applied to everything, the unavailable label dropped, the explanation dropped,
and the mirror field ignored. The available-target control leg is a separate
test so the omitempty contract is exercised on this surface too.

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

* fix(cli): route all three "how to supply it" sites through one predicate (IDEA-2899)

Review found the fix applied at one door and not its siblings — my own
recurring shape, arriving again.

THREE places tell a CLI user how to resolve an unsatisfied field: the detailed
`renderItemCopyNeedsValue`, the `--dry-run` summary, and the error the command
returns. The first commit fixed the render. The other two went on printing
`--field key=value` at someone for whom no value exists — and the ERROR is the
line a script or a hurried reader actually sees, so it was the worst of the
three to leave.

`itemCopyUnfillable` is now the single definition all three consult. Not
because three call sites are tidier than one, but because three sites
independently answering "how do I supply this" is exactly how they diverged in
the first place.

The dry-run summary branches three ways rather than two, because the MIXED
case is the one a boolean gets wrong: some fields can be supplied and some
cannot, and collapsing that either suppresses advice the user needs or offers
advice they cannot use. The error hint is suppressed only when NO field can be
supplied — with one fillable field left, `--field key=value` is still true.

Also pins the BOUNDARY the same review probed: a target collection that is live
and readable but EMPTY is deliberately not flagged. The symptom looks
identical — an empty picker — but the cases differ where it matters. An
unavailable target is unfixable from inside the dialog, so blocking costs the
user nothing they had; an empty collection is resolved by creating the item and
retrying, and blocking would refuse a copy they were about to complete. It
would also cost a live-visible-item count per relation target on a dry run the
UI calls on every keystroke. The weaker case — an empty picker that says
nothing about WHY — is filed as IDEA-2905 and belongs to the picker.

Ten mutants across this round, all killed, including both directions on the
error hint and both directions on the dry-run branch.

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

* fix: unfillable means EITHER reason, and a select never names a relation target (IDEA-2899)

Review round 2, two findings, both real and both about a rule stated in one
place and enforced in another.

**"Unfillable" answered for one of two reasons.** An EMPTY KEY cannot be
supplied either — `--field =value` is rejected by this command's own parser,
and the detailed render has explained that since Codex round 6. Only that
render knew: the --dry-run summary and the returned error went on advising
`--field` for those rows, because the predicate I extracted last commit covered
the relation reason alone. A predicate named "unfillable" that answers for half
its name is a worse trap than no predicate — right at the site that defined it,
wrong everywhere it was reused, which is precisely what extracting it was meant
to prevent.

Two functions now: `itemCopyUnfillable` (either reason — advice), and
`itemCopyUnavailableTarget` (the relation half — the render's own sentence,
since the two explanations are not interchangeable to a reader).

`itemCopyUnavailableTarget` deliberately does NOT also exclude empty keys,
though my first version did. A row can carry both faults, and a mutant removing
that exclusion survived every test — correctly, because all it changes is
printing two sentences that are both TRUE about such a row. The guard was
tidiness dressed as a rule; a condition nothing can distinguish is one the next
reader has to re-derive.

**`Collection` was emitted for non-relation fields**, while its own doc said it
is empty for every other type. That was a claim about the schemas people write,
not a property of the code: a `select` carrying `"collection": "people"` is
storable — field validation has no use for the key and does not police it — and
the value was copied straight through, so the CLI printed "target collection:
people" beneath a select. A relation fact asserted about a field that has none.
`relationTargetSlug` makes the documented contract true at the only place that
can make it true; my own type-gate test had created exactly that shape and
asserted only the FLAG, not the slug.

Three mutants on these fixes, all killed.

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

* fix(cli): the explanation now names the reason that actually applies (IDEA-2899)

Review round 3, and the sharpest miss of this unit — my own, one commit old.

Broadening what a predicate ACTS on silently broadened what a sentence SAYS.
Once `itemCopyUnfillable` counted empty keys as well as unavailable relation
targets, a set of empty-key rows selected the all-unfillable branch and was
explained as "the relation target is not available to you" — a false statement
about rows that contain no relation at all. Same in the returned error, which
is the line a script sees.

The tell was there to be read: a sentence that was TRUE while the predicate was
narrower is a sentence to re-read the moment it widens. I broadened the
predicate deliberately, wrote a commit message about how a half-answering
predicate is a trap, and left the sentence describing the half.

`itemCopyUnfillableWhy` names the reasons actually present — relation targets,
empty keys, or both — and the two one-sentence sites consult it. The detailed
render is unchanged: it explains each reason where the row is printed, which is
why it uses the narrower count.

Four mutants, all killed, including the two that matter: the explanation always
saying "relation" (the defect) and never saying it (the same defect pointing the
other way). The test carries a mixed-reason leg, because a sentence that picks
one of two true reasons is the failure a single-reason fixture cannot see.

Also corrected: three comments claiming `itemCopyUnfillable` is relation-only
or that the detailed render consults it. Both stopped being true last commit.

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

* fix: one row can carry both faults, and four docs said this was simpler than it is (IDEA-2899)

Review round 4. Four findings, no P1s, and the first is the one worth the round.

**A `continue` between the two counts.** `itemCopyUnfillableWhy` counted a row
as an unavailable relation target and then skipped the empty-key check, so ONE
row carrying both faults reported only the first. My mixed-case test used TWO
rows with one fault each — a different input, and the only one it exercised.
Two rows with one fault each and one row with two are not the same fixture, and
I built the weaker one while writing a commit message about fixtures that
cannot discriminate.

**The dialog could still print `--field =value`.** `cliFillableField` excluded
unavailable relation targets and not empty keys, so a required `json` field the
destination reported with no key was type-shaped, blocked, and still offered a
command the CLI's own parser rejects. The CLI has refused those since Codex
round 6; the web side had never learned it. Same defect, other surface —
which is the third time this unit has fixed one door and not its sibling.

**Cardinality.** "no --field can supply it" for several fields, and "reported
them with an empty key" for one. Both sites now agree with their counts, and
the empty-key phrase is neutral on number so it reads correctly after either.

**Four documents claimed every needs_value row is resolvable with an override**
— the CLI renderer's docblock, the server's `NeedsValue` field, the CLI mirror
type, and the dialog's collectability comment. That was true when each was
written and this unit falsified all four; a reader following any of them would
conclude the CLI had simply forgotten to print a flag.

Two mutants on the fixes, both killed: the `continue` restored, and the
dialog's empty-key exclusion removed.

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

* refactor(cli): one tally, because the rounds said the branching was the problem (IDEA-2899)

Four review rounds returned 2, 2, 2 and 4 findings. The counts looked like slow
convergence; the DISTRIBUTION was the finding. Every defect after round 1 lived
in this one layer — how the CLI and the dialog say "here is how to supply it" —
while the server half that computes availability stayed clean throughout.

The layer had accreted exactly the way IDEA-2898's cold path did: a count, then
a second count for the other reason, then a phrase function, then a `continue`
between two counters that made a dual-fault row report half of itself. Round 4
fixed something round 3 introduced to fix something round 2 introduced. That is
not a run of bad luck, it is a shape.

So this round removes branches instead of adding a seventh guard.
`itemCopyTally` walks the rows once and returns what every caller needs;
`AllUnfillable()` is the condition both one-sentence sites test, and `Why()` is
the phrase both interpolate. Three helpers become one type. There is no second
definition of "unfillable" to drift from the first, and no sentence describing
a subset of what a predicate counts, because the sentence and the count come
from the same walk.

`Unfillable` is deliberately NOT `UnavailableTarget + EmptyKey`: one row can
carry both, and double-counting makes `Unfillable == Total` false for a set
that is entirely unfillable — the comparison every caller makes. A mutant does
the addition and dies.

Five mutants, all killed. The last needed a new test rather than a new fixture:
`AllUnfillable`'s `Total > 0` guard is unreachable from both current callers,
so a mutant removing it survived every command-level test. Keeping an
unreachable guard and calling it defence is how a promise becomes a lie, so the
tally is now unit-tested directly — an empty set is not "entirely unfillable",
and a future caller outside the `len() > 0` gate would otherwise be told
silently that nothing can be supplied.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 15:48:22 -04:00
xarmian 07b2e439f2 TASK-2869 (U2b): the preflight names a relation's target collection, and the copy dialog scopes its picker to the destination (#1258)
* feat: the preflight's needs-value row names its relation target, and the copy dialog scopes its picker to the destination (TASK-2869)

U2b, per the day-55 ruling. Both blockers (U1 referent validation, U2 the
FieldEditor branch + picker) are in.

THE DEFECT. `ItemCopyPreflightNeedsValue` carried `type: "relation"` and no
target. FieldEditor gates its relation branch on `wsSlug` AND
`field.collection`, so a required relation in the destination reached the copy
dialog as a field it knew was a relation with no idea what to point at, and
rendered as FREE TEXT. Before U1 the copy stored whatever was typed.

SERVER. `collection` is added to the needs-value row, populated from the
DESTINATION schema's `def.Collection` — the only place it is known, since the
row is built from that schema. Additive and `omitempty`: a client that does not
read it is unaffected, and a row for a non-relation field is byte-identical to
before. Not a wire-version question, for the same reason
`models.ItemWriteWarnings` was not.

CLIENT. `toFieldDef` carries the collection through, and the FieldEditor call
passes `wsSlug={destWs}` — the DESTINATION, never the source. A relation
resolves at the destination, so the picker must list items the copy can
actually point at; that is same-workspace resolution AT the destination, not
the cross-workspace case PLAN-2857 rules out.

`relation` becomes collectable ONLY IF THE ROW NAMES ITS TARGET. Without a
collection, FieldEditor's gate renders the non-editable state, so offering the
row would produce a control that cannot be filled and a Confirm that cannot be
satisfied. Such a row now lands in the blocked list and the user is told which
field and why — the same disposition `multi_select` gets, for the same reason:
a control that silently cannot do its job is worse than an honest refusal.

TWO THINGS THIS UNIT TAUGHT ME THAT ARE NOT IN THE RULING.

1. THE WEB MUTANT SURVIVED, AND THAT IS WHY `isCollectable` MOVED. My first
   version left the predicate inline in `CopyItemDialog.svelte`. Making
   `relation` unconditionally collectable — the exact defect the negative leg
   of the proving test is about — passed EVERY suite in the repo. That is
   IDEA-2894's lesson arriving one unit later in the same file, so the
   predicate now lives in `$lib/items/copyNeedsValue` with tests. Two mutants
   die there: relation-always-collectable, and `multi_select` slipped into the
   collectable set.

   The Go half was pinned from the start (drop `Collection: def.Collection` ->
   FAIL naming the empty value and the expected slug). Only the client half was
   unpinned, and only because of where the code lived.

2. A U2-ERA TEST ASSERTED THE ABSENCE THIS UNIT CLOSES, and asserted it
   CORRECTLY. `fieldEditorRelationCallers.test.ts` required that the dialog
   pass no `wsSlug` and build its FieldDef from a shape with no `collection` —
   which was the behaviour, and withholding `wsSlug` was what kept an unscoped
   picker out. It also named this task by ref and told its successor to revisit
   the gate WITH the change rather than let it drift. Inverted here: the block
   now asserts the destination slug is passed, that the collection reaches the
   FieldDef, and — the half that is easy to lose — that the dialog still
   DELEGATES the collectability decision, so a future inlined predicate would
   pass the unit tests and fail this.

   Worth keeping: a test that pins a temporary absence should name what would
   make it wrong. This one did, and that is the only reason its inversion was a
   five-minute job instead of an argument about whether it was load-bearing.

Gates: `internal/server` ok 158.335s, `go vet` and `gofmt` clean,
`npm run check` 0 errors (6 pre-existing warnings), `make web-test` 127 files /
2123 tests. Postgres and CI are owed on this tip.

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

* fix(cli): mirror the needs-value collection, and name the relation target in the CLI (TASK-2869)

TWO CONSUMERS I DID NOT SWEEP. The previous commit added `collection` to the
preflight's needs-value row and updated the server struct and the TypeScript
type — "both sides", as its own message put it. There are THREE sides.
`internal/cli` keeps a mirror of the preflight shape and
`TestItemCopyMirrorsMatchServerShapes` requires it to match the server field
for field. It failed in the Postgres gate, in a package the change did not
touch.

That is the third time this session a producer change broke a consumer I had
not enumerated, and the shape is always the same: I name the surfaces I edited
and call that the population. The instrument that would have caught it is not
"run more tests" but "grep for the type's name before claiming the sweep is
done" — `ItemCopyPreflightNeedsValue` appears in exactly three files and I
looked at two.

Mirrored, with a comment saying why it exists: a mirror that silently lags is a
mirror that lies, and the CLI renders these rows.

AND THE CLI NOW NAMES THE TARGET COLLECTION, which is the point of the unit on
the surface that has no picker at all. A row reading

    owner_ref            (Owner, relation) required — required, with no value…

tells a user a value is needed and nothing about what kind of value exists.
The dialog answers that with a scoped picker; the CLI had no answer. It now
prints the relation analogue of the `options:` line a select already gets:

    owner_ref            (Owner, relation) required — …
                           target collection: people

Test asserts the line appears for the relation row, appears EXACTLY ONCE with a
select row rendered alongside — so it cannot pass by printing unconditionally —
and that the select's own `options:` line still renders, so this did not
displace it.

Gates: `internal/cli` and `cmd/pad` green, build and gofmt clean. The full
Postgres run and CI are owed on this tip; the earlier PG run is the one that
caught the mirror and is superseded.

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

* fix(web): the relation picker searches the preflight's canonical destination slug (TASK-2869)

Codex review, finding 3 of 3, and the only one of the three that belongs in
this unit.

`wsSlug={destWs}` handed `FieldEditor` a value that is NOT always a workspace
slug. An item can be opened through a workspace-UUID URL, the route parameter
is passed straight through as `sourceWsSlug`, and a same-workspace copy then
puts that UUID in `destWs`. `/search` resolves a workspace by SLUG only, so a
picker handed a UUID searches nothing and returns no results — a control that
looks usable, is not, and says nothing about why.

Now `pickerWsSlug`, which is the preflight response's own
`destination.workspace_slug`. The preflight IS the canonicalising round-trip:
the server resolved whatever it was given and answered with the real slug.
Falls back to `destWs` only before the first preflight returns, at which point
no needs-value row is rendered anyway.

The caller test asserts the prop AND the derivation, because asserting only the
prop would pass against a `pickerWsSlug` that was just `destWs` renamed.

THE OTHER TWO FINDINGS ARE REAL AND ARE FILED, NOT FIXED HERE.

IDEA-2898 — `ItemPicker` serves warm local-index results without re-authorising
them, so a collection whose access was revoked can still be listed. The cold
`/search` path is visibility-filtered and correct; the warm path is not. This
is PRE-EXISTING and applies to every caller of the picker, `ItemDetail`
included — last touched by TASK-2877, not by this unit. U2b widened the
exposure by adding a caller; it did not create the defect, and rewriting the
picker's cache-authorisation model inside a feature branch would be an
unrelated change riding along. The fix needs a decision about where the client
learns its access set from, which no current signal provides.

IDEA-2899 — a relation row can name a target collection that is DELETED or
UNREADABLE, so `isCollectable` says yes on a non-empty string and the user
meets a picker with nothing in it. I could not fix this correctly here, and the
reason is measured rather than assumed: the obvious test is to check the target
against `destCollections`, and that list is filtered by `canEditCollection` —
it is what the user may copy INTO. A relation TARGET needs only READ access, so
a perfectly usable target routinely is not in it. Using it would OVER-BLOCK,
refusing rows the user could have filled, which is a worse failure than the one
being fixed and invisible to whoever hits it. The right shape is probably the
server reporting the target's availability on the row it already builds — an
additive field on the same row this unit just changed, worth doing deliberately
rather than bolted on at the end of a branch.

Gates: `npm run check` 0 errors (6 pre-existing warnings), `make web-test` 127
files / 2123 tests. Postgres is running on this tip; CI is owed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-05 21:37:44 -04:00
xarmian 02846a6785 fix(cli): the session's registered agent is the name its writes carry (BUG-2882) (#1248)
* fix(cli): the session's registered agent is the name its writes carry (BUG-2882)

Two seats booted under one name; one re-registered under the right one
with `pad session register --agent`; every write it made afterwards still
carried the wrong name. The registry row and $PAD_AGENT were two
self-declarations of "the same value" — the row could be rewritten, the
environment could not, and nothing reconciled them or warned. Night 10
read both live seats inverted against each other.

ResolveAgentName now consults the registry record for the session that
owns this process FIRST: a stat-and-read of one file, no MkdirAll, no
lock, ignored when malformed, legacy, or carrying a different
process-start token (pid reuse). A non-empty registered name wins over
.pad.toml and $PAD_AGENT; an anonymous row leaves an environment name in
force. `pad session register` without --agent keeps the current name, as
before, because its default is the resolver. Help text, the record's doc
comment and README's precedence list say what is now true.

Test: register as rook under PAD_AGENT=wren and a .pad.toml name → rook;
default re-register → rook; anonymous → the .pad.toml name; a record for
this pid with another process's start token → ignored. The registry-step
mutant fails the first two assertions.

Fixes BUG-2882

* fix(cli): a registry record names this session only when it is verifiably this session's, and the identity tests stop reading the real registry

Codex round 1 on #1248. (1) registeredAgentForThisSession compared
process-start tokens only when both sides had one, so a stale row with no
token under a reused pid — a dead session's — would have named a live
one. Fail closed: when this process can read a token, the record must
carry the same one; and the record must pass the same OwnerLiveness
verdict `pad session list` applies. The token-less case is now a test
row, with a positive control after it. (2) The pre-existing resolver and
header tests cleared PAD_AGENT/CLAUDECODE but not HOME or the session-pid
variables, so run inside a registered seat they would have read that
seat's row as the resolver's first answer. They now run from a scratch
HOME with no session identity.

Refs BUG-2882

* fix(cli): a registry row names this session only if its owner is this process or an ancestor, where that can be checked

Codex round 2 on #1248. (1) TestPushItemSendsResolvedAgentHeader was the
one identity test round 1's hermeticity fix missed; it now isolates HOME
and the session env like its neighbours. (2) The registry step accepted a
row whose owner pid was alive and token-matched but NOT this process or
an ancestor — a misconfigured CLAUDE_PID pointing at a sibling session
could borrow that session's name. Refused where the platform can walk
ancestry. Not gated on PIDVerified: CaptureSessionOwner records
"cannot check" and "checked and wrong" as the same false, and the flag
alone would have disabled the step on every non-Linux platform. The
check is re-run and only a checked-and-wrong answer refuses. Test uses a
live non-ancestor child as the claimed owner; the refusal-dropped mutant
lets it name us.

Refs BUG-2882

* docs(cli): session list help — a row's name outranks PAD_AGENT; change it with --agent, not by re-registering (BUG-2882, codex round 3)
2026-09-04 12:50:50 -04:00
xarmian b437cc582d feat: item reminders — the fire-at-an-instant primitive, and one overdue rule for all four surfaces (IDEA-2641, closes #1010) (#1244)
* feat(store): item reminders — the fire-at-an-instant primitive (IDEA-2641)

Adds the storage, the scheduler tick, and the canonical event for one-shot
item reminders (GitHub #1010). Nothing in Pad acted at a target time before
this: a due_date makes an item show up as overdue once somebody asks the
dashboard, so "revisit TASK-X on the 1st" had to live in an external cron.

A TABLE, NOT A SCHEMA-FIELD ANNOTATION. The design sketch proposed marking
schema date fields with a `reminds: true` key on models.FieldDef; recon
overturned it. Such a key does not survive an ordinary collection edit, two
independent ways: the web editor destructures each field into an EditableField
and rebuilds a fresh definition key-by-key on save, so unknown keys are
dropped (`pattern` and `unique_scope` survive only because two lines were
hand-added for them), and models.CollectionSchema has fixed fields with no
catch-all, so any Go unmarshal+marshal round-trip strips unknown properties —
the hazard retargetRelationFieldsTx mutates raw JSON to avoid. Both failures
are silent and both disarm a whole collection's reminders at once. It is the
same defect class that moved traits out of the schema column in TASK-2657.

The table also gives the lifecycle a home. A reminder is armed, then fired,
then acknowledged, and a re-arm returns it to armed — per-reminder state a
field definition has nowhere to keep.

remind_at is an RFC3339 UTC instant, deliberately not a `date` schema value:
those admit both YYYY-MM-DD and full RFC3339 and are compared against the
SERVER'S LOCAL calendar day. A fire-at time cannot carry that ambiguity. The
remaining timezone question for due_date is filed separately.

Firing is one transaction per reminder carrying BOTH the fired_at write and
the outbox insert. That pairing is the point: a fired_at committed without its
event is a reminder that silently notifies nobody and can never be retried,
because the row has left the armed set; an event without fired_at fires every
tick forever. The UPDATE's own `fired_at IS NULL` predicate is the arbiter, so
two instances ticking at once produce exactly one winner.

item.reminder_due is admitted to the closed events/1 set as v1.2, with a new
PayloadReminder family and no SSE name. The subject is the REMINDER, not the
item: two reminders can be armed on one item, so an item-subject event could
not say which fired, and the reminder id is what an acknowledgement addresses.
A new payload family rather than reusing the item snapshot for the same
reason — a snapshot would validate and still not answer the only question the
event exists to answer. No SSE name in v1 because the poll surface is the
contract; adding one later is additive, removing one is not.

Ack is explicit and nothing else acks. An item reaching a terminal status
deliberately does NOT ack: that would make every status write a reminder
mutation, and it would silently consume a reminder set to fire after the work
was done.

* feat(server): reminder surfaces, and one shared overdue rule for all four

Second half of IDEA-2641: the HTTP surface, the scheduler tick's wiring, and
the fix for the finding that justified the unit — `ready` / `next` did no date
handling at all.

OVERDUE NOW HAS ONE IMPLEMENTATION. It used to live inline in the dashboard's
attention loop, which meant `pad project stale` inherited it (it filters that
very list) and the recommendation surface never saw it. So a deadline reached
the two surfaces that REPORT on work and never the one an agent PULLS from.
overdue.go is now the only place that decides, and all four call it.

Two behaviour changes fall out, both deliberate:

  - An overdue item bypasses the orphan branch's high/critical priority gate.
    That gate was where a deadline quietly stopped: a low-priority item three
    weeks late was reported by `stale` and never suggested by `next`.

  - Overdue sorts above in-progress. The list is capped at three, so a rank
    below in-progress would not merely order the deadline lower — on any
    workspace with three things in flight it would keep an overdue item off
    the surface entirely, which is indistinguishable from not shipping this.

The server-local-today comparison is UNCHANGED and known to be wrong for
multi-timezone deployments; it is filed as its own item with the cloud case
stated. Changing what "overdue" means on every existing instance inside a
change about where the rule LIVES is the kind of behaviour change nobody
reviews.

Fired reminders reach `next` / `ready` two ways, from one filtered list:
PendingReminders is the addressable form (it carries the id an ack needs), and
a prepended suggestion is the rendered form. They are prepended AFTER the cap
rather than entered as ranking candidates — a reminder is not a task competing
on priority, and whether it appeared should not depend on how busy the
workspace is.

Terminal-item reminders are FILTERED from the surface, never acked. Acking on
terminal status would couple every status write to reminder state and would
consume a reminder armed to fire after the work was done. The row stays
exactly as the user left it; the distinction is observable, and asserted.

Three guard tests caught this change and each was answered rather than
silenced:

  - The request-body reader guard was right: the handlers now go through
    decodeJSON, inheriting the NUL refusal and the size cap.

  - The canonical-events guard was right: item.reminder_due is admitted to the
    duplicated contract table as SPEC-3 v1.7, with the reminder subject kind
    and the new payload family. SPEC-3's own text owes the same amendment.

  - The NUL census asked for a decision on eight new columns. None carries
    caller text: ids and FKs are server-generated, four are the server clock,
    and remind_at is now re-parsed and re-formatted in the STORE as well as at
    the edge — so the stored value is always machine-produced from a parsed
    time and no caller bytes reach the column. The doc comment that used to
    say "the caller normalizes" protected nothing.

    Regenerating the baseline also found that GEN_NUL_BASELINE=1, which the
    test's own instructions name, was never implemented — the flag did
    nothing, so the documented path was hand-editing the file. Implemented, so
    the next reader gets the mechanism the instructions promise.

* test(reminders): the lifecycle, the four surfaces, and 22 killed mutants

Every test here was designed against a specific mutation and the mutation was
RUN. A green suite proves nothing about a suite nobody tried to break, and
three of the mutants I first wrote were not experiments at all.

Store (10 mutants, all killed): candidate predicate <= flipped to >=; the
event emission lifted out of the fire transaction; the fire UPDATE's
`fired_at IS NULL` arbiter removed; the RowsAffected check ignored; re-arm
clearing fired_at but not acked_at; ack losing `fired_at IS NOT NULL`; the
poll surface losing `acked_at IS NULL`; normalizeRemindAt no longer refusing;
it dropping .UTC(); GetReminder losing its workspace scope.

Surfaces (12, all killed): the priority gate no longer bypassing on overdue;
the sort no longer ranking overdue first; attention leaving the shared helper;
the reason losing its OVERDUE prefix; the comparison flipped to >; terminal
items no longer skipped; terminal reminders no longer filtered; the filter
ACKING instead of hiding; reminders appended instead of prepended; the tick
running on a far-future clock; ack answering 200 for an unfired reminder;
parseRemindAt accepting a bare date.

THREE MUTANTS DID NOT COUNT ON THE FIRST PASS and were rewritten. Two failed
to compile (`if false` orphaned a variable; deleting a parse orphaned an
import) and one had an anchor matching two call sites. A non-compiling mutant
emits zero FAIL lines and reads exactly like a surviving one — it invents a
hole that is not there — so the harness reports BUILD-FAIL and ANCHOR-BAD as
outcomes distinct from SURVIVED. It also restores files from an in-memory copy
rather than `git checkout`, which would delete uncommitted work in the tree.

ONE MUTANT GENUINELY SURVIVED and the test was at fault, not the mutant:
appending rather than prepending reminder suggestions was undetectable because
the fixture had a single item, so the reminder sat at index 0 either way. The
fixture now fills the three-item cap with in-progress work, where an appended
reminder lands fourth and vanishes. Faithful mutant, weak test — checked in
that order.

The same lesson shapes the four-surface fixture: it is a LOW-priority open
orphan, because that is the case the old code handled worst. A high-priority
task would have made the ready/next leg pass against the unfixed tree, which
is a green that measures nothing.

Negative controls throughout: a future deadline is not overdue and does not
reach the gate bypass; a tick with nothing due fires nothing; a completed item
is neither overdue nor suggested. Without them a helper that reported every
date, or a tick that fired everything, would satisfy every positive leg.

The lead's pin is asserted in both directions: a fired reminder on a done item
is ABSENT from the surface and PRESENT and still unacknowledged in the table.
Asserting only the absence would pass against an implementation that consumed
the row, which is the behaviour the pin exists to forbid.

* feat(mcp): pad_item.remind + ack-reminder, ToolSurfaceVersion 0.28

An agent that can RECEIVE a reminder but not set one has half the primitive.
The poll surface is pad_project.next / ready, both long exposed, so reminders
already reached agents — what was missing is the other half: deferring a piece
of work is exactly the moment an agent knows when it wants to be asked again,
and it had no way to say so.

Two additive actions, two optional params. Nothing existing moved, so a v0.27
consumer enumerating neither is unaffected — the v0.13 / v0.11 / v0.8
disposition, which likewise wired existing CLI verbs onto the catalog.

remind_at REFUSES a bare date rather than reading it as midnight. Worth
stating because the `date` schema type accepts YYYY-MM-DD and a caller will
reasonably try it here: a bare date names a 24-hour span, and choosing an hour
inside it would fire at a time nobody picked.

Re-arm and disarm stay CLI-only. Both address a reminder by an id the agent
would have to list first, and no listing action exists on this surface — a
door with no handle. Adding them later is additive.

Five guards had to be taught, and each was answered on its merits rather than
excluded: the HTTP parity test (route mappers added, so the actions work on
the remote transport rather than being advertised and unrouted), the
read-only catalog's cmdhelp fixture and expected cmdPath map, the field-
conflict classifier (remind_at / reminder_id are NOT field writers — a
reminder is a row in its own table addressed by its own id, so listing them
as classified sources would have pointed detectFieldConflicts at something
that is not a field source), and the instructions.md / README action tables.

That machinery is why the version bump is safe to make now, and it earned its
keep on this change: every one of the five failed on the first build after the
catalog entry landed.

CONVE-23 sweep for prose this falsifies:

  - SPEC-3 (DOC-2653) amended to v1.7 in the room, recording item.reminder_due
    with its new subject kind and payload family — the first canonical event
    with no user mutation behind it, since a scheduler tick produces it.
  - CLAUDE.md gains the reminder routes, the CLI verbs, and the v0.28 entry.
    It was also stale at 0.26 with NO v0.27 entry at all: the 0.27 unit swept
    instructions.md and README.md and missed this file. Both added.
  - skills/pad/SKILL.md gains the verbs and a routing entry, including the two
    things an agent will get wrong — the time is an instant, so ask for a time
    of day rather than picking one, and finishing the item does not
    acknowledge the reminder.

* fix(reminders): codex round 1 — four findings, all real, all with a pin

Round 1 found four defects and refuted none of them. Each fix carries a test
that fails against the code as it was, and each of those was mutation-checked.

**P1 — pending reminders bypassed item-level visibility.** Every other
dashboard section reads `allItems`, which the store already scoped to the
caller's collections AND their granted item ids. The pending-reminder list is
a direct workspace-wide query and inherited none of that, so a guest holding a
grant on ONE item could read the refs and titles of every other item in the
collection through its reminders — an item-level leak wearing a
notification's clothes. Now filtered with the same `isItemVisibleToGuest` call
the sibling sections use. The test's two items share a COLLECTION on purpose:
a collection-level filter was already applied, so separate collections would
have made it pass against the unfixed code.

**P1 — soft-deleted items could starve the queue permanently.** Candidate
selection ignored `deleted_at`, and `fireOneReminder` rolls back when it finds
the item gone — which leaves the reminder ARMED and therefore a candidate
again on the next pass. Candidates are ordered oldest-first and bounded by a
limit, so enough archived reminders fill every batch and no live reminder ever
fires. Silent, too: the tick reports zero fired and looks idle. Excluded in
the candidate query rather than skipped downstream, so those rows never occupy
a slot; the reminders themselves are kept, so restoring an item restores its
reminder with it — asserted, because a fix that reaped them would pass the
starvation test alone.

**P2 — the pass stopped at the first failing reminder.** The per-reminder
transaction exists precisely so one unfireable row cannot hold back the rest,
and `return fired, err` made that comment false — with candidates
oldest-first, one persistently broken old reminder blocks every newer one
forever. Now continues and joins the errors, so a pass that fired seven and
failed three reports both halves rather than reading as clean. The loop is
split behind an injected seam because a real mid-transaction failure is not
reachable from outside: the database refuses the corrupt rows that would cause
one (verified — invalid JSON in items.fields is rejected by the schema).

**P2 — suggestions dropped the reminder id.** The docs tell an agent to
acknowledge what it sees in next/ready, and the payload carried no handle: a
stateless poller could read the reminder and had no way to retire it, so it
would be shown the same item forever. `DashboardSuggestion` now carries
`reminder_id` (omitempty), `pad project next` prints the exact ack command,
and the test acks with the id the surface handed out rather than merely
checking the field is populated — a wrong-but-present id satisfies equality
with itself.

Four mutants, four killed; one was rewritten first because its anchor matched
two call sites and was therefore not an experiment.

* fix(reminders): codex round 2 — four findings, all real

**`--rearm` was unusable.** `ExactArgs(1)` forced an item ref that the rearm
branch then ignored, so the flag could not be reached without supplying a ref
that was silently discarded. Now `MaximumNArgs(1)`, with each mode checked
explicitly: a ref is required to arm, and a ref supplied ALONGSIDE `--rearm`
is refused rather than ignored — it names an item the reminder may not even
belong to, and quietly dropping it is how a user learns nothing about the
reminder they just moved.

**`unremind --format json` emitted plain text**, breaking the parseable-output
contract every sibling command honours.

**The MCP `ref` param did not list `remind`.** Agents read that flat
description to decide what to send, so an action missing from it is an
invalid call waiting to happen. It now also says what `ack-reminder` takes
instead, and why: a reminder is addressed by its own id because an item can
carry several.

**Fractional seconds fired early.** `time.Parse` accepts `09:00:00.900Z` and
`Format(RFC3339)` drops the fraction, so it was stored as `09:00:00Z` and
fired 900ms BEFORE the moment the caller named — silently, having rewritten
their value on the way in. Seconds are genuinely the stored resolution (the
column is compared as a string against a whole-second clock, and the tick runs
every 30s), so the only question was which way to resolve it, and truncation
resolved it the wrong way. `NormalizeInstant` now rounds UP: at most a second
of lateness, in exchange for a guarantee that can be stated — a reminder never
fires before the instant it was set for. Late is a reminder; early is a wrong
answer. Whole seconds round-trip exactly, which is asserted, because an
implementation that added a second unconditionally would otherwise pass.

Three mutants for this round, three killed (round-up→truncate,
round-up→unconditional-add, MaximumNArgs→ExactArgs). Thirty across the unit.

Two fixes carry no dedicated test and it is worth being explicit rather than
implying coverage: the `--format json` branch on `unremind` is a one-line
output change with no server-free way to drive it, and the MCP `ref`
description is prose the drift tests do not read — they assert an action is
DOCUMENTED, not that a param's sentence lists it.

* docs(reminders): the ack id is on the surface an agent polls, not only on the arm response

CONVE-23 follow-through on the round-1 fix. Both agent-facing docs told a
caller to acknowledge a reminder with the id "returned when you armed it" —
true, and useless to the caller that matters: a poller reading next/ready
never armed anything. The suggestion now carries reminder_id and `pad project
next` prints the exact ack command, so the docs say that instead.

The prose was written before the fix existed, which is exactly the case
CONVE-23 is about: a change that makes an instruction stale without touching
the file the instruction lives in.

* test(reminders): bind the tick LOOP to the work, not just the pass (CONVE-19)

Every other test in this file calls runReminderTick directly. That vouches for
the component and says nothing about whether anything ever calls it — a tick
that is never started is indistinguishable, from those tests, from one that
is. It is the convention's exact case, and the failure I recorded on my own
identity doc three times in one unit: I test the component and not the
binding.

Driven through the injectable tick channel so the assertion pins a SPECIFIC
pass instead of racing a 30-second ticker, and polled to a bounded deadline so
a loop that never runs FAILS rather than hanging the suite.

Mutant: drop `s.runReminderTick()` from the select and this goes red while
every direct-call test stays green. Killed.

The idempotence leg exists because a second Start spawning a second loop would
leave one running after Stop, making the BUG-842 drain invariant false for
this sweeper specifically — the one property a copied lifecycle is most likely
to get right by accident and least likely to be checked.

The cmd/pad call site (cmd_server.go, alongside StartTokenReaper) stays
verified by inspection: a source-scanning guard for it would be an instrument
asserting facts about source, which is code with an adversary and not worth it
for one line that sits in the middle of five identical neighbours.

* fix(reminders): codex round 3 — a deferred reminder fired anyway, and the poll surface was unbounded

**A re-arm mid-pass did not stop the fire.** The candidate scan selects an id;
before the UPDATE runs, a `--rearm` can move that reminder into the future.
Re-arm clears `fired_at`, so a predicate checking only `fired_at IS NULL`
still matched — the pass fired a reminder the user had just deferred and
emitted its event. The re-arm cannot undo that: it can clear the mark, but the
event is already on the outbox and at-least-once means a consumer has seen it.

The fire UPDATE now revalidates `remind_at <= nowTS` against the SAME nowTS the
candidate scan used. Same-value deliberately: the arbiter and the scan must
agree about when this pass is, or a reminder could pass one and fail the other
for no reason but clock drift inside a single pass.

**The poll surface was unbounded.** Every fired-and-unacknowledged reminder was
loaded and turned into a suggestion prepended to a list that is otherwise
capped at three, so a workspace with five hundred unacknowledged reminders
returned five hundred suggestions — in the dashboard response, the hottest read
in the product, growing until somebody acknowledged them.

Two bounds, because they are two different guarantees: the query takes a window
(default 50, oldest-fired first, so it holds what has waited longest), and the
prepended suggestions are capped at 5 so `suggested_next` stays a
recommendation rather than a second inbox. The full set stays addressable in
`pending_reminders`.

Truncation is REPORTED as a boolean, not a count. A count would have to be
post-visibility-filter to be true for the caller reading it, and the store
cannot compute that — the filter runs per item, above. "There are more than you
can see here" is the strongest claim the data supports, so it is the one made.

Four mutants; two killed outright, two survived and were run down under
CONVE-28:

- **Uncapped suggestions survived because the fixture had ONE reminder** —
  capped and uncapped are the same list at n=1. That is the SECOND time a
  single-item fixture hid a count-or-order property in this file. Fixture now
  arms eight; it also asserts all eight remain in `pending_reminders`, so the
  cap is pinned to the recommendation and not to the data.

- **Removing the SQL LIMIT survived, correctly, and the test comment now says
  so.** The Go slice cap bounds the PAYLOAD; the SQL LIMIT bounds the
  DATABASE'S work. Only the first is observable at this level — with the LIMIT
  gone the response is still bounded, while the query silently goes back to
  materialising every pending row before discarding most of them. That is a
  memory and I/O property with no assertion available here, so it is stated as
  a coverage boundary rather than papered over with a green that would not have
  measured it.

* docs(reminders): the fire predicate arbitrates against two actors, not one

CONVE-23 inside the file the round-3 fix touched. The comment described the
UPDATE as an arbiter for concurrent TICKS, which is what it was written for
and is why I did not re-read it when asked whether a user edit could race the
pass. It now says what it actually defends against, and names the general
shape: an arbiter is only an arbiter with respect to the writers it can see.

* fix(reminders): codex round 4 — the round-3 bound recreated the round-1 starvation

Round 3 bounded the poll surface. Round 4 caught what that bound did: the
query took the first N rows and the dashboard then discarded the ones it could
not show — hidden items, unauthorised items, completed items — so N such rows
hide a visible reminder behind them indefinitely, with no continuation to
reach it.

That is the SAME defect I had removed from the fire path one round earlier,
reintroduced in the read path within the hour. The general form is worth
stating because I clearly did not hold it: **a bounded window is only safe
when the discarding happens BEFORE the bound.** Filtering above a limit is a
starvation every time, and it does not matter what the filter is for.

Two halves, because the two filters are not the same kind of thing:

**Visibility is now scoped IN SQL**, using the same collection-id / item-id
sets every other dashboard section gets through `allItems` — the same
three-way shape as ItemListParams, where holding both collection grants and
item grants is an OR. Invisible rows no longer occupy the window at all, which
is strictly better than filtering them out afterwards and is what the sibling
sections have always done.

**Terminality is paged**, because SQL cannot evaluate it — a collection's
schema defines which statuses are terminal. The collector refills from the
next page when a page comes back short, bounded by a max scan so a workspace
full of completed items cannot turn a dashboard read into a table scan. The
bound is 10x the window: the common shape fills on the first page, and the
pathological shape terminates in a fixed number of indexed reads. Stopping at
the scan bound reports truncation, which is honest — there may be more, and we
did not look.

The empty-scope case is a THIRD state that reads like the second: nil
CollectionIDs means unrestricted, a non-nil EMPTY slice means this caller sees
no collections. Without an explicit guard they collapse, because the switch
matches none of its cases at length zero and adds no clause at all — so
"nothing visible" would return the whole workspace.

Three mutants, one survived: the empty-scope guard, because no dashboard-level
test produces that state (callers that would are refused earlier by workspace
access). Faithful mutant, missing test — it now has a direct one, with a
sanity leg so a build returning nothing cannot pass it by accident. A guard for
a state nothing exercises is exactly the one that rots.

* fix(reminders): codex round 5 — the MCP action I shipped did not work over stdio

**P1: local stdio MCP `remind` was unusable.** cmdhelp derives positionals by
regex from a command's `Use` string, and `<instant>` inside
`remind <ref> --remind-at <instant>` matched — it became a second REQUIRED
positional, so dispatch failed with `missing required argument "instant"`. The
action was advertised on a transport where it could not run.

**The MCP catalog's own tests did not catch it, and the reason is the finding.**
That suite builds its cmdhelp document BY HAND: I wrote `Args: mkArgs("ref")`
in it, so the fixture agreed with what I meant rather than with what the CLI
says. Five parity and drift tests passed against a document I authored to
match my own intention — the "a test that agrees with whatever the table says
is not a test of the table" shape, which the canonical-events test warns about
in its own comment two packages away. The new test reads the REAL command tree
via cmdhelp.Build, which is the only thing in this repo that can disagree with
me about what the CLI declares.

**P2: `pad project ready` withheld the ack handle** that `next` prints.
Showing a fired reminder on the surface an agent polls while withholding the
id it needs to retire it means the same entry comes back on every poll,
forever.

**P2: suggestions asserted a collection they did not have.** The orphan branch
admits ANY collection — its own comment claimed it gated on tasks "mirroring
the active-plan branch", and that comment was simply false — while the output
hardcoded `Collection: "tasks"` and the reason said "Open task". Pre-existing
for high-priority items since BUG-1082; my overdue bypass widened it to any
overdue item, which is how it surfaced.

Fixed by carrying the item's REAL collection rather than by narrowing the
branch: narrowing would silently drop the non-task items this has surfaced for
a year, and the defect is the mislabelling, not the inclusion. The false
comment is replaced with what the code actually does.

The first version of that test used an overdue IDEA and SKIPPED — ideas use
`new`, and the branch requires `open` or an active status, so it never became
a candidate. A test that cannot fire is a failed reconstruction, not a pass;
the fixture is now a bug-like collection whose vocabulary contains `open`,
which is the population the defect can actually reach.

Three mutants, three killed. Forty-one across the unit.

* fix(reminders): codex round 6 — reminders fired from soft-deleted workspaces

**P1, and the only defect in this unit whose consequence leaves the process.**
Workspace soft-delete deliberately keeps items for the 30-day restore window,
so the candidate query's filter on the ITEM's deleted_at found nothing wrong —
and the tick kept firing, emitting outbound webhook events for a workspace
whose owner had deleted it, possibly while deleting their account.

Both queries now join workspaces and require `w.deleted_at IS NULL`. Nothing
is destroyed: a restored workspace resumes firing, which the test asserts,
because "stops firing" and "is destroyed" are very different answers to
someone who restores a workspace and only one of them is right.

That test first failed for the WRONG REASON and the fixture was at fault: it
counted every outbox row in the workspace, and item creation writes its own,
so the assertion was satisfiable by the fixture itself and discriminated
nothing. Scoped to the reminder event type.

**`Use: "remind <ref>"` declared a requirement the command contradicts.**
cmdhelp derives the machine-readable arg spec from that string, and `--rearm`
takes no ref — so the published contract said "required" for something
optional. The requirement is CONDITIONAL, which cmdhelp cannot express, so the
honest declaration is `[ref]` plus the explicit check that names both call
shapes. The round-5 test grew a `required` column, which is what makes this
observable at all: asserting only the arg NAMES would have passed.

**The pad_item tool description omitted both new actions.** The params were
declared and the actions dispatched, but the prose an agent reads to decide
what a tool can do did not mention them — discoverable only by someone who
already knew to look. It now describes both, including the two things an agent
gets wrong: remind_at is an instant, and nothing but an explicit ack retires a
fired reminder.

Three mutants, three killed. Forty-four across the unit.

* fix(reminders): codex round 7 — one predicate for the scan and the arbiter

Third instance of one class, so this fixes the SHAPE rather than the instance.

The class: the candidate scan filters on something the fire transaction does
not revalidate, so a change committed between them fires a reminder that no
longer qualifies. Round 3 was a re-armed instant. Round 1's soft-deleted item
was the same thing caught from the other side. Round 7 is a workspace deleted
between the scan and the fire — the round-6 fix added the condition to the
SCAN only, and the arbiter went on not knowing about it.

Fixing those one at a time is what let the third happen. `reminderFireable` is
now a single string that both sites reference: the scan asks it and the fire
UPDATE re-asks it, so they cannot disagree, and a fourth condition is one edit
in one place rather than two edits someone has to remember are paired.

Written as a correlated EXISTS on item_reminders.item_id rather than a JOIN
precisely so the identical text is valid in both a SELECT and an UPDATE, and
the scan drops its table alias so the two uses are the same characters.

What deliberately stays outside it: `fired_at IS NULL` and `remind_at <= ?`
live on the reminder row itself, are already spelled identically at both
sites, and folding them in would need a parameter order the shared form cannot
express. Said in the comment so the omission reads as a decision.

Both directions are now tested at the arbiter — a workspace deleted mid-pass
and an item deleted mid-pass — because the item case previously relied on the
item load coming back nil, and someone simplifying the EXISTS down to the
workspace check alone would otherwise still see green.

Three mutants, three killed: the arbiter dropping the shared predicate, and
the predicate dropping each of its two halves. Forty-seven across the unit.

* fix(reminders): codex round 8 — workspace export silently dropped every reminder

WorkspaceExport is a hand-maintained field list, so a new table joins it only
if someone remembers. Reminders did not: a backup/restore, or a
SQLite→Postgres migration via `pad db migrate-to-pg`, dropped every pending
reminder with nothing in the destination to show anything had gone.

The line that list has always drawn is item-scoped workspace CONTENT
(comments, links, versions — exported) versus per-user state (stars, watches —
not). A reminder has no user column and hangs off an item, which puts it on
the exported side. Stating the rule rather than just adding the field, because
the next person adding a table needs to know which side they are on.

LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged reminder is
still owed to whoever armed it, so it arrives pending; an armed one whose
instant has passed fires once on the destination's first tick, which is what
would have happened had the workspace never moved. Re-arming everything on
import would invent a schedule the user did not set. NULL rather than empty
string for the unset marks — the lifecycle is defined by NULL-ness, and ""
would make a never-fired reminder read as fired at "".

TestMigratedTablesCoversTheExport caught the second half, which I would have
missed: `pad db migrate-to-pg`'s NUL preflight decides what to REFUSE on from
MigratedTables, so a table the migration copies and the preflight does not
know about is a gap in exactly the guard that exists to prevent one. Added
there too, with the reason it can never actually fire — every column is
machine-produced, so it is listed for coverage rather than expectation — and
the "six tables" prose it falsified is now seven.

Two mutants, two killed: export dropping the block, and import discarding the
marks. Forty-nine across the unit.

* test(reminders): state the fire-path invariant and pin it from the invariant

The lead's read on why rounds 4 and 7 were the same class: the fire path had
no stated invariant, so each fix defended an instance. This states it, and
derives the pin from the paragraph rather than from the bug history.

THE INVARIANT: the candidate scan is a hint and may be assumed to prove
nothing. Every condition that made a row a candidate is re-asserted inside the
transaction that marks it fired, in the same statement that does the marking,
so checking and writing are one atomic act.

Worded as "the scan proves nothing" rather than as a list on purpose — a list
invites the next person to add a condition to the scan and stop, which is
exactly what happened four times here.

TestFirePathInvariant is the pin: one table, one row per scan-side condition,
each invalidating that condition in the window between the scan and the fire
and asserting the same three things — nothing fires, no event leaves, the
reminder is not consumed. The earlier per-defect tests are folded in as rows;
they said the same thing one instance at a time, which is how four of these
shipped. Adding a fifth condition to the scan without a row here should feel
like an omission. It carries a positive control, because four cases that all
assert nothing happens would pass against a build that never fires at all.

The matrix immediately falsified a claim in the paragraph I had just written.
I wrote that the item load inside the transaction is "for the payload, not for
the check"; removing the item half of reminderFireable alone changes no
observable behaviour, because the load then returns nil and the deferred
rollback undoes the write. Item liveness is defended TWICE and a single-mutant
experiment cannot say which guard is carrying it — removing both is what kills
the test. Both are kept, the predicate is named as primary (the row never
matches, so no write happens at all), and the asymmetry is stated: workspace
liveness has no second line, which is why dropping ITS half does fail the pin.

Six mutants: five singles plus the pair. Five killed alone; the item single
survives by design and is documented as such rather than left as an unexplained
green. Fifty-five across the unit.

* fix(reminders): codex round 9 — one legacy row could hide every reminder

**P1: items.item_number is NULLABLE and I scanned it into an int.** Migration
006 added the column to existing rows, so a pre-numbering item still carries
NULL — and scanning NULL into an int fails the Scan, which fails the QUERY,
which degrades the whole pending-reminder section. One old row, and the
feature is dark for everyone in that workspace.

ListWatchesForUser, which this query was modelled on, uses sql.NullInt64 for
exactly this column. I copied its shape and dropped the part that handles the
column's actual nullability — the same way of being wrong as the round-5
cmdhelp fixture: borrowing a form without borrowing what it knows. The legacy
row now carries no ref rather than a fabricated "PREFIX-0", which would name a
different item.

**P1: export shipped reminders that import could only discard.** The items
section filters on deleted_at IS NULL, so a soft-deleted item is not in the
bundle and its reminder can never be reunited with it. My comment claimed the
item_links rationale — round-trip the raw graph so a restore reunites them —
which is true for links and false here, because links keep soft-deleted
endpoints in the bundle and items do not. A link is a row ABOUT two items; a
reminder whose item is absent is a dangling schedule.

**P2: import wrote remind_at raw.** Import is a writer, and a bundle is not
necessarily one this server produced — hand-edited, or from another instance.
A local offset or a bare date would land in the one column every comparison
downstream treats as a UTC instant, firing early, late, or never. It now
normalizes like every other door. An unparseable value is SKIPPED with a
warning rather than failing the restore, matching the lenient import-side
precedent already in this file, and the raw value's LENGTH is logged rather
than its content.

Three mutants, three killed; two needed rewriting because the single-line form
did not compile — reverting the nullable scan also requires reverting the
render, and dropping the normalization orphans a variable.

PROCESS FAULT, recorded because it makes this round's findings weaker than
they look: I edited the tree while this review was reading it — committed the
invariant work and ran five mutation experiments, which write and restore
source, over the same files. A review binds to the tree it read and I moved it
underneath. Every finding above was re-verified against the current tree
before being acted on, and the next round runs with no concurrent edits.

* fix(reminders): codex round 10 — one orphaned item aborted a whole restore

An ORPHANED item — one whose collection is missing from the bundle — still
gets an itemMap entry. It has to: the entry is written before the skip because
parent resolution inside the same loop reads the map for items it has not
reached yet. So `itemMap[x] != ""` is satisfied by an id that names no row,
and inserting a foreign key to it fails (SQLite enforces FKs here via the
DSN's `_pragma=foreign_keys(on)`; Postgres always does).

The pre-existing mapping is the sharp edge. The aggravating half was mine:
this loop treated a failed reminder insert as FATAL, where item_links and
item_versions both skip, so one orphaned item carrying a reminder rolled back
an entire 900-item workspace restore. A reminder is the least critical thing
in a bundle and it had the strictest failure handling in the file.

Both halves fixed: the loop gates on items that actually landed, and a failed
insert warns and skips like its siblings.

TWO GUARDS THAT ONLY DIE TOGETHER, and this is measured rather than assumed.
Reverting either alone leaves the test green — with the map gate restored the
skip survives the FK failure, and with the fatal return restored the gate
means the insert never fails. Removing both is what fails it. They are kept as
a pair because they defend the same failure at different depths (prevent the
bad write / survive a bad write arriving some other way), and the pair is
recorded in the code so a future reader does not delete one as dead after
watching its mutant survive. Second time this shape appeared today; the first
was item liveness on the fire path.

The bundle in the test is hand-built, because ExportWorkspace cannot produce
an orphan — which is the reason it needed a test. That shape only arrives from
a hand-edited or foreign bundle, and surviving those is what import is for.

Three mutants: two singles that survive by design, plus the pair that kills.
Sixty-one across the unit.

* fix(reminders): codex round 11 — four contract slips, one of them another unit's

**suggested_next returned up to eight entries against a cap of three.** Round 3
prepended reminders PAST the list's own cap, reasoning they should not compete
for slots. Every consumer — the web dashboard, `pad project next`, `pad project
ready` — is written for three.

Worse, it silently falsified a decision recorded elsewhere: BootstrapDashboard
deliberately has no suggested_next_overflow_count BECAUSE this list is capped
at three upstream, and its comment names raising that cap as the moment to add
one. My change made another unit's reasoning wrong in a file I never opened.
The combined list is now trimmed back to three, reminders still leading — a
reminder can push a task suggestion out, which is the right way round, and the
full set stays addressable in pending_reminders.

My first version of that trim used `limit`, which is REASSIGNED above to
len(candidates) — so on a workspace whose only entries are reminders it would
have truncated to zero, killing precisely the case the surface exists for.
Caught by reading the surrounding lines before running anything; it has its own
test now.

**pending_reminders was uncapped in the bootstrap projection.**
BootstrapDashboard embeds *DashboardResponse, so every new field joins the boot
payload automatically — here, a window of up to 50, which is the budget
PLAN-1410 spent a unit trimming. Capped at 5 with an overflow count, under its
own constant rather than borrowing bootstrapAttentionCap: they answer different
questions and a future change to one must not silently move the other.

**Truncation was reported from the wrong question.** The collector used the
store's `more` flag, which answers "is there another PAGE", not "did I read all
of THIS one" — so a window filling part way through the final page reported
that the caller had seen everything while unread rows sat behind the fill
point. The paging bounds are now injectable so the case is testable at all:
building it with a window of 50 needs ~75 rows in a specific pattern, with a
window of 3 it is four.

**Import accepted acked-without-fired**, which is not one of the lifecycle's
three states. Such a row fires, is excluded from the pending surface because it
is already acked, and can never be acknowledged because AckReminder requires
acked_at IS NULL — an event emitted into permanent invisibility. The
acknowledgement is dropped and the schedule kept, since an ack of something
that never fired means nothing.

Five mutants, five killed (one rewritten — removing the flag orphans a
variable). Sixty-six across the unit.

* fix(reminders): codex round 12 — a read is not a hold; scope the arm; ack from the ack

Four P2s from round 12 (two independent runs, both landing on the same
line of the fire path), each closed at the layer where it lives:

- fireOneReminder pins the item and workspace rows FOR NO KEY UPDATE on
  Postgres before the arbiter UPDATE. reminderFireable re-asserted
  liveness at the predicate's instant and nothing held it to the commit
  instant; under READ COMMITTED an archival could commit in between and
  the event left the process about a deleted resource. Same idiom and
  same lock strength as CreateAttachmentForLiveItem; SQLite is excluded
  by its BEGIN IMMEDIATE, not skipped for convenience. Two PG-only pins
  verify "blocked" in pg_stat_activity, not by elapsed time; the
  pin-removed mutant fails both.
- CreateReminder asserts "live item of THIS workspace" in the INSERT's
  own SELECT and returns ErrReminderItemGone otherwise. The table had an
  FK and no same-workspace constraint; a mismatched pair fed another
  workspace's title to this one's dashboard and webhooks. Handler maps
  it to 404.
- AckReminder matches every fired row (COALESCE keeps the first ack,
  updated_at moves only when acked_at does), so a no-match means exactly
  "not fired at the instant of the ack". The handler no longer decides
  409-vs-200 from the row it read before the UPDATE.
- The invariant paragraph gains its missing sentence: "at that instant"
  means the commit instant, and the pin is what makes the predicate's
  instant and the commit instant the same one.

Round-12 caveat carried: both runs were static reads (sandbox blocked
Go's build cache), so "four" is a floor, not a measurement.

Refs IDEA-2641

* fix(reminders): codex round 13 — a reminder's workspace must agree with its item's, at every read

Every reader scoped by r.workspace_id and then joined the item without
asserting the two agree. No door writes a disagreeing row today
(CreateReminder derives the pair from the item; import maps within the
workspace), and the table has nothing that forbids one — so a hand-edited
bundle, a future move door, or a direct write would carry one
workspace's item into another's dashboard, export, and webhooks.

The identity goes into reminderFireable (scan + arbiter), the Postgres
row pin, ListPendingReminders and the export query. One test writes the
row raw — the only way one can exist — and asserts it is inert at each
site; the predicate-removed mutant scans and fires it.

Refs IDEA-2641

* fix(reminders): codex round 14 — the by-id and by-item reads assert the same identity as every other read

GetReminder scoped by the row's own workspace_id and ListRemindersForItem
by item_id alone, so a row whose two columns disagree — the class rounds
12 and 13 closed at the scan, the arbiter, the pin, the pending surface
and the export — was still readable through the two reads that reach a
single row. reminderOwned is that identity on its own, without the
liveness half those two reads must not have (a fired reminder on an
archived item is history worth showing). The write paths reach a row
only through GetReminder, so scoping it scopes them; a row no door can
write needs no door to delete it. ListRemindersForItem now takes the
workspace its caller already resolved the item in.

The raw-row test asserts both reads refuse the row from both sides; the
reminderOwned-removed mutant surfaces it through GetReminder.

Refs IDEA-2641

* fix(reminders): codex round 16 — an archived item's reminders are readable, and its verbs say "archived"

The doors resolved the item live. Listing an archived item's reminders
answered 409 from a GET, and ack/re-arm/delete answered a bare 404 for a
reminder that exists on an item that exists — while the store, since
round 14, deliberately keeps that history readable. The API already has a
posture for archived items: GET reads them, mutations answer 409
"archived … restore it before editing" (writeItemResolveError). The list
now follows handleGetItem; the lifecycle verbs load the item
include-deleted, run the visibility check first, and then answer the same
409 every other item mutation does. One test walks archive → list 200 /
ack 409 / arm 409 → restore → ack 200 on the same rows.

Refs IDEA-2641

* fix(reminders): codex round 17 — one suggestion per item, the archived 409 by slug, and the door courtesy named

Three findings on the server pass. (1) An item that was both a fired
reminder and an ordinary candidate appeared in suggested_next twice; the
ordinary entry is dropped, the reminder entry (which carries the ack id)
stays, and two reminders on one item remain two entries. (2) Round 16's
409 for an archived item's reminder was written by re-resolving item.Ref,
which is derived and empty for a legacy item with no item_number — so the
class most likely to be legacy fell through to a bare 404. The slug is
handed over instead. (3) The archived check in resolveReminderForWrite is
check-then-write, and an archive landing in between lets the verb through:
accepted and documented — it is the posture of every item mutation here
(UpdateItem's UPDATE has no liveness clause), the outcome is benign, and
putting liveness in AckReminder's WHERE would re-create the no-match
ambiguity round 12 removed.

Refs IDEA-2641
2026-09-04 11:36:11 -04:00
xarmian 63da2f4f5f feat(store,server,cli): count and repair the legacy NUL population (BUG-2810)
Layers A and B stop the value being written. Neither makes a row that
already carries one go away, and BUG-2810's filing is what that costs: an
affected workspace exports with a 200 and re-imports with a 400, so a
self-hoster restoring their own backup is blocked with no path forward in
the product, and `pad db migrate-to-pg` fails partway through the copy
against PostgreSQL's jsonb parser rather than up front.

This is DOC-2823's S3, on Dave's day-54 rulings: U+FFFD as the replacement,
repair standalone only with a migrate-to-pg preflight that refuses and
prints the command, `--repair-nul` on import shipping default-strict.

ONE REPAIR, beside the one predicate. textguard.Repair lives next to
ParameterRefused because four layers that agree about what is REFUSED and
disagree about what a repair PRODUCES is this bug family arriving one step
later. Its contract is a property over the same corpus, in both directions:
every refused value becomes one all four layers accept, and every accepted
value comes back IDENTICAL. The second half is the load-bearing one — a
repair that tidies values nobody complained about rewrites
`{"a":"x\\u0000y"}`, six literal characters after a doubled backslash, and
corrupts it.

The JSON arm is a string-literal SCANNER, not decode-walk-remarshal, which
is what the recon write-up proposed before it was written. Re-marshalling
changes four things nobody asked to change — object key order, insignificant
whitespace, integers wider than float64, HTML-ish characters — and silently
drops one of a document's LITERAL duplicate keys, which is a gap BUG-2812
owns and the last thing a repair should do. Scanning copies every byte it
does not deliberately rewrite, so an untouched document is byte-identical
without that having to be argued. A substring replace is not equivalent and
the test that proves it took a mutation to find: a doubled-backslash literal
ALONE never reaches the scanner, so the discriminating fixture is one
document carrying a live escape AND a literal.

THE COUNT IS COMPUTED IN GO. Measured on the read path in this worktree: a
row planted with `bad<NUL>name` reads back into a Go string with all 8 bytes
and the NUL intact, while `length(name)` in the same database answers 3.
TASK-2824 found that C-truncation and concluded no DB-side REPAIR could be
trusted; the same measurement on the read path says no DB-side COUNT can be
either. SQL narrows — `instr(col, char(0))`, plus the escape prefix on
JSON-classed columns, which is textguard's own pre-filter — and never
decides. The decision stays ParameterRefused with isJSON from the shared
86-column list, i.e. Layer B's classing.

Row addressing is read from the live schema rather than a hand-kept map:
39 tables carry protected columns, one (item_wiki_links) declares no primary
key and is addressed by rowid, two have composite keys, and five have a
single key that is not `id`. The repair checks RowsAffected because an
address that stopped selecting its row would otherwise commit an UPDATE that
touched nothing and report it as repaired — the one failure an operator
cannot see in the output.

`email_optouts(email)` is both a protected column and its own primary key.
Repairing it changes the row's identity and can collide with an existing
row, which in that table means somebody starts receiving mail again. It is
reported and skipped, with the reason.

The import flag is NOT an exemption from the gate. `--repair-nul` buys the
body one repair attempt and then runs the same `bodyDecodesNUL` on the
repaired bytes, which still decides — a decode path that skipped the check
is the door BUG-2803 spent thirty rounds closing, on the endpoint carrying
the largest attacker-controlled body in the product. Only the ESCAPE form is
repaired: a raw NUL byte makes the document invalid JSON, and widening what
parses is not this flag's job. Both doors are covered, JSON and tar.gz,
because giving them different answers is how one of them keeps being
forgotten.

Postgres is settled with evidence rather than sent up as a ruling: it cannot
hold either defect (22021, 22P05) and the four-way differential test already
pins that, so the scan reports not-applicable WITH the reason rather than
returning a zero a reader could mistake for a clean database.

Spellings settled here, per the dispatch: `pad db scan-nul` and
`pad db repair-nul` as siblings rather than `repair --nul`, matching
`migrate-to-pg`'s hyphenated compound — a repair verb that errors when given
no flag is a worse shape, and there is no second repair to share it with.
scan-nul IS the dry run, so repair-nul grows no --dry-run. It refuses while
the server is running unless --force, on the `pad db restore` precedent: the
report is a claim about a database, and one somebody else is concurrently
writing makes it a claim about a moment that has passed.

docs/backup.md's section on this is rewritten. It still said the rule lives
in the binary and not the database, which S2 made false, and it pointed at
this item for a preflight and a repair that now exist. Its import examples
also showed `pad workspace import < file`, which has never worked — the file
is an argument.

Closes BUG-2810.
2026-09-02 14:53:31 +00:00