mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
9e4ffe5cee674446fb4e927577c95121b64a00a0
2060 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9e4ffe5cee | fix(web): a field value whose shape does not match its declared type shows its raw text with a note and is replaced explicitly, never coerced (BUG-3052 unit 2) (#1556) | ||
|
|
81e58a7ed6 | fix(web): a field filter keeps exactly the items its lane holds, so a number under a retyped select or a multi_select value is found (BUG-3052 unit 3) (#1554) | ||
|
|
7376023ff2 |
fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812) (#1550)
* fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812) The body gate and the store's JSON-column guard decoded into map[string]any and walked the tree. The tree is a lossy model: it keeps one value per repeated key, so a NUL in a shadowed copy passed (and the typed decode could merge it into storage), and a number no float64 holds made the decode fail, so the gate answered "no NUL" for a body the typed decode then accepted. Walking that tree also cost about 2x on any body that merely mentioned an escape. - internal/jsonscan wraps github.com/go-json-experiment/json/jsontext (pinned v0.0.0-20260820222146-c27c302e5fc3, BSD-3, The Go Authors). It is the module's only importer; its doc comment names the exit to stdlib encoding/json/jsontext. A differential test pins its acceptance to encoding/json v1's, which found that jsontext refuses a lone surrogate v1 accepts; that case falls back to v1's unquote. - scanRequestBody reads every body once, recording a NUL and a repeated request member separately and reading to the end, so malformed input still reports nothing. Repeats count in the request's own structure, and not in caller data under JSON-encoded keys (BUG-2896 collapses those). Names are case-folded where the destination is a struct: decodeJSONBytes reflects its destination's TOP-LEVEL type, so a struct folds its members, a slice of structs each element's, and a map (admin settings, admin user limits) none. Below the top level only an exact repeat counts. decodeJSONBytes and both bundle paths refuse either. - textguard's document walk and artifactIsBindableText read tokens too, so every layer refuses the shadowed NUL together: the one textguard.KnownGaps case moves into Corpus. - The store's suspect path stays, redundant but correct, by ruling; its deletion is its own unit. Its instruments now pin what is true: no refused value is a suspect, and the remaining suspects are the doubled-backslash class the destination accepts. - The map-model walk survives only as the differential's oracle. * fix(server): update vendorHash and the Postgres-only suspect instruments for the token scan (BUG-2812) The Postgres-backed legs of the suspect path fired when the shared predicate learned to see a NUL behind a repeated key: that value is now a violation everywhere, and the store's own guard refuses it before the destination cast. The preflight legs now assert that, the unmigrated- table fixture uses a doubled-backslash literal (still a suspect), and PostgreSQL's native refusal stays measured by TestNativePostgresAgreesWithTheCorpus. vendorHash is the value this PR's Nix job recomputed. |
||
|
|
96a75e7dfb |
fix(web): a stored field value String() cannot convert no longer crashes the list, board, table, child items or shared views (BUG-3052 unit 1) (#1553)
An item's field values are JSON of any shape, whatever the declared type. A
stored object like {"toString":0} makes String(v), template interpolation,
new Date(v) and Svelte text conversion throw "Cannot convert object to
primitive value". The census on the BUG-3052 trail found 13 such throw sites.
The worst was laneValue, the helper every grouped view reads lanes through.
This adds the read-side layer the lead ruled for, web/src/lib/fields/fieldShape.ts:
- safeString: exactly String, but total. For a value String cannot convert, it
falls back to the value's JSON text.
- safeText: null/undefined read as '', otherwise safeString.
Routed through it:
- laneValue, which covers list grouping, board buckets and drops, relation
lanes and shared-board grouping;
- the TableView sort and plain text cell;
- the ChildItems group key;
- shareView.matchesFilter and the /s/[token] field formatter;
- QuickActionsMenu and quick-action-preview (categoricalTemplateValue, fields,
plan/phase);
- the playbooks sort and trigger/scope chips;
- three ItemDetail sites;
- ChildChart's parent dates, where only objects are converted and every
primitive reaches new Date untouched.
Output is byte-identical for every value the old code could convert. Rendering
garbage, type-changing writes and misfilters are units 2 and 3; the server
SQL is split out as BUG-3221.
Tests cover each throw class (String conversion, a string method on a
non-string, new Date, Svelte text), plus the helpers' own contract. With every
changed source file swapped back to origin/main, the new legs fail.
|
||
|
|
9d5abc7bad |
test(e2e): viewer specs no longer assume the order of two attachments uploaded in the same second (BUG-3210) (#1552)
attachment-surface-chrome.spec.ts:353 failed about 6% of first attempts under 8-worker load, expecting the lightbox counter at '2 / 2' after one ArrowRight from ns-a. Measured with an instrumented copy: in every failure the counter already read '2 / 2' BEFORE the key, and the key was claimed and wrapped to '1 / 2'. The viewer paged correctly. The spec assumed ns-a sorts first, but the two uploads usually share a second of created_at, and the attachments list's newest-first sort has no tiebreaker, so their order is the database's tie-break. - attachment-surface-chrome: assert the shown attachment id (the image src) is ns-a before the arrow and ns-b after it, instead of the counter position. - attachment-viewer-parity, strip surface: open the first tile rather than strip-a.png by name, so the matrix's opening '1 / 2' holds by construction. Population: every e2e counter assertion (10 sites in 4 files). Only these two tests were exposed. The sheet spec opens the first tile, the lifecycle spec derives order at runtime, and the other parity surfaces follow content order. Proof (same box and command, 120 repeats, 8 workers, --retries=0): :353 went from 7/120 failing on main to 0/120; the parity strip test went from 6/120 to 0/120. A product mutant that makes next() a no-op fails the new assertion. |
||
|
|
4d4ab1d69f |
fix(web): the collection table owns its scroll in table view, so its header row sticks while the rows scroll (BUG-3164) (#1551)
The table's header row is `position: sticky; top: 0`, but its nearest scroll container is `.table-scroll`, whose `overflow-x: auto` (sideways scrolling for wide tables) computes to auto vertically too. That box never scrolled vertically; the page did (`.main-content`, or `.list-column` with the pane open), so `top: 0` never engaged and the header scrolled away with the rows. Option A, as ruled on the BUG-3164 trail: in table view the table fills the list's visible region and owns both scroll axes, the way the board does. - `.collection-page.table-active` fills `.main-content` (height 100%, flex column). `.list-column` stays `overflow-y: auto`, so a table-less state (loading, empty, no matches) still scrolls. - `.table-scroll` is `overflow: auto; flex: 1 1 auto; min-height: 12rem`. - `listScrollTarget` / `listScroller` return the table's scroller in table view, so scroll restoration and the BUG-3165 pane-open anchor handoff act on it. - Keyboard j/k now scrolls a focused table row into view (it only matched `.item-card.focused` before), by exactly its overlap with the sticky header or the table's bottom edge. Tests: web/e2e/bug-3164-table-header-sticky.spec.ts. Its no-pane, pane-open and wide-table legs fail on main (header at -461 vs the scroller top at 44). Its keyboard leg runs in its own workspace, because the page resets the cursor whenever the list changes. The BUG-3165 helpers now scroll the view's real scroller, and its table leg also covers Expand to full page + Back. content-visibility was measured, not assumed: 300 rows, rendered after scrolling 38 then 66 on main and 38 then 63 here. A switch between table and list now starts the new view at the top, as a switch to or from board already did. |
||
|
|
bdfb8a989c |
fix(server): a request that repeats fields_patch or field_overrides is refused, where it silently dropped the first copy (BUG-3219) (#1548)
BUG-3202 (#1545) decodes fields_patch from its raw member and field_overrides through models.FieldValues, to keep number literals. A plain map member MERGED a repeated occurrence; a raw member keeps the LAST, so a request repeating either answered 200 having discarded part of the write. A repeat is now refused with a 400 naming the member, and nothing is written: - models.RefuseRepeatedMember scans an object's top-level members and matches names by encoding/json's own rule (exact, or its case fold), so fields_patch followed by FIELDS_PATCH is a repeat. A test pins the matcher against encoding/json itself. - ItemUpdate.UnmarshalJSON refuses a repeated fields_patch; the move request (now a named type) and the copy/preflight request refuse a repeated field_overrides. The three handlers answer with the message unwrapped; the copy door no longer flattens it to "Invalid JSON body". - FieldValues refuses a second non-null decode itself, as the backstop for a future struct that forgets the scan. Out of scope: repeated keys INSIDE one fields object (BUG-2896), and members that were already raw before #1545 (fields, tags), which kept the last occurrence before and after. |
||
|
|
4e2a7cda9f |
fix(web): a board lane named __proto__ or constructor keeps its own bucket and sort, so one option can no longer un-proxy the whole board (BUG-3208) (#1549)
bucketByColumn built a plain object keyed by schema option values. An option
named __proto__ did not create a lane: the assignment re-parented the object
onto that lane's array, and because BoardView assigns the result to $state,
and Svelte 5 proxies only Object.prototype objects, the whole board lost its
drag reactivity. The per-lane sort overrides had the same shape, wider: a
bare overrides[value] ?? sortMode read the inherited member for every
Object.prototype name, so a lane named constructor or toString ignored the
page sort and lost its reorder menu.
- bucketByColumn returns a Map.
- boardColumns exports laneKey ('lane:' prefix, the BUG-3054 answer), and
BoardView keys columnData and laneSortOverrides through it.
Object.create(null) is out because these records are $state.
- ListView and ChildItems import the shared laneKey. Their private copies are
gone, and laneKeyCallers.test.ts forbids a private copy on all three
surfaces.
Census of keyed-by-lane-value objects on the board path (full table on the
BUG-3208 trail): two live members fixed (the bucket record and the sort
overrides). Draft maps (draftKey is field-prefixed), relationLaneByValue (a
Map), column colours (hasOwn, BUG-3041), the public board (Map buckets) and
the roles board (keyed by role UUID) are safe.
|
||
|
|
228abe9702 |
fix(store): a JSON blob that repeats a member is stored collapsed, so SQLite's json_extract and Go read the same value (BUG-2896) (#1547)
SQLite's json_extract reads the FIRST occurrence of a repeated member; Go and Postgres jsonb read the LAST. Two doors stored such blobs raw: - collection create/update with `traits` in the string wire form (the object form was already re-encoded). Measured: a PATCH whose pre-check read `playbook` was refused 409 by the unique index, which read `convention`. - workspace import, for collection traits and item fields. At each door, a blob in which some object repeats a member (any depth, models.FirstDuplicateJSONKey, moved from internal/server) is stored collapsed: traits as the parsed declaration's own encoding (what the resolver reads), item fields as a map re-encode that keeps number literals (last wins, as jsonb). Any other blob keeps its bytes. Surfaces: collection create/update responses carry additive omitempty warnings.collapsed_duplicate_keys; imports carry X-Pad-Import-Collapsed-Duplicate-Keys (always set, "0" when clean) plus a slog line per blob. No MCP tool can send `traits`, so no ToolSurfaceVersion bump; a guard test fails if pad_collection gains a traits param. Live census: 0 of 233 collections and 0 of 9,796 items affected. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m |
||
|
|
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 |