mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
d048631abc4fea416d08045fb2167db07c22b307
1949 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
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) |
||
|
|
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
|
||
|
|
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 |
||
|
|
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) |
||
|
|
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. |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 (
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
c0253831bd |
chore(nix): heal vendorHash for the module set in 4b1876ab6f (TASK-2954)
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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. |
||
|
|
828c9ba2cf |
feat(items): execution lease — atomic claim/checkout with expiry (#1238)
Implements #1221 (IDEA-2815). Contributor: b4rk13. Closes #1221. |
||
|
|
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 |
||
|
|
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
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |