mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
8ee68e9b4e73dcfbc020b1b9332bc4e5dd2f0e2d
2051 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8ee68e9b4e |
test(server): the body-reader accounting asks the type checker, so a request reached through a struct field, context value, alias or embedding is counted (BUG-2820) (#1546)
TestEveryRequestBodyReaderIsAccountedFor scanned internal/server with a parser that matched the spelling `http.Request` and names bound to it. A request reached through a struct field, a context value, a type alias, an embedded field, an import alias, a dot import, a var declaration, a call-derived alias, a named result or a range binding was invisible, and accounting was per file with a reader count as a workaround. - collectRequestBodyReads loads the package with go/packages and counts every selector whose resolved object is the SAME object that looking the member up on net/http.Request yields (Body, FormValue, PostFormValue, ParseForm, ParseMultipartForm, MultipartForm, PostForm, MultipartReader, FormFile). Accounting is per call site: file::function::member -> count, with the per-file reasons kept. - A fixture package (testdata/bodyreaders) pins the 12 forms the parser could not see, the 9 its own controls held it to, and 6 non-readers, including the two shadowing cases the parser over-flagged on purpose, which are now exact. - New: TestEveryRequestHandoffIsClassified. A request passed to code outside the module must be classified. fosite's five New*Request calls read the OAuth form body (BUG-2811) and are pinned per site; interface dispatch (http.Handler.ServeHTTP) is named as statically unresolvable. - Measured on this tree: the type checker finds exactly the readers the parser found, file for file. The blind spots were real, but nothing used them. - The parser (scanFileForRequestBodyReads) and its controls (TestBodyReaderScanDiscriminates) are retired; their cases live in the fixture. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
25d76af0ba |
fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202) (#1545)
* fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202) Every door that writes an item's fields decoded the blob into map[string]any, turning each JSON number into a float64, so the re-encode rounded integers above 2^53 (9007199254740993 stored as ...992). The stored blob was decoded that way too, so a fields_patch to `status` rewrote every large integer in the row. - models.DecodeFieldsJSON / DecodeJSONKeepingNumbers decode with UseNumber. They accept and refuse exactly what json.Unmarshal does (trailing data, out-of-float64-range numbers); only the representation changes. - Used at every fields decode that feeds a write: create, full-fields update, the fields object on the wire, fields_patch (request and store merge), the carried-blank-relation normalisation, the Append/Set metadata helpers, move, bulk move, cross-workspace copy and its preflight, and the move/copy field_overrides (models.FieldValues). - items.validateFieldType accepts a json.Number that parses as a finite number. items.coerceValue and the CLI's parseFieldFlag keep a numeric string that is a JSON number literal as that literal. - Read-only decoders are unchanged (display, hydration, guards). Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(fields): compare field values by value, so an equal number in another spelling is no change and two integers that share a float64 are (BUG-3202) Field writes now keep a number's literal, so one value can be stored as different text (1e3 vs 1000). Three places compared field values: - item.updated's change detector compared the fields blob's TEXT whenever it was not masking a string done key: an equal-value rewrite, or a key-reorder-only rewrite, emitted item.updated. - the activity `changes` diff and the BUG-3163 carry check compared float64s: equal spellings were fine, but two different integers above 2^53 compared equal, so such a change went unrecorded and a changed reserved value passed as carried. models.CanonicalJSONNumbers gives every number one exact canonical spelling. All three decode keeping literals and compare canonical forms. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
d4ec587de9 |
fix(cli): a CLI call during an install-refresh bounce waits for the refresh instead of auto-starting a server over it (BUG-3215) (#1544)
install-refresh.sh stops the server, installs, and restarts it with the
captured argv. Any pad CLI call in between found nothing answering and
auto-started a server of its own: the caller's binary, cwd and default
flags. That server held the port against the refresh's restart (day 79:
127.0.0.1:7777 went dark and the script exited 1).
- The script writes <DataDir>/refresh.lock {pid, port, deadline} by rename
before the stop and removes it in its EXIT trap, only while it is still
its own. The deadline is 10s + PAD_PROBE_TIMEOUT (30s by default).
- EnsureServer defers to a live marker for its own port (writer pid alive
AND deadline not passed): it polls health and never spawns. If the
writer is still alive past the deadline it errors naming the pid. A dead
writer, a passed deadline, another port or an unparseable file means
the marker is ignored.
- The script names a holder that took the port before the restart spawns
(and does not spawn it), and refuses success when the listener after
the probe is not the restart it spawned. A health probe answers for
whoever holds the address.
- The script-test dependency guard now reads every install_refresh*_test.go.
Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
|
||
|
|
209e18c58b |
fix(web): the remaining direct fetches run under the request deadline (BUG-3216) (#1543)
BUG-3211 bounded request() and every direct fetch a gate waits on. The rest were view-only, but a hung one still left a spinner or a disabled button until navigation: the share view and the share page's password submit (passwordLoading), adminFetch (admin stats and actions), the invitations page's delete (actionSaving), the billing plan-limits read, and the attachment text preview (phase 'loading'). A new withRequestDeadline(fn) runs a fetch and its body reads under the API deadline and maps a timeout to request_timeout (a write's message says it may have landed). Every site's budget is the API's 30s. The text preview links it to its repoint controller, so a repoint stays an AbortError and a timeout lands in 'error', which offers a retry. Uploads and exports stay unbounded by design: their time scales with size. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
38034bb69c |
fix(web): a lane draft is keyed by the field it was typed under, so a regroup cannot drop it into another field's same-named lane (BUG-3214) (#1542)
Lane drafts were keyed by the lane VALUE alone, and the page does not clear
them on a regroup. BUG-3043 re-homed a draft whose key named no lane of the
new grouping, but when the old key equalled a lane of the new field the
draft showed there unmarked and saved into that field (a draft typed in
status's "open" lane became stage: open, with status left at its default).
Drafts are now keyed by draftKey(groupField, lane). A draft typed under
another field is orphaned whatever its lane is called, and is re-homed or
blocked by the BUG-3043 rules, marked with its field ("Moved from Open
(Status)"). Blocked notices carry the map key, so Discard hits the right
draft.
Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
|
||
|
|
8d81f3a75c |
fix(timeline): structured entries get a persisted id instead of a positional one that renumbers when a sibling moves (BUG-2788) (#1540)
* fix(timeline): structured entries get a persisted id instead of a positional one that renumbers when a sibling moves (BUG-2788) An implementation note or decision with no usable id, or with an id an earlier entry already claims, fell to the timeline's "<prefix>-idx-<i>" fallback. That id is the cursor's tie-breaker and the client's list key, and it changed whenever an earlier entry was inserted or removed, or when the first of two duplicates was deleted, so an entry could be skipped or shown twice across a page boundary. models.StructuredEntryIDKey is now the one rule for what a stored id means in the timeline, and models.EnsureStructuredEntryIDs persists a minted id on exactly the entries that rule leaves without one (first holder of a duplicate keeps it). It runs in both append helpers, in workspace import, and in a startup backfill that is silent by ruling (no seq, updated_at, activity or event); the Q1 census that licenses the silence is in its comment. A repaired entry's timeline id changes once, from the positional form to its persisted id, and never again. No MCP surface change. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(timeline): the entry-id repair touches only what the timeline reads, keeps values byte-stable, and its backfill compares the fields it read (BUG-2788 review round 1) - Skip a kind whose array the timeline's typed extraction rejects (a non-object element or non-string id hides the whole kind there), so the repaired population is exactly the positional one. - Leave a blob that is not exactly one JSON value untouched, instead of dropping its trailing bytes; re-encode without HTML escaping. - The backfill's UPDATE compares the fields text it read as well as seq, so a write that changed fields without moving seq is never overwritten. - Import's comment no longer claims byte-verbatim storage for a repaired blob. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(timeline): the entry-id repair leaves a blob the timeline cannot parse at all (BUG-2788 review round 2) The timeline parses the whole fields blob with a plain Unmarshal first, which fails on a number outside float64's range and then shows no entries; the repair decoded with UseNumber and would still have minted ids there. It now requires that same first-stage parse. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
2c8960bd58 |
fix(web): a request the server never answers times out, so a gate held across it is released instead of held for ever (BUG-3211) (#1541)
request() passed no signal and set no timeout, so a request the server accepted but never answered (a stalled proxy, a half-open connection after a laptop sleep, a wedged handler) was awaited indefinitely, and every gate held across the await held with it: sync's lock, the local index's dedup promises, the pane's save indicator, the SSE reconnect. - request(): a 30s deadline per attempt, covering headers and body, linked by hand to a caller's signal. It is armed only around the fetch, so the 429 cooldown and retry sleep never count against it. A timeout is PadApiError 'request_timeout'; on a write the message says the change may still have been saved, and it is never retried. The value's receipt and its unmeasured limits sit beside the constant. - The direct fetches a gate waits on get the same bound: auth.session() (authStore.load dedup), billing checkout (checkoutInProgress), the attachment-metadata HEAD (a shared promise cache), probeRefusal (10s; reconnectPending), and attachments.transform (its own 120s). - Uploads and exports stay unbounded by design; the remaining view-only direct fetches are BUG-3216. Every gate in the population releases on a rejection (finally or a resetting catch), so the timeout reaches it; the table is on the trail. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
f896af9398 |
fix(store): restore and move return their own in-tx snapshot, and bulk member snapshots are read in batches (BUG-2717, BUG-2718) (#1539)
* fix(store): restore and move return their own in-tx snapshot, and bulk member snapshots are read in batches (BUG-2717, BUG-2718) BUG-2717: restoreItemOnce and MoveItemWithPreCheck read the item inside their transaction for the outbox event and then re-read it after COMMIT to return it. The re-read could fail after a successful commit and could hand back a concurrent writer's row. Both now return the in-tx snapshot, as the update path has since BUG-2264. A test-only post-commit seam lets a test land a real concurrent write in the old window; with the re-reads put back, both tests return the other writer's title and seq. BUG-2718: outboxMemberSnapshotsTx issued one joined read per bulk member, inside the caller's transaction and under its lock. It now reads in IN (...) batches of 400 through the SAME select and scanner as a single-item read (itemSnapshotSelect / scanItemSnapshot, extracted from getItemScanQ), so a batched snapshot cannot differ in shape. De-duplication, first-occurrence order, skip-if-missing and the PII scrub are unchanged, pinned against the per-member reader across a chunk boundary. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(store): a batched member-snapshot error names its chunk (BUG-2718 review round 1) The per-member read wrapped each error with the member id; a batch has no single member, so its errors now carry the chunk's size and first id. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
46c91b9062 |
fix(web): a lane draft whose lane no longer exists moves to Uncategorized, marked, instead of blocking Save all invisibly (BUG-3043) (#1538)
* fix(web): a lane draft whose lane no longer exists moves to Uncategorized, marked, instead of blocking Save all invisibly (BUG-3043) A lane draft is keyed by the lane it was typed in, and nothing tied that key to a lane that still exists. Delete or rename the option, retype the group field, or regroup the board, and the draft's card vanished while its text stayed in the page's map. "Save all" then either sent the dead lane value (the server refused it; the dialog could not complete, blocked by a draft the user could not see) or, where the client refused it first, read the create's null as success and deleted the text behind a toast. Lead ruling, RE-HOME: an orphaned draft shows inside the Uncategorized lane, marked "Moved from <lane>", and saves there. The lane is rendered for it even when no item is in it. Where Uncategorized cannot receive the create (the field omits a blank and a default would fill it, or it is required), the draft is kept, a notice names the lost lane, and Save all is blocked for that reason. Whether Uncategorized can receive it follows the server's validator per field type (laneDrafts.ts header). quickCreateInColumn refuses by throwing, never by returning null, and the Save-all loop (saveAllDrafts) treats a null with the identity held as a failure that keeps the text. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(items): pin the validator facts the lane-draft re-home rule mirrors (BUG-3043) draftSaveTarget decides client-side whether Uncategorized can receive a draft's create. It mirrors three facts about ValidateFields: a required select stores '' (required fires only on an absent or nil key, and '' skips the options check), a required multi_select stores [], and an omitted key takes its default or is refused as required. Pinned here so a validator change fails a Go test instead of leaving the mirror wrong in silence. Removing the select's '' exemption turns fact 1 red. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
225205052b |
test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213) (#1536)
* test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213) settleScale accepted two equal reads of the rendered matrix as "settled". Under an 8-worker load the renderer can produce no frame between two polls, so a mid-transition scale read as final: the touch spec's pre-zoom stopped at 1.19-2.20 when it needed more than 2.3, on 11/120 runs on main and on c0819dd4^ alike. "Settled" now means no live transform CSSTransition on the viewer image, checked after flushing style. Two animation frames are awaited first: Chromium delivers touch moves aligned to frames, and without the flush the pinch oracle read one move step early (exactly 6.25px = 50px/8 in X, 17/120). The old fixpoint's polling delay had been hiding that. The zoom spec's private copy of the same fixpoint now uses the shared helper. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(e2e): settleScale awaits each transform transition's finished in the page instead of polling (BUG-3213) expect.poll's 100/250/500ms backoff, paid once per zoom step, stretched the zoom spec's 20-step zoomToMax toward its 30s budget under load. A cancelled (retargeted) transition rejects finished, so the wait loops until no transform transition is live. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(e2e): zoomToMax presses in bursts and settles once per burst, so an honest settle does not stretch its callers toward the timeout (BUG-3213) Each '+' computes from the viewer's zoom state, so a burst of four lands exactly four steps; settleScale waits for the transition to end, so a burst that does not climb is the clamp. Measured x40 at 8 workers (p50/max): controls-at-max 14.1/15.6 on main -> 9.7/12.9, pan-clamp 17.2/19.6 -> 14.2/18.7, resize-clamp 15.2/19.5 -> 11.6/17.2. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
c1fa878889 |
fix(attachments,mcp-audit): record where a stored filename or tool name came from, instead of sentinel strings a caller can choose (BUG-2819) (#1537)
* fix(attachments,mcp-audit): record where a stored filename or tool name came from, instead of sentinel strings a caller can choose (BUG-2819)
attachments.filename and mcp_audit_log.tool_name each held either the
caller's value or a server substitution with no marker, and the caller could
send the substitution itself ("upload.bin", "(unknown)"). New columns,
CHECK-constrained on both dialects and defined once in Go:
- attachments.filename_source: caller | normalised | substituted | derived |
unknown, decided by NormalizeFilenameWithSource at the upload and import
doors (raw vs stored), derived for thumbnails and transforms, carried by
clones, and carried through bundle export/import as the bundle's claim.
- mcp_audit_log.tool_name_source: caller | sanitised | synthesised | unknown,
decided at each branch of the audit parser.
Existing rows default to unknown, never caller. The (sanitised)/(unknown)
namespace reservation stays as defence in depth. filename_source reaches
pad_attachment list on both transports, so ToolSurfaceVersion 0.53
(additive).
Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
* fix(attachments): the bidi filename backfill keeps filename_source true when it rewrites a name (BUG-2819 review round 1)
The startup backfill rewrites a stored filename that carries a Bidi_Control
character, and left filename_source untouched, so a row recorded as the
caller's own name would keep that claim over a server-altered name. A
rewritten 'caller' row now reads 'normalised'; every other value is still
true after the rewrite. The 0.53 entry now says the key is always present.
Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
|
||
|
|
8f297f7430 |
fix(store): a collection gated by ID is queried by ID, so a slug freed and re-taken in between cannot answer for another collection (BUG-2631) (#1535)
Six sites resolved or gated a collection by ID and then filtered the item query by that collection's slug; a rename landing in between could hand the response another collection's items (worst case the public collection share link, which serves anonymous viewers). They now set a new ItemListParams/ItemIndexParams.ScopeCollectionID, ANDed on its own and kept out of the OR-ed CollectionIDs/ItemIDs permission pair, which is what made the obvious fix a visibility hole on the first attempt. scalarRelationFilterKeys reads the scope too, so the empty-relation-filter broadening (BUG-3028) still finds the schema. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
a814a96b42 |
fix(api): a workspace that does not resolve answers 404 not_found marked details.scope=workspace, so a client can tell it from a missing item (BUG-3069) (#1534)
* fix(api): a workspace that does not resolve answers 404 not_found marked details.scope=workspace, so a client can tell it from a missing item (BUG-3069)
The workspace-resolution refusal and a missing sub-resource were byte-identical
404 not_found responses that demand opposite reactions. The web client inferred
which from the request path (BUG-2983), which cannot classify item reads and
breaks silently the day a workspace-level endpoint grows its own not_found.
Every workspace-resolution refusal (13 sites) now goes through one helper that
adds details {"scope":"workspace"}; status, code and message are unchanged, so
no consumer keyed on not_found moves (ruled over a new code). The web client
keys its purge seam on the marker and keeps the path rule as the fallback for
older servers. No MCP ToolSurfaceVersion bump: the MCP error surface does not
forward 404 details on either transport.
Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
* fix(api): a store error on workspace delete or oauth claim is a 500, not the marked workspace 404 (BUG-3069 review round 1)
Both sites mapped any lookup/delete error onto the workspace-not-found
response, which after the marker tells a client to stop asking about a
workspace that may be perfectly live. Only sql.ErrNoRows (delete) and a nil
workspace (claim) are resolution refusals now.
Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
|
||
|
|
326acd29af |
fix(web): the item pane's save indicator counts the writes outstanding, so "Saved" never shows over a write still in flight (BUG-3044) (#1532)
* fix(web): the item pane's save indicator counts the writes outstanding, so "Saved" never shows over a write still in flight (BUG-3044) saveStatus was one value written by whichever write reached it last. An older write's success showed "Saved" while a newer one was in flight (or after the newest had failed); one field's failure hid another's write; a previous save's two-second timer fired mid-request. The pane's SSE gates read that value, so each also admitted an incoming event while a local write was outstanding. SaveTracker (web/src/lib/items/saveTracker.svelte.ts) counts outstanding writes: 'saving' while any is out, 'saved' once the last settles only if the newest-issued one succeeded, 'idle' otherwise. Starting a write clears the old timer. A reset at load bumps an epoch, so a previous item's late settle is ignored. saveStatus is now $derived from it, and the ten SSE-gate reads are unchanged. All nine save paths begin a token, record success through showSaved(saveTok), and settle in a finally, so the returns that used to leave the status alone (a superseded field write, a superseded content write, the collab flush's force_refresh skip, an item switch) still count down. Without that, a count would pin 'saving', and every SSE gate with it, until navigation. The raw drain uses one token for the whole drain. Identity-fence gate (lead ruling): each settle after an unfenced await is tabled per row as an allowed commit whose reason cites the tracker's stale-epoch test. The analysis is not taught to trust the epoch. Rows whose coverage changed are re-signed. Every changed coverage run traces to this diff: 25 rows, 285 token-run edits; the 20 not verbatim in it are boundary splits of the import, the finally blocks, the removed timer declaration and the helper body. Fixtures are migrated by the same rename. Kill table, old fixtures on main against migrated fixtures here: 171 fixtures, 0 verdict flips. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * docs(web): the raw drain's one save token is the truthful status, stated on its own terms (BUG-3044) Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
372f91f03b |
fix(events): a resume from exactly the discarded peak is refused after a counter-backwards reset, instead of being handed the new sequence as though it followed (BUG-3206) (#1530)
* fix(events): a resume from exactly the discarded peak is refused after a counter-backwards reset, instead of being handed the new sequence as though it followed (BUG-3206) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * docs(BUG-3206): the floor does not refuse discarded+1, which is narrower than serving it (codex round 1) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
6d25e67456 |
test(BUG-2683): pin that a bulk move records an ordinary dropped field in its activity row, the case the bug was filed for (#1531)
The report itself shipped in TASK-2878 (#1246); its existing pin covered only a relation drop. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
7458fa87f4 |
test(BUG-2684): pin that the copy preflight labels a dropped-then-defaulted key from=default, the case the bug was filed for (#1533)
The labelling fix shipped in TASK-2878 (#1246, codex round 9); existing pins covered only null-source and destination-only defaults. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
53c567dd4a |
fix(web): the sync cursor is only ever a server time taken before the reads it vouches for (BUG-3207) (#1525)
* fix(web): the sync cursor is only ever a server time taken before the reads it vouches for (BUG-3207) Three cursor writes used the client's wall clock: the initial value, a failed seed, and markSynced() after every successful full reload. A client running ahead of the server by D skipped every change committed in that window, on the hottest of the three paths. markSynced also stamped at the END of the reload, which skips a change committed between the reads and the stamp even with a perfect clock. - serverClock.ts estimates the server clock from response Date headers, projected by monotonic elapsed time, minus 1 s. It errs early only: an early cursor re-delivers (a duplicate), a late one skips (a miss). - stamp() is taken BEFORE a full reload's reads and passed to markSynced(stamp). With no response seen yet it asks /changes once. - A failed seed writes nothing. An unseeded cursor (0) answers full_refresh instead of asking since=0. - The seed's since uses the estimate, so a client running ahead still gets the changes between its page load and the seed. - ItemDetail no longer calls markSynced (lead ruling): a single-item reload cannot vouch for the workspace cursor. The layout is the only writer. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(web): re-sign the ItemDetail fence rows the markSynced removal moved, and state what the duplicate legs measure (BUG-3207) The AST gate hashes each async unit with its enclosing onMount, so removing the sync callback's markSynced call moved both the SSE and the sync row. Both were re-read against the new code: the SSE callback is untouched, and the sync change removes one synchronous call after the existing fence, adding no await and no commit. Both rows hold, and their hashes are updated. The reconcile-duplicate legs now say what mutants measured: the same-row duplicate is idempotent by content (no guard mutant can redden it), and the late older copy is held by the cursor-floor and existing-seq guards together. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * fix(web): the server-clock estimate is a whole millisecond, so /changes accepts the cursor it stamps (BUG-3207) performance.now() is fractional, and handleGetChanges parses `since` with ParseInt. A stamp taken from the estimate became a fractional cursor, every /changes asked from it answered 400, and each sync degraded to a full reload for the rest of the tab. Measured on the e2e trail: 11 of 11 first-attempt failures of bug-2539-sync-window sent a fractional since after the archive, 0 of 49 passes did. Floor rather than round, so the estimate still never runs ahead. The cursor model's /changes mock now refuses a non-integer since the way the server does. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * fix(web): a sync signal that beats the seed waits for it instead of reloading the workspace (BUG-3207) An unseeded cursor answers full_refresh, and the SSE connect and the first events routinely arrive while the seed request is still out. Each of those signals cost a whole-workspace reconcile where main asked one incremental /changes: 77 of 160 instrumented page loads under load. A signal reaching an unseeded cursor now awaits the in-flight seed and asks from its server_time, which is taken before the seed's reads, so the pass covers everything the seed's own delta did not. A tab whose seed failed still answers full_refresh. bug-2539-sync-window: the oracle now awaits every /changes issued after the archive before reading the record. One issued after the archive but not yet answered at check time read as "nothing carried it". Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * fix(web): an early sync signal waits for the seed at most SEED_WAIT_MS, and a failed seed still ends in full_refresh (BUG-3207) The API client has no request timeout, so an unbounded wait on a seed that never answers held the signal, and the sync lock with it, forever. The wait is bounded at 5 s against a measured seed latency of p50 160 ms / max 874 ms at 8 workers. Two legs pin the lead's question: a FAILED seed and a HUNG seed each leave the awaited signal answering full_refresh, never dropped. bug-2539-sync-window: a non-seed /changes that carried the deletion passes whenever the client sent it. Client send time was the wrong test: a response that carries the deletion was read after the archive committed. Negative control: with ItemsModifiedSince comparing deleted_at with > instead of >=, 20 of 20 same-second runs fail. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
67b25d1dc6 |
docs(BUG-2743): the ambiguity boundary refuses a new-space client on every reconnect until the space climbs past the old peak, not once (#1529)
Corrects the stated cost in the field comment, the counter-reset arm, and docs/deployment.md. The behaviour is unchanged, by lead ruling (a) on BUG-2743's trail. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
e4a8398e29 |
test(BUG-3199): the concurrency test's straddle is built with two barriers instead of hoped for with a 2ms sleep (#1519)
* test(BUG-3199): the concurrency test's straddle is built with two barriers instead of hoped for with a 2ms sleep Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * test(BUG-3199): release the producer's barrier on every exit, and say the race is possible rather than forced (codex round 1) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
70f7c0c15c |
fix(watchevents): a resume from a cursor inside an abandoned id space is refused on every arm, instead of being served the new space's ids or told it is caught up (BUG-2743, BUG-2728) (#1518)
* fix(watchevents): a resume from a cursor inside an abandoned id space is refused on every arm, instead of being served the new space's ids or told it is caught up (BUG-2743, BUG-2728) Every arm that detects a watch-stream id-space restart (the epoch arm and both counter-backward arms) now records the old space's high-water mark, and replaySince refuses any cursor at or below it. The boundary is never cleared: climbing past the old peak does not make a cursor below it any less ambiguous. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * docs(BUG-2743): the counter-reset arm's prose states the boundary's cost and which id the resume check compares (codex round 1) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(watch): pad watch --stream prints a PAD (resync) line on sync_required, so the agent reading it learns it missed notifications instead of carrying on (BUG-2743 follow-through) Clearing the cursor was the process's whole response; the consumer of its stdout could not see a cursor and was never told. The line goes through the same consent gate as a notification, and repeats with no notification between them are folded into one. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * docs(plugin): tell the agent what a PAD (resync) monitor line means and what to do with it (BUG-2743 follow-through) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * docs(BUG-2743): the resync line says the stream MAY have missed notifications, since sync_required means the span is unvouched, not certainly lossy (codex round 3) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
7e2545a0d3 |
fix(web): two quick clicks on a checkbox field are two toggles, not one lost (BUG-3047) (#1526)
The toggle sent !value from the prop, which lags every write until its round trip lands, so two quick clicks both sent the same value and the field ended where it started. It now toggles the newest value it sent for this item while that write is outstanding, and the switch shows it at once, through the same record and display hold the number step uses (sendTyped / typedDisplay). So it inherits their release paths: its echo coming home, the write settling (a refused toggle returns to the stored value), a subject swap, unmount. The number step half of the filing was already fixed on main by BUG-3039 (it steps from what was last sent); a leg pins it. Tests: two toggles send true then false; the switch shows each click before the prop catches up; an older echo does not flip it back; a refused write releases it; a toggle outstanding for another item is not a base. Four mutants (base from prop, display from prop, no display hold, no item check) all killed. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
c9aae56463 |
fix(web): a queued open-children confirmation that is no longer live is never shown (BUG-3046) (#1528)
A request queued behind another open-children prompt was shown after it even when its reason had gone (the pane switched items, or a newer edit superseded the one that raised it), asking the user to decide something no longer live. The requester now supplies an isLive predicate (only it knows its pane identity and write order), which the store asks when a queued entry is about to be SHOWN. A dead one resolves false, the cancel answer, and the queue moves on to the next live entry. ItemDetail's field write passes stillCurrent() and supersession, the move passes stillOnSource, and the collection page passes its identity fence. Tests: the store skips dead entries and asks at show time; the helper hands the predicate through (a dead prompt never runs the forced retry); a source guard pins that every call site passes one. The two ItemDetail fence rows the change moved (updateField, handleMove) were re-read and re-signed. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
a2917bf97a |
fix(web): a collection named "Constructor" no longer crashes the command palette, and no other value naming an Object.prototype member crashes a view or loses its lane (BUG-3054) (#1527)
* fix(web): a value that names an Object.prototype member no longer crashes a view or loses its lane (BUG-3054) Plain objects keyed by user data (a group value, a status, a collection slug, a file extension) answered an INHERITED member for keys like constructor, toString or __proto__: a push onto a function threw, __proto__ re-parented the object, and ?? fallbacks passed the truthy member through. Swept by construction (population table on the BUG-3054 trail): - ListView and ChildItems lane buckets: prefixed keys (lane:<value>). They stay plain objects because they are $state, and Svelte proxies only objects whose prototype is Object.prototype. - graph palette (the page now uses the shared assigner instead of its private copy), CommandPalette grouping (extracted to groupResults.ts), and EditCollectionModal option renames: null-prototype objects. - literal lookups keyed by user data (EmptyState copy, the collection page's empty hint, attachment extension families, the playbook status order, the library enforcement colour): the new ownValue helper. - the collection page's itemCounts was dead code (never read) and is deleted. Residual, recorded: boardColumns.bucketByColumn with a schema option literally named __proto__. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(web): index the constructor group with a string-typed key, since TypeScript types .constructor as Function (BUG-3054) Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
a2e08bfe5a |
test(web): the ItemGraph mount helper waits for readiness by time, not by poll count (BUG-3203) (#1524)
* test(web): the ItemGraph mount helper waits for readiness by time, not by poll count (BUG-3203)
The helper gave the graph 50 polls of setTimeout(5), about 250 ms. The mount
chain includes a dynamic import('@dagrejs/dagre'), which is IO-bound, so under
full-suite load the timers keep firing while it waits and the budget ran out
before the first node rendered (seen once in make web-test, at mount :163).
Measured: every mount is ready on the first poll (72-76 ms isolated for the
file's first test, 150 ms under the full suite). Control: a 400 ms async step
in the chain failed all 8 tests at :163 with the old helper and passes with
the new one; a 4 s step still fails loudly at the node assertion rather than
timing out. Siblings with the same loop (console shell, TopBar) wait on a
mocked probe and rAF, not IO, and are left alone.
Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
* test(web): the ItemGraph mount budget carries its measured receipt (BUG-3203)
Lead condition on the GO: state the budget and why it is enough, so it does
not drift upward. 3 s against a worst measured mount of 150 ms; a planted
400 ms step fails the old budget 8/8 and passes this one.
Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
|
||
|
|
5742393520 |
fix(web): a pending lightbox delete confirmation owns the pointer gestures, as it already owned the keyboard (BUG-2522) (#1522)
* fix(web): a pending lightbox delete confirmation owns the pointer gestures, as it already owned the keyboard (BUG-2522) While the toolbar's delete confirmation was pending, onKeydown returned for every key, but wheel zoom, double-click, mouse/pen pan, touch pan, pinch and double-tap all stayed live on the stage behind it. Each gesture START path now stands down while deleteConfirm.pending (the wheel is still consumed, so the page behind cannot scroll), and the reactive gesture teardown also fires when the confirmation opens, so a pan already held when another finger taps Delete stops there. Tests: one leg per start path plus the already-live pan, each red on the old code, each with a cancel-then-same-gesture control. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(web): a mouse pan live when the delete confirmation opens is torn down (BUG-2522) The touch straddle leg could not see the confirmation's own teardown trigger, because the paint-arm leg of the same effect already tears a touch gesture down (a mutant removing the trigger survived it). This leg kills that mutant. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
aa43128917 |
test(e2e): a stuck list cursor in the attachment-viewer owner-2 spec names the guard that dropped the key (BUG-3204) (#1523)
The spec failed once in CI (run 36036738521) with the cursor stuck through both j and k, kept no trace, and has not reproduced locally (0/50, including 6x CPU throttle). The collection route drops j/k for a still-held viewer lease or native modal (a product fault) OR for focus inside the pane, an editable or a menu (a harness fault), and the log cannot tell them apart. expectCursorMoves now records, at each press, where focus is, whether it sits where the route bails, the viewer roots and native modals isBlockedByModal reads, and the inert body children, and attaches them to the failure. A negative control (focus planted in the pane) produced activeInPane: true. No fix is chosen until an occurrence carries this evidence. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
c0819dd48c |
fix(web): a wheel over a lightbox nav arrow or the close button no longer zooms the image (BUG-2507) (#1521)
The wheel handler excluded only the toolbar and meta, while pointerdown and double-click share chromeExcluded, which also names close, nav, retry and tap-load. Close and nav are on screen with a bitmap present, so a wheel over either zoomed the image under the cursor. The wheel now calls chromeExcluded, so the three gestures share one list and cannot drift apart again. Tests: a wheel-inert leg for prev, next and close, each red on the old code (scale 1 -> 1.25) with a same-wheel-over-the-image control that zooms. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
be37c14c4a |
fix(web): the workspace sync seed delivers the changes it returns instead of dropping them (BUG-3201) (#1520)
syncService.setWorkspace seeds its time cursor from `/changes?since=<now>` and kept only server_time. That response holds the changes committed between the request's `since` and the server's clock, and the cursor moves past them, so no later sync can return them. Measured with a network-held seed: a child renamed inside that window stayed stale in its parent's children panel, 3/3, even across a tab-resume (the resume answered caught_up, and ChildItems reloads only on a result that is not caught_up), while the same rename after the seed healed on the resume, 3/3. The collection list was never stale: it renders from the seq-based local index, which the resume also drains. A non-empty seed delta is now delivered as an incremental result for its workspace; an empty one sends nothing. The cursor is written only by the newest seed (a generation counter, so A, then B, then A cannot let the first A's late clock win), while a seed's delta is delivered whenever its workspace is still the current one, including when a later seed for the same slug superseded it (codex round 1: dropping that delta was the same loss, one call removed). A superseded failure no longer writes the client-clock fallback. Residual, unchanged: when the NEWEST seed fails, the cursor still falls back to the client clock, which skips changes if that clock runs ahead of the server's. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
271174f2a5 |
fix(web): a server sync_required is answered after a per-tab random delay, so a fleet-wide coverage drop does not land every tab's resync on the database at once (BUG-2761) (#1517)
* fix(web): a server sync_required is answered after a per-tab random delay, so a fleet-wide coverage drop does not land every tab's resync on the database at once (BUG-2761) Item-change reconciles (items_bulk_updated, a collection change that rewrote items) and the first-connect sync stay immediate, per the lead's ruling on the item's trail. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * test(BUG-2761): pin the fold at the first signal's delay, and read needsSync after clearing the connect's own flag Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(BUG-2761): relay the spread as a flag on the existing envelope, so an older-bundle peer answers at once instead of dropping the signal (codex round 1) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * docs(BUG-2761): onSyncRequired describes the mid-stream signal this client actually receives (codex round 2) Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
1169a05929 |
fix(editor): opening an item without typing no longer rewrites a body the editor does not reproduce byte for byte (BUG-3197) (#1515)
The collab flush dedupes a no-edit view by comparing the editor's markdown with the seed it was loaded from. The editor does not reproduce every stored dialect exactly (`* a` comes back `- a`, a setext heading comes back ATX, a leading rule gains a blank line, a bare URL is autolinked to `<url>`, a body ending in a table gains a newline), so for those bodies the compare missed and merely opening the item PATCHed the normalised body and wrote a version row. A census of a copy of the live DB found 7,119 of 9,002 non-empty bodies are not fixed points of the round trip. The editor-space dedupe gains a second arm: the editor's own serialization of the seed. It replays setContent on a DETACHED state (the live editor's parser and schema, then one replace-document transaction applied to a state carrying the live editor's plugins minus the Yjs ones and filterInvalidContent, so appendTransaction plugins run as they do for real), and the live document is never touched. Parse-only diverged from the real path on 28 of 9,002 live bodies; this diverges on 0. A transaction a filter refuses answers null rather than the empty document, so a whole-body delete can never be deduped against it. The result is memoized on the flush context and primed while the editor is alive, because the teardown flush runs after the child editor is destroyed; any failure falls back to the old compare. Revert-safety is unchanged: the arm applies only before the session's first flush. A real edit still writes, and may normalise the whole body; that is accepted. The collab-watermark proof's prose is widened to the canonical case: the stamp still sends the stored body's hash, and in both cases the proof is about markdown. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
a231d0b65a |
fix(BUG-2367): a move or copy that would reopen, close or un-abandon an item must name the status instead of inheriting whatever survived migration (#1516)
Before this, every migrate door carried whichever done-field value survived
migration, or injected the destination default. A done task moved into
ideas silently reopened as `new`, and an abandoned item could land counted
as shipped.
Now every door (single move, bulk move, cross-workspace copy and its
preflight) compares the item's CloseState (open / done / abandoned, derived
from IsTerminalItem + IsAbandonedItem) before and after. A change is
refused unless the caller names the destination done field. Only the state
is compared, so two different values that both mean done carry without
asking. A destination with no done select field is exempt.
- move: 400 state_change_requires_value, details {field, options, from, to}
- bulk move: the same refusal in failed[]; `move` + `status` counts as supplied
- copy: 400 validation_error with the same sentence, after the unique check,
in the preflight's order
- preflight: a needs_value row with reason `state_change`, which the web
dialog's existing picker renders
- CLI: `hint: pad item move REF COLL --field status=<a|b|c>`
ToolSurfaceVersion 0.52 (behaviour bump). Missing needs_value routes on the
pane-menu move, CLI move and MCP are BUG-3200.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
|
||
|
|
fd95b79694 |
fix(BUG-2367): moves and copies never carry into a computed field, and a carried unique-field collision is dropped and named instead of 500ing or storing a duplicate (#1514)
MigrateFields matched fields by key and type only. On every migrate door (single move, bulk move, cross-workspace copy and its preflight): - a value was carried into a destination field marked `computed`. It is now dropped, with preflight reason `target_computed`. - a value colliding on a destination `unique_scope` field answered 500 on single move (invocation_slug, which has an index), leaked the SQL error through bulk move, and stored a duplicate on an unindexed unique field. Provenance decides now, as TASK-2878 did for relation referents: a CARRIED collision is dropped and reported as `not_unique`, and an override or schema default that collides is refused with 409 conflict. A required field emptied by the drop is refused as required. One store check (UniqueFieldConflictsQ / DropCarriedUniqueCollisionsQ) serves all four doors, including the tx-bound copy. The report names the value, and names the item holding it only when the caller may see that item. The activity row always uses the holder-free sentence. Reporting, all additive and omitempty: warnings.not_unique on the move response, the copy response and bulk updated[] rows; `detail` on the preflight's dropped row; CLI copy dry run and result, plus ` dropped:` lines on `item move` stdout; web copy dialog, move and copy toasts, and the timeline move card. ToolSurfaceVersion 0.51 (behaviour bump). The relation leg of the original report was already closed by TASK-2878. Item 4 (terminal/abandoned state change) is PR 2. Out of scope and filed separately: BUG-3200 (missing needs_value routes) and BUG-3202 (float64 precision; the migrateCopyFields comment now points there). Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
a1b4c4c867 |
fix(editor): an upload finishing while the master is frozen is inserted on thaw, and interrupted image edits and source refreshes say so (BUG-2177) (#1511)
When a detail pane opens, the master editor is frozen in place. An attachment upload that finished during the freeze used to drop its result: the bytes were stored and nothing referenced them, with no notice. A rotate, crop or source refresh finishing then was dropped the same way. The upload is now deferred: its result is parked on the upload plugin's placeholder entry, which is mapped through every transaction, and the plugin view inserts it once the editor is editable again. A placeholder whose position was deleted outright (its content removed, or the document replaced) is never inserted at the deletion boundary; the user is told the file is stored but not inserted, as they are when the editor is destroyed first. A failure while frozen is reported, since every entry point refuses to start an upload on a read-only view. Rotate, crop and source refresh are not replayed on thaw (applying them minutes later would change the document while the user is not looking); the user is told they were interrupted. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
e4224da26b |
fix(progress): an abandoned child leaves both the done count and the total, and 0/0 renders as no progress (BUG-3195) (#1510)
* fix(progress): an abandoned child leaves both the done count and the total, server side (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(progress): every progress door leaves abandoned children out, member and guest, through the router (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(store): done-field progress tests expect abandoned children out of both counts (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(progress): the web's per-row child counts leave abandoned children out, and 0/0 renders as no progress (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(progress): e2e for a cancelled child left out of the parent's progress; the redundant TableView guard is reverted (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(progress): the web's abandoned resolver and its Go/TS negative-terminal parity are pinned (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(progress): the web's per-row counts judge each child by its own collection's done field, case-insensitively, as the server does (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(progress): the web resolvers fall back on odd stored JSON, and per-row counts ignore a collection store stamped for another workspace (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(progress): the web done-field resolvers validate stored schema fields in one place and fall back as Go does on a mistyped schema (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(progress): the schema validator's comment states its parity limit on corrupt data (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(progress): one shared vector pins the Go and web abandoned-child rules, including the corrupt-schema divergence (BUG-3195) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
0362057861 |
fix(web): an item change delivered while the item page is still loading is recorded and re-read, not dropped (BUG-3198) (#1513)
ItemDetail's loadData installs `item` only after the item read, the collection read and the index bootstrap all resolve, and the bootstrap is the slow leg. A change could land after the item read and be delivered (a sync result, an SSE item event, a collection migration) before `item` existed. Every handler returned early without an item, so the change was dropped and the pre-change row stayed on screen: an item archived in that window never showed its Archived banner. BUG-3192 Unit A (#1502) exposed it. The route effect used to re-run loadData on each bootstrapState write, and the rerun after the index landed read the item again, which covered the drop. One load per page is correct; the drop was not. The handlers now record what they would have applied (item ids, a full refresh, or a migrated collection) while no item of the requested ref is installed, and loadData re-reads the item once if the record names the item it installed. That read is issued after the change, so no ask is answered by an older read. runOwedRefresh, which does the re-read, now also stops if the pane is destroyed mid-read. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
b2bee3242f |
fix(scripts): install-refresh restores the server's own cwd and matches the running server by identity (BUG-3196) (#1512)
* fix(scripts): install-refresh restores the server's own cwd with its argv, refuses a deleted one, and matches the running server by identity rather than the built binary's name (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(scripts): one existence check refuses a deleted server cwd, naming the path without the kernel's suffix (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(scripts): the no-/proc refresh test declares lsof, which now reads the server's cwd there (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(scripts): the lsof dependency sits on the requireScriptDeps line the guard reads (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(make): the install target's guarantees include the restored cwd (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(scripts): the server's cwd is re-checked immediately before the stop, and the capture's limits are stated (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(scripts): a failed restart cd says what state it leaves, not that no server runs (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(scripts): an unreadable server cwd is refused naming what was read and its error, and the case is tested (BUG-3196) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
7fcbcaabcf |
test(web): a superseded collections load does not commit, tested through collectionStore (BUG-2144) (#1509)
loadCollections already drops a response a newer load superseded (collectionsFlight's isLatest), which is BUG-2144's fix, but nothing tested that binding: with the drop removed, every store test still passed. The existing superseded-load leg resolves every request with the same list, so it could not see which one committed. This one answers each workspace with its own list and resolves them out of order. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
f7d4b61620 |
fix(editor): a leading YAML frontmatter block survives the markdown round trip instead of flattening into a heading (BUG-2692) (#1508)
The editor parsed `---\ntitle: x\ndate: y\n---` as a horizontal rule followed by a setext heading, and serialized it back as `---\n\n## title: x date: y`, a fixed point. Four blog posts are stored that way, and the blog-publish contract reads the block. A markdown-it block rule now turns a strictly recognised leading block into the existing codeBlock node with language `frontmatter`: no new node type, so the persisted Y.Doc shape and SCHEMA_VERSION are unchanged. It fires only for a `---` at byte 0, a later `---` line, and only key: value / key: lines, indented continuations or blank lines between, so a body that opens with a horizontal rule stays a rule. The block serializes back as `---` only when it is the first block and still passes the same check; otherwise as a frontmatter fence, so an edit never re-flattens it. Every other language keeps tiptap-markdown's default codeBlock spec. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
7c2c514d72 |
fix(store): a workspace import no longer re-inserts every item into items_fts after its commit, and migration 095 rebuilds the index (BUG-2758) (#1507)
ImportWorkspace and RemapAttachmentReferencesInWorkspace each ran a post-commit rebuildFTSForWorkspace: one autocommit INSERT INTO items_fts per item. items_fts is an external-content FTS5 table kept by the items_fts_* triggers, which had already indexed those rows inside the transaction. So the pass double-indexed every imported item (FTS5 integrity-check reports the table malformed), and it turned an N-item import into N write transactions after the commit, which stalled every other writer on the database. Under e2e load that stall outlasted the 30 s busy_timeout, which is why version-restore-collab and workspace-bundle-roundtrip failed together. The pass and both call sites are removed; the triggers are the index. Migration 095 (SQLite chain only) drops and recreates items_fts and its three triggers with 005's definitions, then runs FTS5 'rebuild', so it repairs existing databases whatever state they are in. slowSQLiteMigrations logs a line before it starts. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
f5b0062d6e |
fix(store): NUL Layer B covers columns added after 084 through later generated trigger files (BUG-3108) (#1506)
* fix(store): NUL Layer B covers columns added after 084 through a later generated trigger file (BUG-3108) The trigger generator had one hardwired target, 084, which runs before every later migration's columns exist, so every post-084 column could only be baselined. The trigger files are now an ordered list; each protected column is assigned to one, 084 by default. The generator writes every file and the pin test compares every file. The startup re-assertion wants and re-executes only the files already applied, because it runs after every migration, including the ones before a later column exists. 094 carries the three post-084 columns that hold caller text: items.lease_holder (request body, verbatim), item_relation_links.source_field_key (a schema field key) and item_decisions.model (the admin model pin). 084 is byte-identical. TestNULTriggerFilesFollowTheirColumns walks the migration chain one file at a time and fails when a column's trigger file does not run after the migration that introduced it, naming the fix. The next post-084 column therefore fails there instead of going quietly into the baseline. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * test(store): a NUL trigger restore does not re-execute a trigger file that is not yet applied (BUG-3108) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(store): item_relation_links.source_field_key stays out of Layer B on migration 088's derivation, now recorded as an exclusion (BUG-3108) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(store): ScanNUL's cost comment counts the two post-084 protected columns (BUG-3108) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(store): later trigger files get their own name prefix so an older binary cannot drop them; the ordering instrument takes a column's latest appearance (BUG-3108) An older binary's restore reads triggers by GLOB 'pad_nul_*' and drops any it does not expect. Had 094's triggers used that prefix, a binary predating 094 started with --force would have removed them all. Each later file now uses pad_nul<NNN>_, and the restore reads one GLOB per file it knows. columnIntroductions records the last time a column appeared after being absent, so a column dropped and re-added, or a table renamed and replaced, counts as introduced again. The instrument is parameterised so a synthetic chain can show that rule going red. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(store): the source_field_key exclusion names the check that covers it, a decoded value (BUG-3108) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
6862c6fc5c |
fix(BUG-3192): a parent's progress is the server's, and a tab loaded hidden defers its non-essential panels until shown (#1505)
Two parts, per the lead's ruling on Unit B. 1. Progress has one writer. ItemDetail recomputed computedOverrides.progress from ChildItems' children list, racing the load's GET /progress, and the two disagree. Measured on a private instance: children in a collection whose done field is `stage` (board_group_by), one shipped and one not, gave server 2/1/50% and client 2/0/0%, because the client read `status`. Two of three plain tasks done gave 66% vs 67%. Whichever landed last won. The client no longer computes it. A change in the children (ids or statuses) within a load re-reads /progress, and the load's first report does not, since the load already asked; if the load's own read failed, that first report re-asks once. /progress reads are ordered by a ticket and high-water mark, so an older answer landing last never wins. ChildItems' header takes the same numbers through a new `progress` prop, with NO local fallback: without them it shows the child count alone and no bar. 2. A tab loaded hidden (session restore, middle-click, several at once) mounts decisions, attachments, the comments timeline, children, backlinks and relation backlinks only when it is first shown. visibility.seenVisible flips once and never back, so nothing is ever unmounted, and PLAN-2290's never-unmount rule stands. A visible load is unchanged. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
45dddb8f8f |
fix(scripts): install-refresh replaces the server listening on its port, by pid, and leaves other pad servers running (BUG-3194) (#1504)
* fix(scripts): install-refresh replaces the server listening on its port, by pid, and leaves other pad servers running (BUG-3194) The script counted every `pad server start` on the box and refused on more than one, so a sibling seat's e2e server (a worktree binary on its own port) made the scripted refresh unusable whenever anyone ran e2e. Its stop was a system-wide `pkill -x`, which would also have killed that server and any `pad` CLI call in flight. The target is now the `server start` process listening on the port the server itself would resolve without a flag (PAD_PORT, config, 7777), read with ss or lsof. It is stopped by pid, after its argv is re-read and matched. Still refused: the port held by something that is not a captured server, several servers on it, or several servers and none on it. One server with nothing on the port is still that server. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(make): the install caution names npm ci, the hazard that remains; the stop is no longer system-wide (BUG-3194) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(scripts): refuse a foreign or unreadable listener on the port, re-check before each signal, keep empty trailing arguments (BUG-3194, codex round 1) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(scripts): the one-server fallback needs an explicit --port, so a lone sibling server is never replaced (BUG-3194, codex round 2) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(make): reflow the install comment (BUG-3194) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
019f8c984e |
fix(BUG-3192): a 429 on a GET is retried up to 3 times, each no sooner than Retry-After and jittered (#1503)
Two tabs opened together each spend an item page's worth of requests from one per-user burst. The refused GETs all carry the same Retry-After, so the client's single fixed retry sent them back together, the herd was refused again, and the tab showed errors it never recovered from without a reload. GET/HEAD now retry up to MAX_RATE_LIMIT_RETRIES (3) times. Each delay is drawn uniformly from [floor, min(2 x floor, 5 s)]. The floor is the UNCLAMPED Retry-After, or 750 ms doubled per attempt with no header, so a retry is never earlier than the server asked. A floor above the 5 s cap is not clamped down, which would retry early into a bucket that said wait: that request surfaces rate_limited at once. Writes still never auto-retry, and the global cooldown is unchanged. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
25a6d17088 |
fix(BUG-3192): one item-page load is one loadData, and a reconcile ask is answered by one read issued after it (#1502)
A cold item page sent 32-33 API requests (measured). Two defects accounted
for six of them:
- The load $effect ran loadData() in its tracking scope, so the effect
depended on everything loadData read before its first await. That
included localIndex.bootstrap()'s bootstrapState, which bootstrap
writes twice ('cold' -> 'loading', then 'loading' -> 'ready'), so the
item and its collection were fetched three times. The effect now reads
wsSlug/collSlug/itemSlug explicitly and runs loadData under untrack.
- localIndex.reconcile started its own /items-changes read per ask, even
while the bootstrap's /items-index snapshot was in flight at cursor 0.
The layout's first sync result sent since=0, a full delta duplicating
the snapshot. Each ask is now answered by ONE read issued after it:
a bootstrap still hydrating, or an already-queued pass, is joined. A
read already in flight gets one shared pass queued behind it, reading
from the snapshot's cursor. The wait is bounded at 15 s, and reset()
releases the queue.
Measured with the checkpoint-1 fetch instrument on a cold task page:
origin/main 32, 32, 33 requests; this branch 26, 26, 27.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
|
||
|
|
8b5cad8b8e |
fix(import): a panic after a bundle import creates its workspace takes the keep door, then re-panics (BUG-3191) (#1501)
* fix(import): a panic after a bundle import's mint takes the keep door, then re-panics (BUG-3191) A panic after importBundle minted its workspace left retErr nil and unwound past the handler's keep arm, the only place a failed import's owner row is written, so the workspace stayed live, ownerless and unnamed by chi's 500. importBundle's defer now recovers, attaches the importer through addOwnerOrCompensate (or removes the workspace when that fails), logs the outcome, and re-panics so the recovery middleware answers as before. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(import): the keep door logs the original panic stack; panic(nil) pinned; comments say what the helper does (BUG-3191, codex round 1) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
03ff8fc43a |
fix(BUG-3186): the CLI turns an attachment name into a path Windows can store, on Windows only; stored names stay as given (#1498)
A stored attachment name keeps < > : " | ? * (legal and common on Linux and macOS: "Meeting: notes.pdf"). On Windows those are refused, and a colon is worse: "a.svg:x.txt" names an NTFS alternate data stream, so the bytes land in a stream on an empty "a.svg". - attachments.WindowsPathComponent, beside WindowsDeviceName: reserved characters and C0 controls become "_", trailing dots and spaces are trimmed, and a device stem gets the "_" prefix. It is for the boundary where a name becomes a path, never for the stored name. - The CLI's safeLocalFilename applies it only when it RUNS on Windows (safeLocalFilenameFor(runtime.GOOS, ...), so it is testable on any host). Each saver applies its own filesystem's rules, as browsers do. The accepted edge is written down there: a Linux CLI writing onto an NTFS mount keeps the colon. Not changed, by lead ruling on the trail: the Content-Disposition name (it stays faithful; the server cannot know the client filesystem) and the bundle tar path. Import recomputes that path from the manifest and skips unmatched blobs silently, so changing it would make older servers drop bytes. A cross-version contract comment on bundleAttachmentPath says so. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
74bff149da |
chore(nix): update nixpkgs to a nixos-26.05 revision shipping go 1.26.7, clearing the 8 stdlib advisories from the Nix baseline (BUG-2085, BUG-2567) (#1500)
The Nix-built binary stayed on go 1.26.5 because the locked nixpkgs shipped it and its Go builder pins GOTOOLCHAIN=local. nixos-26.05 now ships go 1.26.7 (pkgs/development/compilers/go/1.26.nix at 1bc55b9d), so the 8 stdlib advisories accepted for that lag are removed. The one entry left, GO-2026-5932, is a deprecation advisory on x/crypto/openpgp that can never clear: pad requires the module for bcrypt and does not import openpgp. Its reason is corrected to say so. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
c50f0585ad |
fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap, and the editor jitters its reconnects (BUG-1308) (#1499)
* fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap; the web provider jitters its reconnects (BUG-1308) A collab dial drew on the general per-user API bucket (burst 60), so 100 dials from one user got 60 through, and a user's own dials and REST calls refused each other. Dials now draw on a CollabDial bucket (5/s, burst 50), and how many sockets a principal may hold is bounded separately by PAD_COLLAB_MAX_PER_USER (default 50), refused with 429 collab_limit_exceeded and Retry-After. Both defaults are sized from the reconnect-storm measurement on BUG-1308's trail (checkpoint 2). A browser cannot read a refused handshake's status, so the provider's backoff is what spaces a refused herd out: each step is now jittered to between half and all of itself. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(collab): PAD_COLLAB_MAX_PER_USER and the collab dial bucket, in the deployment docs, samples and load-test usage (BUG-1308) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(collab): only a WebSocket upgrade is a dial; per-user cap pinned within one workspace; dial-bucket keying stated correctly (BUG-1308, codex round 1) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(collab): a PAD_COLLAB_MAX_PER_USER below 1 means unlimited, as the SSE knobs do (BUG-1308, codex round 2) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * docs(collab): the struct field agrees, 0 or less is unlimited (BUG-1308, codex round 3) Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
16e60cf79f |
ci(BUG-3188): the E2E job cap goes 15 -> 20 minutes; the green median is 10.9m and rising with the suite (#1496)
Measured 2026-09-24 on 5 green CI runs: E2E wall time 9.3-11.7 min (median 10.9) against a 15-min cap, versus 7m54s when #1158 set it. One flaky retry (~+2m) still fits, two may not, and a cap-end cancels a job whose tests passed. The growth is new specs: tests present in both the baseline (#1158 merge run) and today's run went 606s -> 566s in total, while 83 added tests account for 257 test-seconds (test phase 5.5m -> 7.3m). Raising the cap treats the symptom. The tripwire is IDEA-3189: shard or trim when the median of the last 10 green E2E jobs passes 14m. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |