Commit Graph

1949 Commits

Author SHA1 Message Date
xarmian d048631abc fix(attachments): the blocklist judges the same filename the download header serves (BUG-2818) (#1445)
A stored attachment name had three readers that disagreed. The extension
blocklist looked up filepath.Ext of the name as stored; the
Content-Disposition sanitiser dropped control characters, '"' and '\'
before serving it. So "x.s<VT>vg" or `x.s"vg` passed the blocklist
(".s<VT>vg" is no known extension) and was downloaded as "x.svg". For SVG,
whose bytes sniff as allowed text/xml, the extension is the whole defence.

The fix makes them read one string:

- DroppedFilenameRune (internal/attachments) is the ONE character rule:
  Unicode control characters (C0, DEL, C1), '"' and '\'. The header
  sanitiser uses it instead of its own switch.
- NormalizeFilename runs at BOTH ingest doors before ValidateUpload: the
  upload handler, and bundle import, which stored the manifest's name
  verbatim with none of the upload door's rules. It keeps the upload
  door's leaf reduction and BUG-2803's fallback, drops the predicate's
  characters, and trims trailing dots and spaces (which a download save
  strips) unless that would leave nothing.
- ServedFilename covers legacy rows stored before this: a name whose
  normalised extension is blocked is served as stem + ".bin".

Normalise rather than refuse: the attack still ends in the ordinary 415
extension_blocked, because its normalised name carries the extension it
was hiding, while a benign name with a stray tab is stored without it.

Visible change: stored attachment names lose control characters, quotes
and trailing dots/spaces.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-22 20:54:46 -04:00
xarmian c3d64451ed test(buildtools): the install-refresh pre-server proves it is the listener the script probes (TASK-3146) (#1443)
* test(buildtools): the install-refresh pre-server proves it is the listener the script probes (TASK-3146)

Seven tests pre-start a "running server" on a port chosen by freePort, which
releases the port before the stub binds it, and then waited only until
SOMETHING answered on it. A stub that lost the port in that window exits on
the bind error and the dial succeeds against the foreign listener, so the
script's outcome was judged against someone else's process.

The port cannot be 0 there, as it is in BUG-3144's two-server test, because
the script restarts that server and reads the port from PAD_PORT or the
captured argv. startPreServer therefore proves two things instead: the
stub's own STUB_ANNOUNCE line, with the port it bound equal to the chosen
one, and its pid at the address the script will probe (a new /stub/pid
route), which covers a wildcard bind beside a held specific address on a
stack that permits it. A collision is now a named fixture failure.

Tests force each case: a foreign listener answering health ok on the
chosen port, a probe address that reaches someone else, and a stub that
bound a different port, plus a positive control.

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

* test(buildtools): hold the chosen port in the port-proof case so the stub's port 0 cannot land on it (TASK-3146, codex round 1)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-22 19:55:00 -04:00
xarmian 4b5daad5a5 fix(web): three identity leaks found by sweeping the item pane's unmodelled post-suspension calls, and the guard now enumerates them (BUG-3130) (#1438)
* fix(web): an identity change now moves the reset generation of every workspace, and every post-await call outside a fence is classified (BUG-3130)

localIndex.resetAll, the identity-change listener, reset only the workspaces
it held state for. A caller that captured resetGenerationFor(ws) while ws had
no state saw no drop across a sign-out, so FieldEditor.createRelationTarget
wrote the previous identity's new row into a fresh index. resetAll now bumps a
counter folded into resetGenerationFor.

The child-population guard now fails on any post-await call that is neither a
modelled send nor a fence call and has no fence before it, unless it is
classified in KNOWN_OUTSIDE_FENCE (26 rows, each with its reason). Two-way:
an unlisted call fails, and so does a stale row.

* fix(web): decision chips no longer paint the previous identity's answer, and promise continuations in the item pane are enumerated (BUG-3130)

DecisionChips' .then checked only its item token, which a sign-out does not
move. It now also checks an identity fence captured in the effect.

It was invisible because the guard does not model promise continuations
(gap (f)) and the hand list of them predated the component. The guard now
enumerates every function handed to .then/.catch/.finally in the population,
inline or by name, into a two-way KNOWN_CONTINUATIONS table (12 rows), so a
new continuation fails until it is read. Codex round 1 found the component and
the by-name callback the first enumerator missed.

* docs(web): the child-population guard's gap (e) instance has been fixed; say so (BUG-3130)

* test(web): table keys carry an occurrence count, ['then'] is a continuation, and other deferrals are a declared gap (BUG-3130)

* docs(web): the child-population guard names generator resumption, what a table row pins, and where fail-closed stops (BUG-3130)

* fix(web): a field edit typed under one identity is not sent as the next one's (BUG-3130)
2026-09-22 19:19:07 -04:00
xarmian 0d0c0cfb0a fix(web): duplicating a long-titled playbook cuts the source so " (copy)" fits (BUG-3149) (#1442)
Duplicating a playbook whose title is 249+ characters always failed: the
page sent `${title} (copy)`, which the server refuses as over its
255-character limit.

copyTitle (web/src/lib/items/titleLimit.ts) cuts the SOURCE, by code point
after Go's TrimSpace, so that base + " (copy)" <= MAX_ITEM_TITLE_RUNES, then
re-trims the trailing whitespace the cut exposes (on Go's space set) and
appends the suffix. No ellipsis, matching the server's import cut. The
suffix is kept because it is the only part of the title that tells the
copy from its original.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-22 18:32:41 -04:00
xarmian e1908096dc fix(web): tag suggestions can be picked from the keyboard (BUG-3150) (#1441)
* fix(web): tag suggestions can be picked from the keyboard (BUG-3150)

TagInput's suggestions acted on mousedown only, and Enter on a focused button
fires click, so a keyboard user could never pick one. BUG-3148 then took them
out of the tab order, since the list unmounts under focus. They are now
reached the combobox way, with focus never leaving the input:
role=combobox with aria-activedescendant on the input, a role=listbox of
role=option entries, ArrowDown/ArrowUp to move the highlight (clamped at the
end; past the top clears it), Enter to add the highlighted tag (else the
typed text, as before), Escape to close.

The highlight is held by the tag itself, not its position, following
ItemPicker's activeId. Typing re-filters the list, and an index would land on
whatever slid into that slot.

Tests: a jsdom suite for Enter, clamp/clear, re-filter and Escape, plus
tabIndex -1 still holding. A Playwright leg picks a suggestion by keyboard
inside the mobile overlay and asserts focus stays on the input and in the
pane. It types a prefix, never the whole tag, so Enter could not add the tag
from the typed text.

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

* fix(web): name the tag combobox, and reference its list only while it exists (BUG-3150, codex r1)

aria-controls pointed at a listbox that is rendered only while open, and the
input's only name was a placeholder that is empty once tags exist. The input
is now labelled "Add tag", and aria-controls is set only while the list is in
the DOM.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-22 17:43:24 -04:00
xarmian 275efc17be fix(web): a starting copy preview cancels the debounce it already answers (BUG-3151) (#1440)
* fix(web): a starting copy preview cancels the debounce it already answers (BUG-3151)

copy-dialog.spec.ts' coalescing test failed in 2 of 3 full desktop runs
(4 preflights instead of 3) and passed in isolation. The CI traces show the
4th request's body is byte-identical to the 3rd's.

The runner reads the current state when a run starts, so that run answers
every edit made before it, including one whose debounce timer is still
pending. When edits are spaced wider than the 250ms debounce while a preview
is in flight, the earlier edit's timer queues the trailing run. The last
edit's own timer is then still pending when that trailing run starts, and it
fires afterwards to send the same body again. Suite load is what spaced the
test's picks that wide; a 300ms gap reproduces it with no load, every time.

runPreflight now cancels any pending debounce timer when it starts a run or
queues the trailing one. The spec runs the collapse test at two pick spacings
(0ms and 350ms). It waits out the debounce before counting, because the
duplicate arrives after the point where the count used to be read.

* test(e2e): derive the copy-dialog collapse spec's timings from the shared debounce constant (BUG-3151, lead condition)
2026-09-22 16:56:44 -04:00
xarmian 69c2f5d4dd fix(web): the mobile pane trap keeps focus when the focused control is removed (BUG-3148) (#1439)
pane-a11y-focus.spec.ts:230 failed in CI on three heads, each on both
attempts, and passed locally. The CI trace shows both attempts escaping on
exactly the 14th Tab: a position in the tab order, not load. The 14th tab
stop is a tag SUGGESTION button. The pane's tag input shows suggestions only
when the workspace has tags, which the shared e2e workspace does once other
specs have run, and never does in isolation. Suite order was the variable.

The suggestions unmount 120ms after their input blurs, so Tab lands focus on
a button that is about to disappear, and focus falls to <body>. Removal fires
no focus event, so the trap's focusin backstop, whose comment claimed this
exact case, never saw it. The loading-state header's Close button does the
same when loading ends.

- PaneHost: a MutationObserver on the overlay pane refocuses the pane when a
  removal leaves focus on <body>. This closes the class, whichever control
  unmounts.
- TagInput: suggestions are no longer tab stops. They are picked by pointer
  only (onmousedown), so a keyboard user could reach one but not use it.
- Spec: seeds a tag so the case is always present, checks focus over a
  250ms window per Tab rather than instantly (the escape arrives late), and
  adds two legs: a focused control removed from the pane must not drop focus
  out of it, and Tab from the tag input must not land on a suggestion.
2026-09-22 16:13:04 -04:00
xarmian 3f567965bc fix(web): a refused title keeps the typed text, and a too-long one is refused before sending (BUG-3115) (#1437)
* fix(web): a refused title keeps the typed text, and a too-long one is refused before sending (BUG-3115)

The sidebar quick-add dialog closed and cleared itself before the create
request, and the item title editor closed before its PATCH and re-seeds from
the stored title on reopen, so a title the server refused was simply gone.
Both now keep the text on screen, editable, with the reason beside it.

Every web door that sends a typed title (11 doors, 12 call sites) now checks
it against the server's limit first, on the exact string it sends. The limit
lives in web/src/lib/items/titleLimit.ts and counts code points after Go's
TrimSpace set, which is not JS's trim set (U+FEFF, U+0085). A shared corpus,
testdata/item_title_limit.json, is asserted by a Go test and a vitest suite,
and the Go half proves the corpus's whitespace list is exactly
unicode.IsSpace over the whole rune range.

The roles page's create refusal was console-only; it and the playbook and
convention forms now show the server's message instead of a generic one.

* fix(web): an in-flight quick-add belongs to one opening of the dialog (BUG-3115, codex r1)

The old response recognised "its" dialog by collection, so dismissing and
reopening the SAME collection mid-request let it close the new dialog and
clear the new text (the store's $state list hands out one stable proxy per
collection, so the comparison matched). A per-opening session counter
replaces it; Sidebar.quickAdd.svelte.test.ts pins both outcomes against a
$state-backed double and fails on the old comparison.

Also: the playbook duplicate refusal names the server's reason, and the
comments claiming every web door is checked now say UI doors where a user
types a title — WebMCP's agent-supplied titles and generated titles such as a
playbook "(copy)" are not pre-checked.

* fix(web): quick-add is read-only while its create is in flight, and a new opening does not inherit the busy state (BUG-3115, codex r2)

* fix(web): the playbook editor no longer refuses to save an unchanged legacy over-limit title (BUG-3115, lead NO-GO)

The server re-validates a title only when it changes (the handler's early
refusal and the store's echo comparison), so a playbook whose title predates
the 255-rune bound stays saveable as long as its title is sent back as-is.
save() always sends the title, and a5ea9e0b checked it unconditionally, so
such a playbook could no longer be saved at all, not even to change its body.

Both UPDATE doors now go through titleEditError(sent, stored), which mirrors
the server's echo rule (raw or TrimSpace'd equal to the stored title) before
applying the limit. ItemDetail already skipped an unchanged title; it now
shares the helper. Every other door the change touches is a create.

Pinned by titleEditError unit cases, and by an e2e leg in which the editor is
shown a legacy title and must send the save. It fails, with its premise holding,
when the editor goes back to the unconditional check. The server half is
TestPatchItemVerbatimEchoOfALegacyTitleIsANoOp.

* test(e2e): a control leg so the playbook editor's legacy-title leg cannot pass without a length check (BUG-3115, codex r4)
2026-09-22 15:30:37 -04:00
xarmian 371ea3a145 fix: a tab's older content write can no longer land over its newer one (BUG-3080) (#1435)
* fix: a tab's older content write can no longer land over its newer one (BUG-3080)

The item pane's teardown flushes (collab snapshot and raw markdown) are
fire-and-forget PATCHes carrying the whole body. Two can be in flight at once,
for example after a cancelled unload, more typing, then a real close. When
they arrive in the wrong order, the older one puts old text back.

The client cannot order them: the second is sent from a page that is going
away. A version token cannot either: both are dispatched before the tab sees
either result, so both carry the same token, and the older landing first would
get the NEWER refused. So the order is decided at the server, from what the
tab says:

- Every item-pane content PATCH now carries client_write {tab, n}. The tab is
  a per-page-load random id (never a session or user id), and n only rises.
  This covers all five content writes the pane sends, not only teardown, so an
  ordinary slow-then-fast pair is ordered too. That is intended.
- The server keeps the highest n APPLIED per (item, tab) in one bounded map
  (10,000 keys, 10 minutes idle). A lower n answers 409 superseded_write,
  naming the n it lost to, and writes nothing. The pane treats that answer as
  success and never retries.
- The per-key lock is held from the check through the write, so the older
  write cannot pass its check and then land last.
- Per instance, and forgotten on restart: a missing mark refuses nothing, so
  every gap degrades to today's behaviour. A write without client_write (the
  CLI, MCP) is never compared against a mark. The CLAUDE.md note sits beside
  the SSE budget.
- Refusals are counted in pad_content_writes_superseded_total.

Tests: Go handler tests on both paths, including a concurrent case held open
through a test-only seam, age and bound, and unstamped writes. Playwright
holds a real older flush and delivers it last. Removing the server check,
releasing the lock early, or not sending the stamp each turns a test red.

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

* fix(server): client_write orders CONTENT writes only (BUG-3080, codex round 1)

A stamp on a PATCH that carries no content (a field or title write) used to
advance the (item, tab) mark. That could refuse this tab's next real content
write with a lower n. The stamp now neither orders nor marks a write that
carries no body. The pane never stamps such writes, so this pins the contract
rather than a current path. A test covers it, and dropping the gate turns it
red (409 superseded_by 50).

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

* fix(server): the client_write mark moves when the write commits, not at the response (BUG-3080, codex round 2)

The mark was advanced only on the 200 exit. A write that committed and then
failed in response enrichment (a 500) left it behind, so this tab's older
write could pass its check and overwrite what was just stored. It now moves at
the point every write path has confirmed its commit. That is argued from the
code: there is no seam to make enrichment fail on demand.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 13:54:24 -04:00
xarmian 5da904b9cb feat(server): a dedicated rate limit for synchronous decision-provider calls (TASK-3141) (#1436)
* feat(server): a dedicated rate limit for synchronous decision-provider calls (TASK-3141)

POST /playbooks/match makes a paid provider call on every request, needs
only viewer access, and rode the general API bucket, so one viewer could
drive 600 provider calls a minute.

- New RateLimiters.DecisionProvider bucket: 30/min per user (IP when
  anonymous), burst 5. A refusal is the existing 429 with Retry-After.
- Charged in the HANDLER, immediately before provider.Ask, through
  allowDecisionProviderCall: only a request that would actually spend
  consumes a token. An unconfigured-provider 404, a 400 for bad text, zero
  active playbooks and the pre-send refusals cost nothing and consume
  nothing. A future synchronous caller uses the same helper instead of
  adding another path string to the middleware switch. All three doors
  (CLI, stdio MCP, remote MCP through the full router) meet it. The general
  API bucket still applies as before.
- No env knob, by ruling: no bucket has one (the only rate-limit env is
  PAD_DISABLE_RATE_LIMITS, E2E-only), and tuning belongs in the
  instance-admin decision-provider setting (TASK-3121). The async
  decision_jobs runner is untouched.
- RateLimiters.Stop's "every limiter MUST be in this list" is now enforced
  by a reflection test, not trusted.

Tests: 429 + Retry-After after the burst, with the provider not called for
the refused request; per-caller isolation; 404 / 400 / zero-playbook
requests consume no token; the production numbers pinned; Stop coverage.
The behaviour tests use a same-burst, near-zero-refill bucket so "the 6th
call" cannot depend on runner speed. Counterfactuals: charge removed ⇒ both
behaviour tests red; charge moved to the top of the handler ⇒ only the
non-spending test red; limiter dropped from Stop ⇒ only the coverage test
red.

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

* docs(server): say which refusals the decision bucket charges; count limiters instead of writing the number down (TASK-3141)

Codex round 2, both valid:
- CLAUDE.md and the bucket comment said a 400 consumes no token. The
  request-too-large 400 is raised INSIDE the provider call (the decision
  package's pre-send ErrRequestTooLarge), after the charge, so it has
  spent one. Both now name which refusals are free (unconfigured 404, text
  400, too-many-playbooks 400, zero playbooks) and which is not.
- TestServer_Stop_DrainsRateLimiterCleanup asserted baseline+9 goroutines
  per server. That was already stale (13 limiters before this unit, 14
  now). It now counts the *ipRateLimiter fields by reflection. The
  middleware comment's "spawned 9" is BUG-851's history and stays.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 13:42:37 -04:00
xarmian 94381d09a5 test(buildtools): start the two-server fixture on port 0 and wait on each stub's own announcement (BUG-3144) (#1434)
* test(buildtools): start the two-server fixture on port 0 and wait on each stub's own announcement (BUG-3144)

TestInstallRefresh_RefusesWhenSeveralServersAreRunning failed once on
CI's Go (PostgreSQL) job (run 35738655417): "script proceeded with two
servers running". The script was right. The fixture had one server.

The script counts server PROCESSES (pgrep -x plus the `server start`
argv). The test picked two "free" ports with freePort, which releases the
port before the stub binds it, and proved readiness by dialing each
port. A stub whose port was taken in between (by the other stub, or by
anything else on a busy runner) exits on the bind error, the dial
succeeds against the other listener, and only ONE stub process exists.
The script correctly proceeds, and the test blames it.

The stub now binds via net.Listen and, with STUB_ANNOUNCE=1, prints the
port it actually got. Otherwise it behaves as before (a failed bind
exits). The test starts both stubs on port 0 and waits on each
process's own "LISTENING <port>" line, so readiness is tied to that
process and two stubs cannot share a port. A stub that exits is now a
fixture failure naming itself, not an assertion about the script.

Evidence, pre-committed on BUG-3144 before running:
- unfixed, forced portB = portA: 3/3 fail with the CI message
- unfixed, a foreign listener pre-bound on portB: 3/3 fail, same message
- unfixed, unmutated, -count=30, all CPUs busy + a port-churning load:
  2/30 fail (one exactly the CI message; one the same mechanism with a
  failed restart)
- fixed, the second stub forced onto the first's port: 3/3 fail as
  "stub exited without announcing a port" (a fixture failure, not a
  wrong verdict about the script)
- fixed, same contention + churn, -count=50: 50/50 pass

Not changed: eight other tests pre-start a server on a chosen port the
script must restart on, so they cannot use port 0. They share the
pick-then-bind window.

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

* test(buildtools): reap announced stubs in t.Cleanup and keep draining their stdout (BUG-3144)

Codex round 1, both valid: startAnnouncedStub started each stub without
ever calling Wait, and its reader stopped after the first line, so a
stub writing more could block on a full pipe.

Each stub is now killed and reaped in t.Cleanup, which runs even when an
assertion fails. Wait closes the pipe, ending the reader. The reader
drains stdout after the announcement.

Hygiene, not measured: with the old code the defer's pkill plus the test
binary's exit leave nothing observable afterwards, so no counterfactual
discriminates this change. Stated rather than claimed.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 13:23:50 -04:00
xarmian 9424365ac0 test(events): wait on the reset report, not on a state that precedes it (BUG-3145) (#1433)
* test(events): wait on the reset report, not on a state that precedes it (BUG-3145)

Main went red at 3c6a0801 on the Go (PostgreSQL) job with
TestACollidingRepairIsCaughtBySequenceRatherThanEpoch reading resets=[]
on the line after the guard's own "event sequence went backwards" WARN.

The bus reports sequence resets AFTER releasing its lock, deliberately, so
an observer may call back into it: RedisBus.fanOut's first-registered
defer, and dropWorkspaceCoverage's unlock-then-report. Everything a test
can observe first (a delivered event, a dropped buffer, EventsSince
answering nil) changes UNDER that lock. So reading the observer on the
strength of one of those races the report, and a loaded runner loses it.
Test-only defect; the product ordering is intended and unchanged.

recordingObserver gains awaitReset, which blocks on a channel closed by
every SequenceReset. The deadline bounds a genuine failure; nothing
retries an assertion. Applied at the three sites with this shape: the
colliding-repair test (after a delivery wait) and the two
undecodable-message tests (after polling EventsSince). The other
positive reset reads in the package follow a synchronous
fanOutFromRedis on the test goroutine, which reports before it returns.

Evidence, pre-committed on BUG-3145 before running:
- unfixed, -race -count=50, all 8 CPUs busy: 2/50 fail at :552, got []
- counterfactual, 50ms delay before each deferred report: all three
  unfixed tests fail 3/3 with got []; fixed, 3/3 pass
- fixed, -race -count=50, all CPUs busy: 150/150 pass

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

* test(events): assert the exact reset history, not just that the awaited reason arrived (BUG-3145)

Codex round 2: awaitReset returns once the awaited reason exists, and the
assertions after it checked only for that reason (or the last one), so a
wrong reset reported EARLIER passed. The old snapshot() reads could have
caught that, whenever they happened to win the race.

All three sites now assert the whole history is exactly the one expected
report (measured: 20/20 runs of each test report exactly one reset). ws-1's
messages are fanned out by one receive goroutine, in order, so once the
awaited report exists every earlier fanOut has reported. The exact match
therefore deterministically catches a wrong reset from before, including
one the colliding-repair test's space-A control read too early to see
(:511, which on its own can pass while a report is in flight).

Boundary, stated in the comments: a duplicate issued by the SAME report
path after the awaited one is caught only if it lands before the read.

Counterfactual: a mutant that reports every reset twice passes the
previous commit's assertions (and main's) and fails all three here.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 12:27:40 -04:00
xarmian d67a02e785 fix(web): the tag saver no longer overwrites a concurrent tag change (BUG-3143) (#1432)
The item pane's tag saver PATCHed the whole tag set with no version token.
Another writer's tag change disappeared, with no conflict anywhere to retry on.

Every tag batch now:
- carries the version token of the row whose tags it last confirmed (seq via
  occTokenFor, as updateField sends), so a write that landed since is refused
  with a 409 instead of overwritten;
- sends the burst's GESTURE applied to that row,
  rederiveListWrite(base, desired, confirmed), the same helper BUG-3038 added.
  It does this on EVERY batch, not only after a 409. The saver coalesces edits
  into absolute sets taken from a display that can lack the other writer's
  tag, and a batch that follows a 409 goes out with a current token, so a
  replay there would erase the tag with no conflict.

The conflict path is a new top-level sendTagBatch: identityHeld before the
refetch, the refetch, identityHeld again, advance, re-send. flushTagSaver
keeps its original single-await shape and its BUG-3084 check. When a batch
drains with nothing queued, the saver is rebased onto the returned row. The
tag overlay would otherwise keep hiding the other writer's tag after the
burst.

Playwright: real server conflicts, one batch and two. Removing the token,
re-deriving only on a 409, or dropping the rebase each turns a leg red.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 12:19:23 -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 1f7402c15e fix(web): a list-field retry re-applies the gesture to the fresh row (BUG-3038) (#1431)
* fix(web): a list-field retry re-applies the gesture to the fresh row (BUG-3038)

The item pane's 409 refetch-and-retry re-sent its ORIGINAL body. For a
whole-list field that body is the result of a gesture applied to an older
list, so the retry erased whatever another writer changed in between. The
field held [A,B,C] and the user removed A, so [B,C] was sent. Another writer
added D first, and the retry wrote [B,C] over [A,B,C,D].

The gesture can be recovered without a contract change. The base (the list on
the row the write was dispatched against, captured at dispatch) and the sent
list give removed = base - sent and added = sent - base.
rederiveListWrite applies those to the refetched row: order comes from the
fresh row, removed elements are dropped by identity, and added elements are
appended. updateField re-derives in the existing onRefetched hook, which runs
after the refetch and before the re-send, for multi_relation fields only.
Scalars and json keep the replay, since their gesture is the value.

fieldWriteOrder.test.ts's "still retries" leg is inverted, as its annotation
asked. A Playwright spec forces a real server conflict. Replacing the
re-derivation with a replay turns it red.

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

* test(web): pin why the retry base is the dispatched row, not the editor's list (BUG-3038, codex round 2)

Codex round 2 proposed using the editor's list as the base. With an earlier
uncommitted write in flight, that base names only the later gesture and
resurrects the earlier removal: [A,B,C] minus A then minus B against [A,B,C,D]
gives [A,C,D]. The dispatched row gives [C,D]. Both outcomes are pinned, and
the reasoning is in rederiveListWrite's comment.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 11:12:22 -04:00
xarmian 3c6a08013d fix(web): the item pane never puts an older row over a newer one (BUG-3036) (#1430)
Every writer in the item pane answers with the whole row and assigns it to
`item`. Per-field ordering cannot see across fields, so a slow response for
one field put an older row on screen over a newer write to another field,
while the server kept the newer one. An SSE re-read did the same. Events that
arrived during a save were skipped, not queued, so the pane stayed behind the
server until something unrelated refreshed it.

Snapshots are now ordered by the ROW's seq (BUG-3037), not a client dispatch
count. A dispatch count drops an older successful response because of a newer
write that then fails, which is the BUG-2773 hole. withInflightTags, the
funnel every server snapshot already goes through, refuses a snapshot of the
shown item whose seq is strictly below the one on screen. Equal or missing seq
is accepted, and a different item is never compared. Follow-ups that used the
incoming row now read the row actually installed. A delta-sync pass for an
older row returns before it bumps itemGen.

The three non-destructive skip-while-saving sites now owe a re-read, which
runs once the save (or title edit) ends. The seq order makes a late re-read
harmless.

Verification: a Playwright spec with real commits and delayed responses (a
slow field PATCH, a slow SSE re-read, a change during a save). Removing the
funnel guard turns the first two red. Restoring the skip turns the third red.
Relaxing strict to non-strict fails the unit test.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 10:11:21 -04:00
xarmian 0393bc9180 test(web): pin that a refused text/url/number write reverts on screen (BUG-3040) (#1428)
BUG-3040 was filed during PLAN-2857 U4, before BUG-3039 (#1347) routed the
typed path through `sendTyped`. That change releases the display when the
write SETTLES, not only when the prop agrees, and a refused value never
agrees. So the product fix is already on main. This adds the browser leg.

The existing unit leg stubs a consumer that REJECTS. The real consumer,
ItemDetail.updateField, catches the refusal and RESOLVES, and this spec runs
that path: the real item page and the real server, with the item PATCH
fulfilled as a 400. It has one test per input kind (text, url, number).

Mutant: removing `if (!hasPending) typedDisplay = null;` from sendTyped's
release fails all three, each on its own defect assertion.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 08:56:13 -04:00
xarmian c1076ce15f fix: a relation's "no target" has one stored form, and required means it names one (BUG-3028) (#1427)
* fix: a relation's "no target" has one stored form, and required means it names one (BUG-3028)

A scalar relation had three stored spellings of "no target" (key absent, ""
and whitespace-only), and required was enforced only against the first, so a
required relation holding "" validated. The canonical form is now the one
multi_relation uses: the key absent.

Provenance decides the rest, per the lead's ruling. A blank a write SETS
(create, fields_patch, a changed value in full fields, a move or copy
override) is removed before validation, so a required one is refused as
required. A patch blanking a required relation now gets the answer deleting
it always got. A legacy blank a write only CARRIES is removed after validation
and never refused: an untouched key under fields_patch, an unchanged full
fields value, move, bulk update and bulk move, cross-workspace copy and its
preflight, artifact import. On the patch path the store does this under its
write lock (ItemUpdate.BlankRelationKeys), so a concurrent write's new value
is never mistaken for a blank.

An empty list filter on a scalar relation of a collection-scoped list
(owner=) now matches all three spellings instead of only "".

MCP ToolSurfaceVersion 0.40, a behavior bump. No migration: this instance
has no relation fields, and the prod census is Dave's.

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

* fix(items): a blank relation default is no default, so it cannot satisfy required (BUG-3028, codex round 1)

Codex round 1 P2: a required relation whose schema default is "" had the blank
injected by validation, which satisfied required, and was then normalised
away after it, storing a required relation with no target. A blank default
on a scalar relation now takes the no-default path: a required field is
refused (the message names the blank default) and an optional one stays
absent with nothing reported dropped.

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

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

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 07:34:33 -04:00
xarmian 6be2f81fec fix(collab): the relay does not store a byte-identical re-sent frame (BUG-3135) (#1425)
* fix(collab): the relay does not store a byte-identical re-sent frame (BUG-3135)

A reconnecting tab re-pushes its whole document, and the relay stored each
copy: 92 MB of byte-identical rows above watermarks on the live op-log. The
relay now appends through store.AppendSyncFrame, which skips a frame that is
byte-identical to an earlier row of the same item. The byte compare decides;
the hash only narrows the lookup. Subtype twins and envelope frames are
stored as before.

A skipped frame is still broadcast, and still acknowledged: its cursor is the
item's MAX(id), read under appendMu, so every row at or below it has already
reached each peer's bus. It does not advance the sender's lastPersistedOpID,
which the restore bracket reads as "this frame is in the op-log".

Replay-safe because op-log GC removes an item's rows all or nothing, so the
earlier twin exists while anything could follow it. No reader of MAX/MIN(id)
or of the flush cursor sees a frame that never got an id.

Measured on BUG-3134's harness: +2 rows / +331 bytes per reconnect became
+1 / +10 (the SyncStep1), and +325 bytes per repeat join became +4.

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

* fix(store): answer the duplicate check and MAX(id) in one statement (BUG-3135, codex round 1)

Codex raised a delete landing between the two reads. No op-log delete path
can reach a live readLoop today, but one statement makes the skip exactly as
durable as the insert it replaces under any interleaving, without relying on
that.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 04:15:14 -04:00
xarmian 6d6ac71908 fix(store): a step2 answer carrying an update's exact payload is not content (BUG-3136) (#1422)
* fix(store): a step2 answer carrying an update's exact payload is not content (BUG-3136)

An open tab answers a joining tab's SyncStep1 with a SyncStep2 carrying its
full state, and the relay persists it. On an unedited doc that payload is
byte-identical to the seed update; only the subtype byte differs, so unit A's
whole-frame twin check missed it and the item read pending after every first
join. The twin lookup now also matches the frame with its subtype swapped
between step2 and update, for well-formed single-message frames only.
y-protocols applies both payloads through the same readUpdate, so identical
bytes have identical effect under either envelope.

Migration 092 (PG 069) re-queues content-bearing step2/update rows for the
startup backfill, which reclassifies them under the new rule. On a copy of
the live DB: 57,056 rows re-examined, 34 cleared, pending items 189 -> 178.
The 18 remaining bearing step2s above watermarks are edited docs whose merged
state no single earlier row equals; clearing those needs a Yjs parse.

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

* test(store): pin that migration 092 leaves non-sync rows alone (BUG-3136, codex round 1 P3)

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

* chore(store): renumber the step2 reclassify migration to 093 / PG 070 (BUG-3136)

#1421 landed first with 092 / 069.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 01:27:03 -04:00
xarmian 3b7665a469 chore(decision-eval): attention threshold eval — no threshold passes the pre-registered rule (TASK-3137) (#1424)
* chore(decision-eval): attention threshold eval through the production state builder (TASK-3137)

The instrument and its recorded results. The pre-registered rule is met by
no threshold on the production state, so AttentionThreshold is unchanged;
the ablation shows the 70/70 labels are historical while production asks
about open items in the present. See RESULTS.md.

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

* chore(decision-eval): replay each item to its last open moment (TASK-3137)

The lead-ruled instrument: BuildItemState over the item as of just before
its first logged terminal transition (r1), and at filing (r0), with the same
140 labels. The pre-registered rule (P >= 90%, R >= 60% on r1) is met by no
threshold, so AttentionThreshold is unchanged. RESULTS.md records the
finding: collection-membership labels cannot see the transient human waits
that a task at its last open moment really has.

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

* test(models): classify the eval tool's generic decode site (TASK-3137)

The BUG-2685 guard found the new scripts/decision-eval readJSON helper as an
unclassified interface-destination decode. It decodes item, comment, history
and activity dumps, never a collection definition.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 23:55:54 -04:00
xarmian 2e45d75efc feat: a caught-up tab stamps the flush watermark, so viewing an item no longer leaves it pending (BUG-3124 unit B) (#1420)
* feat(server): content-free flush-watermark stamp for a caught-up tab (BUG-3124 unit B, server half)

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

* feat(web): a caught-up tab stamps the flush watermark when its flush dedupes (BUG-3124 unit B, client half)

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

* test(web): BUG-3124 — provider onOpLogCursor fires on advance only

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

* fix(server): BUG-3124 — the watermark stamp decodes through decodeJSON (NUL refusal + size cap)

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

* refactor(web): BUG-3124 — stamp rules move to watermarkStamper; settle via a component helper; identity-gate rows re-reviewed

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

* fix(web): BUG-3124 codex round 1 — keepalive always stamps; a non-advancing 200 releases the cursor for retry

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

* docs: BUG-3124 unit B — opening an item in a tab now clears a stale content_state

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 19:53:23 -04:00
xarmian 98213b0334 fix(cli): refuse a blank --stdin body on item create/update; add --clear-content (BUG-3100) (#1415)
Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 18:52:28 -04:00
xarmian cdbc69dd0e fix(editor): an empty mermaid block no longer shows "Rendering..." forever (BUG-3113) (#1414)
* fix(editor): an empty mermaid block no longer shows "Rendering..." forever (BUG-3113)

The mermaid NodeView seeded its diagram with a "Rendering..." placeholder
and queued a render only for non-empty source, so a block that was empty
when its view was built kept the placeholder with nothing coming to
replace it: a freshly typed fence, or an item loaded with an empty block.
The placeholder is now shown only when a render is actually queued; an
empty block stays empty, which is what update() already leaves when a
diagram's source is cleared.

e2e covers both entry paths, with a control block that must render an SVG
so "no placeholder" cannot pass because rendering is broken. Both paths
were red on the unfixed binary ("Rendering...") and are green on this one.

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

* docs(editor): say which renders show the mermaid placeholder (BUG-3113 review round 1)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 18:28:03 -04:00
xarmian a69656ed89 fix(store,server): an invitation to a soft-deleted workspace reads as unknown (BUG-3104) (#1413)
Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 17:58:27 -04:00
xarmian 216e60817f fix(editor): code block Copy copies the model text, not the contentDOM (BUG-3111) (#1412)
The Copy button read the <code> element's textContent. That element is the
block's contentDOM, where ProseMirror also renders decorations, and a remote
collaborator's caret is a widget decoration whose label is a text node
holding their display name. With a peer's caret parked in a block, Copy put
their name into the clipboard at the caret. Reproduced in a browser on the
unfixed build: "const ansE2E Adminwer = 42;".

The plain code-block NodeView moves to codeBlockCopy.ts, and its button now
reads view.state.doc.nodeAt(getPos()) at click time. Reading at click time
needs no update() on the view, so ProseMirror's update path for non-mermaid
blocks is unchanged, and edits made after mount are copied. A position that
no longer resolves to a code block reports Failed rather than copying the
wrong text or an empty string.

Tests: a real Tiptap editor with the CollaborationCaret extension's own
caret render planted in the block (precondition: the old DOM read carries
the name); a source-level wiring guard on Editor.svelte; and an e2e with two
live browser peers that reads the clipboard, red on the unfixed binary and
green on this one.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 17:53:01 -04:00
xarmian 096584c349 feat(decision): pluggable typed-decision provider with a typesafe.ai backend (TASK-3116) (#1411)
* feat(decision): pluggable typed-decision provider with a typesafe.ai backend

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 17:31:25 -04:00
xarmian 5272c7a981 fix(web): identity-fence ItemDetail's children, part 2 (BUG-3105) (#1410)
* wip(web): BUG-3105 PR B checkpoint 1 — identity-fence ItemTimeline (9 of 36)

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

* wip(web): BUG-3105 PR B checkpoint 2 — CopyItemDialog fenced; guard finally/catch model

CopyItemDialog: six paths fenced; the copy dispatch refuses before the
send (folded into superseded()) and never re-sends after it.

Guard: literal assignment in a finally is not a commit (lead ruling 1),
and a catch/finally commit is no longer protected by a fence in its try
block (found by the non-literal control). ItemPicker.invokeCreate delisted.

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

* wip(web): BUG-3105 PR B checkpoint 3 — EditCollectionModal fenced; post-try guard rule

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

* wip(web): BUG-3105 PR B checkpoint 4 — ItemAttachmentStrip fenced; test mocks gain identityFence

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

* wip(web): BUG-3105 PR B checkpoint 5 — backlink panels, QuickActionsMenu, PushToAgentDialog fenced

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

* wip(web): BUG-3105 PR B checkpoint 6 — list emptied; FieldEditor, EditorBubbleMenu, probe fenced; guard counts own awaits

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

* wip(web): BUG-3105 PR B checkpoint 7 — navigation-vs-identity prose sweep in ItemTimeline

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

* wip(web): BUG-3105 PR B round 1 — fence Editor upload callback; narrow gap (d) claim

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

* wip(web): BUG-3105 PR B round 2 — upload leg pins the broadcast and the onError sentinel

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-21 17:05:41 -04:00
github-actions[bot] c0253831bd chore(nix): heal vendorHash for the module set in 4b1876ab6f (TASK-2954) 2026-09-21 18:08:31 +00:00
dependabot[bot] 4b1876ab6f chore(deps)(deps): bump the go-minor-and-patch group with 3 updates (#1407)
Bumps the go-minor-and-patch group with 3 updates: [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go), [golang.org/x/tools](https://github.com/golang/tools) and [modernc.org/sqlite](https://gitlab.com/cznic/sqlite).


Updates `github.com/mark3labs/mcp-go` from 1.0.0 to 1.1.0
- [Release notes](https://github.com/mark3labs/mcp-go/releases)
- [Commits](https://github.com/mark3labs/mcp-go/compare/v1.0.0...v1.1.0)

Updates `golang.org/x/tools` from 0.49.0 to 0.50.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.49.0...v0.50.0)

Updates `modernc.org/sqlite` from 1.58.0 to 1.59.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.58.0...v1.59.0)

---
updated-dependencies:
- dependency-name: github.com/mark3labs/mcp-go
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/tools
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 13:59:27 -04:00
dependabot[bot] 97b01a580b chore(deps)(deps-dev): bump the npm-minor-and-patch group (#1408)
Bumps the npm-minor-and-patch group in /web with 2 updates: [marked](https://github.com/markedjs/marked) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `marked` from 18.0.12 to 18.0.13
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.12...v18.0.13)

Updates `vitest` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 18.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 13:59:23 -04:00
dependabot[bot] c85910039e chore(ci)(deps): bump docker/setup-buildx-action (#1409)
Bumps the actions-minor-and-patch group with 1 update: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).


Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...f87e5991a6d7451dcb8d9637bfbc97413f497069)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 13:59:20 -04:00
xarmian 6282cb9ace mcp: correct the claim/release catalog comment — the actions own the 0.38 bump (#1406)
OpenCode review (pad-reviewer, gpt-5.6-luna) on 4abc8ad6 flagged the
comment still saying "no ToolSurfaceVersion bump (DR-7 grounds)" after
#1405 bumped to 0.38 for exactly these two actions. A maintainer
following the comment for the next additive action would leave the
version behind. Comment-only change.

Claude-Session: https://claude.ai/code/session_01M64hSzHJUwWYHZX2kH3FDC
2026-09-19 17:12:29 -04:00
xarmian 4abc8ad6c3 mcp+cli: ToolSurfaceVersion 0.38 for claim/release; refuse sub-second lease ttl on both transports (#1405)
Follow-up to #1238. Review: two CONVE-735 2a rounds (backend: claude-agent), both CLEAN; trail on docapp HT-2860.
2026-09-18 21:47:38 -04:00
David Barkhausen 828c9ba2cf feat(items): execution lease — atomic claim/checkout with expiry (#1238)
Implements #1221 (IDEA-2815). Contributor: b4rk13. Closes #1221.
2026-09-18 12:25:38 -04:00
xarmian 6c5ead1ff6 fix(editor): follow the app's light/dark mode in mermaid diagrams (BUG-3106) (#1404)
* fix(editor): follow the app's light/dark mode in mermaid diagrams (BUG-3106)

initMermaid passed `theme: 'dark'` unconditionally, so every diagram was
drawn on the dark palette whether the app was in light or dark mode. The
value predates the mermaid 12 bump, which kept it for continuity — true of
the BUMP, but it was never a deliberate choice to ignore the app's mode.

The mode read is `data-theme` on <html>. There is no theme store: four
components (+layout's initializer, TopBar, Sidebar, YouSheet) each toggle
that attribute and mirror it to localStorage['pad-theme'], so the attribute
is the only thing all four agree on. ABSENT MEANS DARK — +layout only ever
SETS the attribute and leaves it off for the default — so the predicate is
`!== 'light'`; `=== 'dark'` would draw the default mode, the common case on
a fresh browser, on the wrong palette.

A theme change needs both halves: a fresh initialize(), because mermaid
caches its config, AND a re-render, because the SVG already on screen has
the old palette baked in. A MutationObserver on the attribute drives it,
rather than a hook in the togglers, so it covers all four without any of
them having to know diagrams exist — and covers the next one too.

`layout: 'dagre'` and `look: 'classic'` are untouched.

mermaidTheme.ts holds the two decisions worth testing — which theme a mode
calls for, and which diagrams a change must redraw. Diagrams showing a
syntax error are skipped so a toggle does not flicker an error message the
reader is already looking at.

Verified in the browser on the served binary, reading the painted fill
rather than eyeballing it. Branch build on a private port, one flowchart
and one sequence diagram, against main on :7777 as the control:

  main, light mode   node fill rgb(31,32,32)    <- the bug: dark palette
  branch, light      node fill rgb(236,236,255), text rgb(51,51,51)
  branch, dark       node fill rgb(31,32,32),    text rgb(204,204,204)
  branch, back light node fill rgb(236,236,255)

The SVG element id changes on each branch leg (mermaid mints a random id
per render), which is what shows the re-render actually fired; on main the
id is unchanged across the toggle, the other half of the same bug.

Gates: svelte-check 0 errors, vitest 205 passed across 12 editor files.
Three mutants killed and the restore verified byte-identical — always-dark
(the original bug), the inverted `=== 'dark'` predicate, and dropping the
error-class skip.

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

* fix(editor): source the theme re-render from the model, share mermaid state, follow prefers-color-scheme (BUG-3106 review round 1)

Three defects from review round 1 on b1d93902, plus the test gap that let
them through. Fixed as one round because they touch the same two functions.

P1 — the theme re-render read its source from the DOM.

collectMermaidRerenders took `code.textContent`, and that `code` element is
the NodeView's contentDOM, so ProseMirror renders DECORATIONS into it
alongside the text. A remote collaborator's caret is a widget whose label
holds a text node with the peer's display name — CollaborationCaret is
configured with no custom `render`, so the extension default applies — which
makes the source read `graph TD; A-->B;Dave` for any block with a peer's
cursor in it. mermaid rejects that, the catch swaps the SVG for "Invalid
Mermaid syntax", and the error-skip added in the same change then STOPS any
later toggle from retrying it: a transient pollution became a permanently
broken diagram, the two new rules combining into something neither did
alone.

The NodeView already computes the trimmed model source at both of its render
sites, and both of its own paths read the model. It now publishes that source
into a module-scope WeakMap keyed by the diagram element, and the theme path
reads it from there. A diagram with no published source is skipped, not
guessed at.

P2 — instance state guarding a global.

mermaid is a singleton: initialize() and render() act on one module-global
config. Two <Editor>s are routine, since the item route keeps the master
mounted while a pane holds a second. A per-instance applied-theme tracker
therefore guards a config another instance can have changed, so an instance
could skip an initialize() it needed and draw on the other mode's palette
with nothing queued to correct it; and a per-instance queue made overlapping
render() calls routine, when that queue exists precisely because mermaid
cannot render concurrently. mermaidMod, renderQueue and appliedMermaidTheme
move to `<script module>`. One queue, one tracker, one mermaid.

P2 — the predicate ignored prefers-color-scheme, and the previous comment
asserting "ABSENT MEANS DARK" was simply false.

app.css:699 is `@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) { ...light... } }`, so an absent attribute
renders LIGHT under an OS light preference. +layout papers over this at mount
only, and writes no attribute at all when the OS is dark — so loading with
the OS dark and nothing saved, then switching the OS to light, flips the CSS
with no attribute mutation for a MutationObserver to see, and every diagram
stayed dark against a light page with nothing able to correct it. The read is
now three-way in the CSS's own order of precedence, and a matchMedia change
listener sits alongside the observer, removed in the same onDestroy.

The enumeration in both comments was also wrong: FIVE places write
data-theme, not four — the workspace settings page is the fifth.

Test gap: the unit's tests exercised only the extracted pure functions, so
restoring the literal `theme: 'dark'` inside initMermaid left the suite
green. The binding was where the defect lived. mermaidThemeWiring.test.ts
pins it, following the 30 existing source-level guard specs in web/ rather
than mounting a component no sibling spec mounts.

Verified in the browser on the served binary, nine legs, reading painted
fill and page background rather than eyeballing. Diagram palette matches the
page in all nine, and no diagram errored:

  OS dark, nothing saved   attr absent  page 11,11,16     node 31,32,32
    toggle to light        attr light   page 245,245,249  node 236,236,255
    toggle back to dark    attr dark    page 11,11,16     node 31,32,32
  OS light, nothing saved  attr light   page 245,245,249  node 236,236,255
    toggle to dark         attr dark    page 11,11,16     node 31,32,32
    toggle back to light   attr light   page 245,245,249  node 236,236,255
  OS dark, nothing saved   attr absent  page 11,11,16     node 31,32,32
    OS -> light            attr ABSENT  page 245,245,249  node 236,236,255
    OS -> dark             attr ABSENT  page 11,11,16     node 31,32,32

The last three are the prefers-color-scheme finding: the attribute never
changes, so the MutationObserver never fires, and the diagrams still follow.

Gates: svelte-check 0 errors, vitest 214 passed across 13 editor files.
Three mutants killed and restore verified byte-identical — the round-1
predicate (kills the OS-preference legs), the contentDOM read (kills the
decoration leg), and the hardcoded `theme: 'dark'` (kills the wiring pin,
which is the gap this round closes).

Boundary, stated rather than implied: the OS-flip legs were verified against
the FIXED binary only. The counterfactual for that predicate is the unit
mutant, not a second browser run against b1d93902.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
2026-09-18 11:55:49 -04:00
xarmian b64ee56bea fix(web): surface the server's reason for a refused bulk restore (BUG-3102)
The collection page computed a failure COUNT and threw the reasons away.
`runBulkOn` read `res.updated`, derived `failed` by subtraction, and never
touched `res.failed` -- so a row the server refused with a structured
`plan_limit_exceeded` reached the user as a bare "N failed". Archive five
items, a teammate creates three, click Undo: three rows fail and nobody is
told the workspace is at its item cap or where to lift it.

`code` DECIDES, `error` DISPLAYS, `details` is not read for prose. The
server already sends the finished sentence -- handlers_items_bulk.go builds
a refused row as `message: planLimitMessage(&ple.Result)`, byte-for-byte
what the single-restore 403 produces. Rebuilding it from limit/current in
TypeScript was the original plan for this fix and is deliberately not what
it does: it would fork the wording (TASK-788's single source of truth), and
it cannot produce the right answer anyway, since BUG-3103's import sentence
needs the server's `requested` branch to come out right. One refusal would
then read differently depending on which door the user came through.

Rows are accumulated ACROSS chunks -- they were read and discarded per
chunk -- and grouped by (code, message) rather than by code alone, so two
`open_children` rows naming different blockers do not collapse into one
row's specifics standing for the other's.

A chunk that THROWS never reaches per-row reporting, so its items are
failures with no row. They are counted as "not attempted" and carry the
CHUNK'S OWN error, which is their real reason -- not another row's borrowed,
which would send the user to fix the wrong thing. A test pins that they do
not inflate the plan-limit count.

THE SINGLE-RESTORE HALF OF THIS BUG WAS AGAINST UNREACHABLE CODE.
`handleRestore` on this page had no caller -- no prop, no markup reference,
no export -- so the reported defect was one no user could reach. The live
single-restore path is ItemDetail.svelte:4717, whose catch shows
`e.message`, and PadApiError's constructor is `super(err.message)`, so the
server's sentence already arrives there intact. That half needed no fix; it
was already correct where reachable and unfixable where not. The dead
function is DELETED rather than fixed (lead ruling): it read as live enough
that a defect was filed against it and part of a seat window spent on it.
The identity-fence source guard's asserted population drops 15 -> 14, which
is that guard working -- a member cannot leave unnoticed any more than it
can arrive unnoticed.

Two smaller things fixed on the way:

  - The old all-failed branch read `Failed to ${verb.toLowerCase()} items`
    while every call site passes a PAST-TENSE verb, so it rendered "Failed
    to restored items" and "Failed to moved back items". The builder uses
    the passive, which composes with every verb in use; there is a leg for
    the two-word one.
  - `PadApiError.details`' documented shape for `plan_limit_exceeded` did
    not list `requested`, which BUG-3103 added server-side earlier today.
    Documented, with the note that callers must not render prose from it.

Tests: 17 unit legs on the builder, every expectation a LITERAL rather than
a re-derivation of the implementation's own template; 3 mounted legs driving
the reported scenario end to end (archive -> Undo -> refused restore) so the
page's USE of the builder is covered, not only the builder. Mutation: 4 of 4
killed, each confirmed to build -- rows never accumulated, chunk error
dropped, grouped by code alone, un-attempted folded into the reasons.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
2026-09-18 01:10:19 -04:00
xarmian 212645c7d5 fix(web): model post-await COMMITS in the child identity guard; fence ShareDialog and ItemPicker's hand-off (BUG-3105 PR A) (#1402)
* wip(BUG-3105): commit model + ShareDialog/ItemPicker fences (pre-legs)

* wip(BUG-3105): ShareDialog driven legs

* wip(BUG-3105): ItemPicker hand-off legs

* test(web): update ItemPicker oncreate assertions for the widened hand-off (BUG-3105)

---------

Co-authored-by: xarmian <dave@houseofvoi.com>
2026-09-18 00:38:52 -04:00
xarmian b6bf03c2a4 docs(store): keep the do-nothing-lock note once, at the lock site (BUG-3103)
The paragraph explaining why the workspace seq lock guards nothing on this
door appeared twice -- at the lock site and again in the comparison block,
a leftover from an earlier draft that put the whole check after the loop.
Two copies of a rationale drift apart, and the one a reader reaches first
is then the one that is stale.

Kept at the lock site, where the lock is; the comparison block points at it
in one line. The two headings there now say the two different things that
block needs to say -- why the comparison is after the loop, and why the
limit is resolved before it.

Comment-only: `git diff` carries no non-comment line. Review r1.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
2026-09-17 23:54:41 -04:00
xarmian 7f823c7fe0 fix(store): enforce items_per_workspace at workspace import (BUG-3103)
ImportWorkspace enforced only the user-scoped `workspaces` cap, so a
free-plan owner could import a workspace holding more items than the plan
allows. Every other door that raises the count -- create, artifact import,
cross-workspace copy, restore -- refuses; this one did not. Dave's day-71
ruling: the cap applies at import, and the refusal names how many would
land versus the limit.

The check is resolved before the item loop and compared after it, which is
the part worth reading twice:

  - AFTER, because the number that matters is how many items LAND, and that
    is not len(data.Items). The loop skips an item whose collection is
    missing from the bundle, so counting the bundle up front refuses an
    import of 150 items at a 100 cap even when 50 are orphans and only 100
    would land. TestImportWorkspace_OrphanedItemsDoNotCountTowardTheCap is
    the leg that discriminates this; without it the simpler wrong version
    passes everything else.
  - BEFORE for the limit and the prior count, because CheckLimitTx counts
    on THIS transaction and so returns a count that already includes the
    inserts once they have run. Resolving afterwards and subtracting would
    work only by an identity nothing enforces, and getting it wrong refuses
    an import that exactly fills the cap.

The refusal happens inside the transaction that also minted the workspace
(BUG-2892), so nothing is left behind -- no husk holding the slug, which is
what would push the retry that succeeds onto `name-2` with that slug in
every URL afterwards. The first test asserts the absence of the row, not
merely the presence of the error.

The seq lock guards nothing here and is taken only so this door reads like
#1393's: the workspace does not exist to any other writer until commit, so
no concurrent insert can race the count. Recorded in the code so nobody
later reads the lock's presence as evidence of a race.

LimitResult gains `Requested` (omitempty) rather than overloading `Current`.
Current is published in the API error's `details` and means "how many this
workspace holds now" at every other door; setting it to the incoming count
would render "150 of 100" for a workspace holding zero. Two questions, two
fields. planLimitMessage gains one branch for it -- the existing sentence
never reads Current and "You've reached" is false for a workspace the
transaction just minted -- and every other door's message and details are
pinned byte-identical against literals.

Both import doors answer the same refusal. Sharing the store check did not
share the RESPONSE: handlers_workspaces.go already read the error back out,
while the bundle door fell through to a generic wrap carrying a Go-side
string. importStatusError gains an optional details map so it can carry the
same envelope.

Gates: make test, make lint (0 issues), make test-pg all green. The four new
store legs were additionally run against Postgres with an explicit
PAD_TEST_POSTGRES_URL and confirmed PASS by name, rather than inferred from
the suite being green. Mutation: 7 of 7 killed, every mutant built.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
2026-09-17 23:54:41 -04:00
xarmian d7df8a4b4b fix(web): fence child components' post-await sends against identity change (BUG-3095) (#1400)
* wip(BUG-3095): fence rows 1-8 + driven leg (pre-counterfactual checkpoint)

* wip(BUG-3095): population guard + gap list (pre-mutation checkpoint)

* fix(web): fence child components' post-await requests against identity change (BUG-3095)

TimelineVersionCard and ChildItems issue their own requests after awaiting a
parent-provided callback or a previous iteration of their own write loop. None
of the population held an identity fence: every guard present compares the
workspace/item slugs, which do not move when the signed-in USER changes on the
same workspace and item.

Fixes the eight await-then-send paths in those two files, adds a driven leg for
the known member, and lands an AST guard over the whole child population with
the remaining six paths enumerated as known gaps.

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

* test(web): resolve one-hop local-helper sends in the population guard (BUG-3095)

Three independent hand audits of the same population found await-then-send
paths this guard reported as having no send at all: the commonest post-await
send here is not an api.* call but a call to the component's own loader
(loadTimeline, loadChildren). Matching api.* syntactically missed every one --
a false negative, the direction that stays silent.

Adds one hop of local-helper resolution, which surfaces eight further paths
(ItemTimeline's six comment/reaction handlers, CopyItemDialog.runPreflight,
PushToAgentDialog.handleSend); all are enumerated in KNOWN_UNFENCED. Declares
depth, commits and non-await suspension as gaps (c)(d)(e).

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

* fix(web): fence the attachment delete confirmation against identity change (BUG-3095 row 9)

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

---------

Co-authored-by: xarmian <dave@houseofvoi.com>
2026-09-17 23:14:20 -04:00
xarmian 568b113446 fix(editor): pin mermaid's layout engine to dagre across the 12 bump (TASK-3090)
mermaid 12 changes the default layout engine. Read from the shipped
bundles rather than the release note -- mermaidAPI.getConfig() on each
version, with no initialize() call in front of it:

  11.17.2  layout="dagre"  look="classic"  theme="default"
  12.0.0   layout="elk"    look="classic"  theme="default"

So `layout` is the one that moved. `look` is a defensive pin of a value
that did NOT change, written down so a later default change cannot move
our diagrams silently, and `theme` stays 'dark' -- our own value, not a
default, left exactly as it was.

What the pin does and does not buy, measured in a real browser against
two servers (mermaid 11 at main's tip, mermaid 12 on this branch), same
item, same viewport:

  flowchart   377.5x623 -> 426x737     (v12 pinned to dagre)
  state       120.7x412 -> 152x412
  sequence    706x389   -> 706x389     (byte-identical viewBox)

Node counts, edge counts, font size and reading order are identical in
every case. So the pin preserves the LAYOUT -- the graph is laid out the
same way by the same engine -- but mermaid 12 draws that layout LARGER
regardless of this setting. Stored diagrams do not re-flow; they do get
bigger, and no config setting available here changes that.

Without the pin, ELK lays the same graphs out differently again
(flowchart 416x737, state 152x452) and the build's 1.46 MB elk chunk,
which is otherwise never fetched, is downloaded on first render.

This is the site default, not a ceiling: a diagram carrying its own
`config: layout: elk` frontmatter still gets ELK.

Editor.svelte's dynamic import('mermaid') is the only mermaid call site
in the tree (`grep -rln mermaid web/src`).

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
2026-09-17 22:32:02 -04:00
xarmian dd8674255c chore(web): override lodash-es to ^4.18.1 to clear mermaid 12's audit (TASK-3090)
mermaid 12.0.0 introduces chevrotain 11.1.2, which exact-pins
"lodash-es": "4.17.23" in chevrotain, @chevrotain/gast and
@chevrotain/cst-dts-gen. That version carries GHSA-r5fr-rjxr-66jc
(high, code injection via _.template import key names) and
GHSA-f23m-r3pf-42rh (moderate, prototype pollution in _.unset/_.omit),
which is the whole of the PR's 5 high advisories -- the chevrotain and
mermaid rows carry no advisory of their own, every `via` resolves here.

The tree already shipped lodash-es 4.18.1 at the top level (3d-force-graph
-> kapsule takes a ^4 range), so this override does not add a version:
it collapses chevrotain's three nested 4.17.23 duplicates onto the copy
the app already loads. The lockfile delta is exactly those three removals,
with nothing added and no other version changed.

An override is the only instrument available, since the pin is exact.
Overriding chevrotain to 13.2.0 -- which drops lodash-es entirely -- was
rejected because mermaid declares "chevrotain": "~11.1.2", so that would
run mermaid against a parser two majors from what it was tested against.

Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
2026-09-17 22:32:02 -04:00