Commit Graph

258 Commits

Author SHA1 Message Date
xarmian e12feb46cb fix(copy): authorize attachment references in cross-workspace copy (TASK-2408)
Cross-workspace copy authorized the source item and the destination
collection but never the individual attachments it cloned.
PlanAttachmentCopy scoped every lookup to `workspace_id =
SourceWorkspaceID AND deleted_at IS NULL` — but the workspace is not the
caller, so a restricted member who could edit any item in the source
workspace could paste `pad-attachment:<uuid>` for an attachment on an
item they could not see, copy that item into a workspace they own, and
read the bytes through the ordinary blob endpoint (BUG-2407).

The planner now consults an AttachmentAuthorizer supplied by the caller,
applied to every row it resolves: the referenced rows, the parents it
adopts as clone roots, and the variants it follows. A denial DELETES the
row from the resolution map, so it is indistinguishable from a row that
was never there — the reference lands in UnresolvableRefs beside
dangling, soft-deleted and foreign ids, and attachment_count /
attachment_bytes / unresolvable_ref_count read identically. The
preflight's numbers stay oracle-free.

It is a callback because the rule is the read path's — resolve the
parent, reject a foreign or non-live one, check item visibility, apply
the orphan rule — and every input to it lives in package server. It
cannot run BEFORE planning either: the copy re-reads the source content
under its locks and computes destination fields inside its transaction,
so a reference set enumerated beforehand is not the set the planner
resolves. Authorizing the rows the planner actually resolved keeps the
dry run and the copy on one path, which is the property DR-11 exists to
protect. Both endpoints take the authorizer off the same shared
resolution (resolveAuthorizedCopy), so what the preview calls
unresolvable is what the copy refuses to clone.

Mutation-verified: without the authorizer the secret PNG is cloned into
the destination, referenced by the rewritten body, and served byte-identical
to the attacker through the destination workspace.
2026-08-02 04:54:31 +00:00
xarmian 9ad718178d fix(store): workspace-scope the item-grant lookup (TASK-2403)
ResolveUserPermission matched item grants on item_id alone, so a grant on
an item in workspace B resolved for a request scoped to workspace A. This
is the underlying lookup behind the delete escalation PLAN-2382 fixed at
the handler; closing it here means the next caller does not have to
remember the workspace-identity guard.

The adjacent collection-grant lookup had the identical defect and the
identical safety argument, so it is scoped in the same commit rather than
leaving a second unscoped lookup three lines below the one DR-5 names.

Safe for every caller: all three (requireEditPermission, the collab
access check, crossWorkspaceEditAllowed) already pass the workspace the
item/collection was resolved in, and grant rows carry the workspace they
were minted in — the same scoping listUserItemGrants already uses.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 23:59:38 +00:00
xarmian 380b75e12c fix(attachments): gate transform on item visibility (TASK-2402)
handleTransformAttachment opened with a flat requireMinRole("editor") and
never looked at the attachment's parent item at all. A restricted editor —
one whose collection access excludes that item — could transform an
attachment on an item they cannot see, given only the attachment id: the
handler read the source blob and returned output metadata plus a new row.
The output URL inherits ItemID and is gated by TASK-2401's read gate, so
this was not direct byte exfiltration, but it crossed the same boundary and
leaked processing behaviour and metadata for an invisible item.

The handler now authorizes per-attachment, in the order the read path uses
(PLAN-2391 DR-10): load the row -> workspace identity -> load the parent
with GetItem -> parent workspace identity -> checkItemVisible -> edit
permission -> transform. Every denial goes through writeAttachmentNotFound,
so a missing attachment, a foreign parent, a soft-deleted parent and an
invisible item are byte-identical; a distinguishable code or message would
be an existence oracle. Malformed non-null parents that resolve nowhere are
rejected by the same guard.

Edit permission is requireEditPermission rather than the flat editor role:
an item- or collection-grant editor can already attach to the item
(BUG-1661), so refusing them a rotate on their own upload would be an
inconsistency, not a boundary. Orphan rows keep the flat editor gate and,
matching the DELETE path (PLAN-2382 DR-4), require unrestricted workspace
access — the storage listing hides orphans from restricted members, so the
transform must not confirm one exists.

DR-14's race is closed, not narrowed. The parent check is point-in-time:
item deletion commits in its own transaction, and the blob read, decode,
transform, encode and Put in between are unbounded work, so the item can be
archived mid-flight and the insert then writes a quota-counted live row
against an archived item whose bytes DR-13 refuses to serve. The new
store.CreateAttachmentForLiveItem re-checks the parent under a row lock
inside the insert's own transaction: the row is written against a live item
or not written at all. FOR NO KEY UPDATE, not FOR UPDATE — DeleteItem's
UPDATE touches no key column so the archival still blocks, while the many
tables with a REFERENCES items(id) foreign key (comments, stars, the Yjs
op-log) keep taking FOR KEY SHARE on the parent uncontended. SQLite skips
the clause: _txlock=immediate already serializes writers there.

Tests fail against the pre-fix code: the restricted-editor transform
returns 404 with a body byte-identical to the missing-attachment body, and
the mid-flight test archives the item from inside the processor's Encode —
between the up-front check and the insert — asserting the hook actually ran
so it cannot pass vacuously. The Postgres lock test polls pg_stat_activity
until the statement is registered as lock-blocked rather than sleeping, and
watches the completion channel so a missing lock fails immediately. Both
were mutation-verified.

Recorded, not fixed here: a refused insert leaves a rowless blob on disk,
and the orphan GC is row-driven so nothing reclaims it. Pre-existing on the
upload and thumbnail paths too; filed as BUG-2406 with the dedupe guard a
correct fix needs. The comment claiming GC reclaims a transform's original
was wrong and is corrected — only an orphan original is GC-eligible.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 23:05:26 +00:00
xarmian 6e2b972fb0 fix(attachments): gate blob reads on item visibility (TASK-2401)
handleGetAttachment opened with a flat requireMinRole("viewer").
roleLevel("guest") is 0, below viewer's 1, so every grant-based guest
was rejected before any item-level check ran and inline images broke in
items shared with them (BUG-2386).

The handler now authorizes per-attachment, in the order PLAN-2391 DR-10
fixes: load the row -> verify the parent item's workspace identity ->
check item visibility -> serve. Orphan rows keep the flat viewer+ gate;
the workspace-wide storage listing is untouched.

Also closes two defects sitting immediately around that gate:

DR-16 - GetAttachmentVariant scoped on parent_id/variant/deleted_at but
not workspace_id, so a foreign-workspace variant sharing a parent id
would be served after the local parent was authorized. Fixed at the
store API rather than in the handler because the other caller,
thumbnail derivation, has its own stake in the scope: an unscoped
"does this variant exist?" probe lets a foreign row suppress generation
of a legitimate local one.

DR-13 - the parent is loaded with GetItem, so a soft-deleted parent
404s. The DELETE path keeps GetItemIncludeDeleted, unchanged.

Denial paths now carry Cache-Control: private, no-store, set as the
handler's first statement (writeError calls WriteHeader immediately, so
anything later never reaches the wire); the positive private,
max-age=3600 is set only after authorization succeeds. Every
authorization-dependent refusal goes through one writer so the
responses are byte-identical and can't be used as an existence oracle.

The MCP image resource pad://workspace/{ws}/attachments/{id} inherits
the gate; asserted against a real server rather than assumed.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 20:42:28 +00:00
xarmian eae42b843e fix(store): scope attachment list JOINs by workspace (TASK-2399)
WorkspaceAttachments joined `items` (and, through it, `collections`)
on item_id alone, so an attachment whose item_id points at another
workspace's item borrowed that item's title, slug, and collection
into the storage listing.

Both queries — the count and the result — now join with
`ON i.id = a.item_id AND i.workspace_id = a.workspace_id`. The
predicate is deliberately in ON, not WHERE: in WHERE the LEFT JOIN
degenerates into an inner join and the malformed row would vanish
from the listing entirely, hiding a row that still consumes quota
and that the PLAN-2397 repair has to be able to see. In ON the row
survives with NULL item/collection metadata.

Keeping the two queries in step matters — they are separate SQL and
a restricted caller's count must not diverge from their rows.

Review turned up a second hop of the same leak, folded in here:
items.collection_id has no composite workspace foreign key, so a
LOCAL item can reference a FOREIGN collection and surface its slug
even through a scoped item join. The collections join now carries
its own workspace predicate, same ON-clause rule.

Two fixtures pin both hops, each verified by mutation to fail when
its predicate is moved to WHERE or removed.

PLAN-2391 DR-3.
2026-08-01 18:11:35 +00:00
xarmian d9d96b85c9 refactor(store): delete two unused item-workspace-move accessors (TASK-2374) 2026-07-31 17:28:05 +00:00
xarmian 3bbd326857 test(store): make the copy concurrency and attachment assertions bite (TASK-2372) 2026-07-31 16:50:37 +00:00
xarmian c6ebe5a3e3 refactor(store): unify the collection column list and scan (TASK-2368)
Three accessors read a full collection row and each carried a verbatim
copy of the same 15-column projection and scan/hydration block:
GetCollection, GetCollectionAnyState, and the transactional
getCollectionInWorkspaceTx used by the cross-workspace copy. A column
added to the model had to be added in three places, and the copy path
drifted silently if only GetCollection was updated.

Extract collectionColumns plus scanCollectionRow, parameterized over
rowQueryer (the uniqueSlugQ / validateAssignmentScopeQ pattern from
TASK-2362) so the same read runs against *sql.DB or inside a caller's
*sql.Tx. Each accessor's full statement is assembled from constants, so
the WHERE predicate is the only per-caller difference, the SQL is built
at compile time rather than per call, and no runtime-assembled fragment
is ever handed to s.q.

Preserved deliberately: s.q placeholder rewriting (applied once, inside
the helper, so no call site can skip it); nil-on-sql.ErrNoRows at every
accessor -- the helper returns real errors unwrapped so each keeps its
own distinct prefix; the transactional lookup stays workspace-scoped and
active-only, which is the security boundary that makes a foreign
collection a not-found rather than a cross-workspace write.

lockCollectionRows is untouched: its SELECT id ... FOR UPDATE is a
locking primitive that duplicates nothing, and its sorted acquisition is
load-bearing.

ListCollections is deliberately left out and documented as such: it is
an aggregate multi-row query with aliased columns, a trailing COUNT and
no deleted_at, so sharing a projection would need a second count-aware
scanner and would reshape a hot query for no correctness gain.

TestCollectionAccessorsShareOneHydration pins all three to one
hydration. Every scanned column except deleted_at is asserted against a
literal, distinct value rather than against another accessor's output,
since cross-accessor equality alone cannot catch a mutation in the
shared projection; created_at and updated_at are set to different
instants so transposing them fails, and deleted_at is pinned by the
soft-delete branch, the only state in which it is non-nil. Verified by
mutation: a transposed slug/prefix projection, a transposed
created_at/updated_at projection, a dropped workspace scope on the
transactional read, a flattened deleted-state predicate, and a miss
turned into an error each fail the test.
2026-07-31 16:02:52 +00:00
xarmian c783d36a13 fix(store): make migration 077 constraint-equivalent to 055 per final review
Postgres' BOOLEAN admits exactly two values; SQLite's bare INTEGER admits
any. A stray 2 would scan as true through BoolToInt while the partial
index the moved-to lookup uses is WHERE archived_source = 1 — a row that
reads as a move but is invisible to the query that finds moves, which the
Postgres schema cannot represent. Add the CHECK, and make id NOT NULL
explicit since SQLite does not imply it for a TEXT PRIMARY KEY.

Migration 077 is unreleased, so amending it in place is safe.

The test is mutation-verified. Its first draft was NOT: it used
placeholder ids and passed against a schema with no CHECK at all, because
the foreign keys rejected the insert before the constraint under test was
reached. It now uses real fixture rows and asserts the same row inserts
cleanly with archived_source = 1.

Found by the final full-diff Codex pass over PLAN-2357, data-at-rest angle.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 12:45:58 +00:00
xarmian 66fa464699 fix(store): distinguish SQLite lock timeout from a real deadlock per final review
isDeadlockError matched SQLite's "database is locked" alongside
Postgres' 40P01, and the rollback path logged both as deadlock=true at
ERROR. But SQLite is single-writer with a 30-second busy timeout, so
"database is locked" is an expected saturation mode under burst load —
it says the box is busy. A 40P01 says DR-9's lock ordering, which is
meant to make deadlock impossible, is wrong. Reporting both identically
left an operator unable to tell a lock-ordering bug from ordinary load,
defeating the only signal this log exists to carry.

Split the predicates and add lock_timeout to the log line. Classification
test is mutation-verified: reintroducing the conflation fails it.

Found by the final full-diff Codex pass over PLAN-2357, operability angle.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 02:43:56 +00:00
xarmian 2b9da9412b fix(store): classify unique violations as expected copy rejections per final review
The store logged a unique-constraint violation as an unexpected rollback
incident while the HTTP layer mapped the same error to a caller-facing
409. A workspace-unique field colliding in the destination — a playbook's
invocation_slug, say — reaches this on ordinary input, so every routine
409 fired an operator warning and buried the deadlock signal the log
exists to surface.

Found by the final full-diff Codex pass over PLAN-2357 (P2: two
commits classified the same error two ways).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 02:39:20 +00:00
xarmian f8ff5742e5 feat(server): add cross-workspace copy endpoint with post-commit fanout (TASK-2365)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 00:59:25 +00:00
xarmian 01d640978c feat(server): add cross-workspace copy dry-run preflight endpoint (TASK-2364)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 20:15:00 +00:00
xarmian 0fad869a28 feat(store): add CopyItemAcrossWorkspaces atomic orchestration (TASK-2363)
PLAN-2357 DR-9 / DR-9a / DR-11 / DR-12 / DR-14 / DR-16 / DR-17. One
store operation, one transaction: create in B, clone attachments,
archive A on a move, write provenance.

Lock order (the whole point of DR-9):
  1. Both workspaces' advisory locks, sorted and deduplicated by the
     hashtext LOCK KEY — sorting the ID strings does not order their
     hashes, so two opposing movers could still deadlock.
  2. Both collection rows FOR UPDATE, sorted by collection ID —
     MigrateFields consumes both schemas.
  3. Source item re-read under those locks; that snapshot is copied.
Both primitives are dialect-gated: FOR UPDATE is a syntax error on
SQLite, where BEGIN IMMEDIATE already serializes writers.

Pipeline: migrate -> overrides -> validate (DR-12: MigrateFields'
errors are stale once an override lands) -> quota -> PlanAttachmentCopy
INSIDE the tx -> rewrite content AND fields via the plan's IDMap ->
create in B -> attachment rows (originals before variants, item_id set
from the outset, uploaded_by = the actor) -> archive A -> provenance.

Seq (DR-14): B always advances; A advances only on ArchiveSource, and
a plain copy leaves A completely untouched. Quota (DR-16) runs inside
the transaction after the destination lock so two concurrent copies
cannot jointly exceed the cap.

Cross-backend attachment copies are REFUSED in v1
(ErrCopyCrossBackendAttachments): the store has no AttachmentStore
handle, and a byte transfer under both workspaces' locks would block
every writer in both workspaces on unbounded I/O with no rollback.

Supporting changes:
- CreateAttachmentTx: tx-taking insert (CreateAttachment is
  self-committing), sharing one body with the pool form.
- CheckLimitTx: the feature COUNT reads through the caller's tx.
- createItemTxWithID: createItemTx with a caller-supplied id, so the
  destination item id exists before the attachment plan is built.

Tests: creation parity, seq on both sides, DR-12 ordering, DR-8/DR-17
scrubs, attachment clone + rewrite (including refs in code fences),
DR-11a unresolvable refs, rollback at all four stages, quota. Postgres
only: opposing A->B / B->A copies do not deadlock, concurrent copies
cannot jointly exceed the cap, and colliding hashtext keys take one
lock. All three verified falsifiable by mutating the production code.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 15:50:18 +00:00
xarmian 60dd3e1a37 feat(store): add attachment resolution planner for cross-workspace copy (TASK-2354)
Implements PLAN-2357 DR-11 / DR-11a. PlanAttachmentCopy takes the copied
content plus the FINAL destination fields and returns the old->new
attachment UUID map, the rows to create (originals followed by their
variants, parent_id remapped), the byte total, and the unresolvable-ref
list. It writes nothing, takes no *sql.Tx, and is shared by the copy
orchestration and the dry-run endpoint so their numbers cannot drift.

DR-11a: every resolution is scoped to workspace_id = A AND deleted_at IS
NULL, and the parent/variant traversal carries the identical scope. The
reference set comes from user-controlled content, so an unscoped lookup
would let a user clone another workspace's blob into a workspace they
control, bypassing the download handler's workspace check. Refs that
resolve to nothing under that scope -- dangling, soft-deleted, or foreign
-- are never cloned and never fatal: they get no map entry, so the
rewrite preserves the literal text and the copy renders exactly as broken
as the source did.

A cross-backend row emits an empty storage_key with the source key in
SourceStorageKey, so the plan never contains a key the target backend
cannot resolve. CreateAttachment now rejects an empty storage_key, which
turns that contract into an enforced invariant: an orchestration that
skips the Get/Put byte transfer fails at insert rather than creating a
live attachment that 404s on download.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 14:37:51 +00:00
xarmian dbede59edf refactor(store): extract tx-taking item creation helper (TASK-2362)
Implements PLAN-2357 DR-9a. CreateItem opens and commits its own
transaction, so the cross-workspace copy path (create in B + attachment
remap + provenance row + optional source archive, all atomic) cannot
call it. A raw in-tx `INSERT INTO items` in its place would silently
break version history, wiki-links, reporting, delta sync and slug
uniqueness -- none of which fail loudly.

Extracted, not duplicated, and CreateItem now goes through the same
function so the two paths cannot drift:

- `insertItemTx` is the write half, lifted verbatim out of the old
  tryCreateItem body: the items INSERT (item_number, workspace seq,
  content-flush watermarks), the initial item_versions row, wiki-link
  indexing + broken-title resolution, and the create-time
  status_transitions row.
- `createItemTx(tx, workspaceID, collectionID, input) (*models.Item, error)`
  wraps it with the rest of CreateItem's pipeline -- defaults,
  assignment-scope validation, workspace-scoped unique slug allocation --
  inside a caller-owned transaction. It returns the item read back in-tx
  so an orchestrator can consume its slug / item_number / seq (DR-14
  fanout) without a post-COMMIT round-trip.
- `tryCreateItem` is now a BEGIN/COMMIT wrapper around createItemTx, and
  CreateItem is the retry loop around that.
- `uniqueSlug` and `validateAssignmentScope` gained rowQueryer-
  parameterized forms (`uniqueSlugQ` / `validateAssignmentScopeQ`) so
  both can run on the caller's transaction. The *sql.DB entry points
  delegate to them; behaviour is unchanged.

Content must already be final: wiki-link indexing and the first version
row are written from input.Content as given, so callers doing DR-11
attachment-ref rewriting must rewrite BEFORE calling.

Trust boundary is documented on the function. collectionID/workspaceID
consistency and ParentID scope stay the caller's job, matching the
pre-extraction tryCreateItem -- DR-9 has the orchestrator re-read and
row-lock both collections in-tx, so a check here would be a second,
weaker read of an already-pinned row. Assignee and agent role ARE
validated, as in CreateItem. No internal retry on unique violation: a
failed statement poisons the caller's transaction and an internal retry
would need a savepoint the caller can't see.

Fixes a latent slug race in CreateItem along the way. It used to
allocate the slug ONCE, outside the transaction, and re-submit that
stale value on every retry -- so two concurrent creates of the same
title had the loser burn all ten attempts on a slug the winner had
already committed and then fail with a unique-constraint error. Slug
allocation now happens inside the transaction under the workspace
advisory lock, so each attempt sees the previous scan's outcome. Two
Postgres-falsifiable concurrency tests cover it (createItemTx-only and
mixed CreateItem + createItemTx).

24 tests: one per DR-9a parity-checklist line, a rollback test asserting
no item / version / wiki-link / status transition / seq advance
survives, an in-tx-visibility test, a slug-collision test, and the two
concurrency tests. Every parity assertion verified falsifiable by
mutating the production code.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 13:03:16 +00:00
xarmian 1eb1c9eda6 feat(server): expose ACL-gated moved-to pointer on item GET (TASK-2359)
An item MOVED to another workspace — copied, then archived — can now say
where it went. GET on a single item gains an optional `moved_to` block
naming each destination in displayable terms (workspace slug + item ref +
title + collection slug), so a consumer can render a link without a second
call. No HTTP redirect, no resolver change.

The ACL gate is the point. A destination is revealed only after the caller
independently passes AuthorizeCrossWorkspaceRead (TASK-2358) with an ITEM
scope on the destination item itself. Workspace-level access is not
sufficient: a restricted member of the destination workspace, or a guest
holding one unrelated item grant there, has a role in that workspace while
having no right to the copied item's collection.

A caller who fails that check sees NO hint a destination exists. The key is
omitted entirely — not a null, not an empty array, not a boolean — so the
response is byte-identical to an archived item with no move record at all.
A structurally distinguishable response is itself the leak.

Restore decision: the block is OMITTED for a non-archived source. Restoring
a moved-out source leaves two live items with the same content in two
workspaces, which is legitimate, but at that instant the source has not
moved anywhere and the response must stop asserting that it did. Past-tense
provenance is the back-pointer question and applies equally to plain copies,
which this field must never claim as moves.

Also honored: DR-2a (only archived_source rows feed the pointer; plain
copies are back-pointer material only), per-destination filtering over the
forward lookup's SET with no short-circuit on the first hit or first denial,
newest-first ordering, a scan bound on the per-GET authorization cost, and
deliberate isolation of the hand-rolled public share-link DTO — pinned by an
explicit negative test that freezes its key set.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 12:20:38 +00:00
xarmian bf14c1168a feat(store): add item_workspace_moves provenance table (TASK-2356)
Phase 1 of PLAN-2357. Durable record of "this item was copied/moved from
workspace A to workspace B", backing the forward redirect (TASK-2359) and
the destination's back-pointer. Implements DR-2 / DR-2a.

Paired, dual-dialect, forward-only migrations (migrations/077 +
pgmigrations/055). archived_source distinguishes a move from a plain copy
(INTEGER on SQLite, BOOLEAN on Postgres, written through dialect.BoolToInt).
source_seq is a NULLABLE per-source move ordinal that exists solely so two
moves inside the same second are orderable — created_at is second-precision
RFC3339, so archive -> restore -> move again would otherwise resolve to an
arbitrary destination. Partial index (source_item_id, source_seq DESC) WHERE
archived_source, deliberately NOT unique: restore-then-move-again legitimately
repeats. The back direction IS uniquely indexed — a destination item is
created by exactly one copy, in the same transaction that writes its
provenance row, so a duplicate there would silently change which source the
back-pointer names.

Cascade is asymmetric on purpose, inverting item_collection_moves: the
archived source is precisely the row whose pointer must survive, so
source_item_id carries no FK at all; target_item_id cascades, because a
pointer at a vanished destination is worse than no pointer.

Store accessors: a tx-taking insert helper (no self-committing variant — the
row must land in the copy transaction), a forward lookup returning a SET
newest-first, and a back lookup. The insert rejects an archived row with no
seq and a copy row with one, so DR-2a's ordering invariant is enforced at the
write boundary rather than assumed. NULL ordering is normalized with COALESCE
because SQLite and Postgres disagree on DESC NULL placement.

Also wires workspace purge, which the two-workspace shape requires: both
workspace columns are RESTRICT references, so a purge clearing only one
direction would fail outright when the purged workspace sits on the other end.

Tests cover insert-in-tx, forward lookup with multiple destinations ordered
newest-first and scoped to one source, back lookup, rollback leaving no row,
and both DR-2a criteria. The ordering and scoping tests use fixed row IDs
whose lexical order contradicts the expected answer, so deleting the ordering
term or the WHERE clause under test fails them on every run rather than half
the time; verified by mutating the production query.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 12:20:38 +00:00
xarmian e601f2b368 fix(collab): reconcile Postgres commit-ack-loss on version restore instead of treating it as rollback (BUG-2276 residual 1)
On Postgres, a version-restore commit that durably lands but whose ack is lost surfaced as an error and wrongly resumed peers on a stale Y.Doc. ForceRefreshRoom now runs a Postgres-only reconcile after a commit error: two durable signals (content == restored version AND last_restore_seq advanced past a lock-captured baseline) must agree → LANDED (publish fences + reseed, return the restored item + SSE); both false → rolled back (unfreeze); disagree/read-error → UNCERTAIN (invalidate in-memory fences so durable state governs, then plain-close sockets so peers reconnect + re-evaluate). SQLite path unchanged.

Confirming Codex (high effort): 3 rounds — false-404, frozen-forever, archive-nil, stale-baseline, stale-in-memory-fence-clobber all closed; real Postgres end-to-end ack-loss + SSE test. make test-pg green. Residual 2 (applier-ack rollback race) follows separately. Go CI red only on the pre-existing govulncheck advisory (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 18:00:49 -04:00
xarmian 37ec77d110 fix(store): monotonic tie-breaker for same-second item version ordering (BUG-2270)
Adds a per-item monotonic `version_seq` column (dual migrations: SQLite 076 / Postgres 054, backfilled via ROW_NUMBER) so version-history RECONSTRUCTION resolves same-second versions deterministically instead of by the random-UUID PK. Reconstruction paths (shouldCreateItemVersion, ListItemVersions/Resolved, export) order by version_seq; the timeline keyset path (ListItemVersionsBeforeTime) keeps its id-consistent cursor.

Confirming Codex (high effort): found + fixed one keyset-pagination P2 (order/cursor key mismatch). make test-pg green (migration verified against Postgres). Go CI job red only on the pre-existing govulncheck advisory tracked in BUG-2278.

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 16:16:15 -04:00
xarmian 40f88052cd fix(collab): version restore via prune+reseed (BUG-2264) (#990)
Version restore didn't reconcile the live Y.Doc: peers kept editing a
Y.Doc built on pre-restore ops, and their next collab-snapshot flush
clobbered the restored items.content. Reworked restore to prune+reseed —
the restored content becomes canonical and every peer converges on it
(unflushed edits are discarded, which is exactly restore semantics),
replacing the earlier applier/epoch/watermark routing.

handleRestoreItemVersion drives RoomManager.ForceRefreshRoom under the
per-item lock. Hardened across Codex xhigh review rounds:

- Atomicity: pre-prune MAX(op-log), the items.content write, the
  "Restored from…" version, the op-log wipe, AND both durable restore
  boundaries all run in ONE store transaction. A failed commit rolls back
  all of it — no divergent state, no fail-open boundary.
- Unambiguous commit signal: UpdateItem reads the updated row WITHIN the
  tx (getItemTx) before commit, so a read failure can't make a committed
  update look failed and the returned seq is this restore's.
- Restore freeze: conns are paused via a dedicated rc.frozen flag (NOT
  canWrite) so the auth-revalidation loop can't thaw the freeze mid-restore
  or promote a viewer; pickApplier + the applier-ack handler reject frozen
  conns so a concurrent external PATCH can't falsely succeed.
- Stale-flush boundary: pre-prune MAX+1 fences in-flight snapshot cursors
  under the same item lock.
- force_refresh fan-out deadlock: per-conn timer-close so a wedged
  writeLoop can't hang the fan-out + item lock.
- Stale-SEED clobber: the client announces the item.seq it seeded from
  (?content_seq=) on every (re)connect; Join force_refreshes any seed that
  predates the last restore.

Residual #1 (restart-durability) CLOSED durably, for BOTH stale vectors —
the in-memory fences didn't survive a restart, so a surviving cursor-0
pre-restore browser tab wasn't fenced on reconnect. Two nullable per-item
columns (migration 075 SQLite / pg 053), both stamped in the restore's own
tx (atomic with the content write + op-log prune):
  * items.last_restore_seq — the content generation. Join's stale-seed
    fence reads it (via store.ItemLastRestoreSeq) when the in-memory
    fast-path misses (after a restart); if that read errors, Join fails
    CLOSED via a RETRYABLE plain close (not a force_refresh, which would
    discard the Y.Doc and spin an unbounded refresh loop) so the client
    reconnects with backoff, Y.Doc intact.
  * items.restore_boundary_op_id — the op-log-id boundary. The
    collab-snapshot flush gate reads it (via store.ItemRestoreBoundaryOpID)
    when the in-memory RestoreBoundary misses (after a restart), failing
    closed (409) on a read error, so a surviving tab's stale HTTP flush is
    fenced too.
No SCHEMA_VERSION bump — durable columns are not a Y.Doc node-spec change.

Deferred to BUG-2276: (a) a Postgres commit whose ack is lost is treated
as rolled-back (needs commit-outcome reconciliation; SQLite unaffected);
(b) a restore rollback racing an in-flight external-applier ack can drop
the ack and retry/fall back (needs the applier flow serialised under
itemLock at a 30s-stall cost).

NOTE(BUG-2270): ForceVersion can mint same-second version rows; the
item_versions ordering tie-breaker is tracked separately.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 15:21:57 -04:00
xarmian 1dbe04399a fix(store): optimistic concurrency + sibling broadcast for collection settings (BUG-2265) (#989)
* fix(store): optimistic concurrency for collection settings writes (BUG-2265)

Collection-level settings (e.g. quick_actions) were written by reconstructing
the whole settings JSON from a caller's local Collection snapshot, and
UpdateCollection replaced the column with no concurrency check. Two ItemDetails
in the same collection (full-page pane host master + pane) hold independent
snapshots and clobbered each other.

Mirror the item optimistic-concurrency pattern (IDEA-1480): add
CollectionUpdate.ExpectedUpdatedAt; when set, UpdateCollection re-reads
updated_at atomically under the workspace write lock (SQLite BEGIN IMMEDIATE /
Postgres advisory xact lock) and returns CollectionUpdateConflictError on a
mismatch. Empty token keeps the legacy last-write-wins path unchanged for
CLI/MCP/API callers. No DB migration — reuses collections.updated_at.

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

* feat(server): collection.updated broadcast + 409 conflict mapping (BUG-2265)

- handleUpdateCollection boundary-validates expected_updated_at (400 on a
  malformed token) and maps store.CollectionUpdateConflictError to the shared
  update_conflict envelope (HTTP 409) — byte-identical wire shape to the item
  path, via the extracted writeUpdateConflictEnvelope helper.
- Add the collection_updated EventBus type and publish it after a successful
  update so sibling ItemDetails / collection pages refresh their independent
  Collection snapshot proactively, shrinking the 409 window. Routed by
  Collection (slug) through the existing SSE visibility filter.

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

* fix(web): 409-aware collection settings writes + sibling refresh (BUG-2265)

- CollectionUpdate carries expected_updated_at; add isUpdateConflictError.
- QuickActionsMenu sends the token and, on a 409, refetches the collection,
  re-appends the new action onto the FRESH settings, and retries once — no
  silent loss, no user-visible error.
- EditCollectionModal captures the token at open-time (edge-gated seed so a
  concurrent broadcast can't wipe in-progress edits) and shows a
  non-destructive "changed elsewhere, reload" message on 409 rather than
  auto-merging a full-form edit.
- Subscribe to collection_updated over SSE: ItemDetail and the collection page
  refresh their own Collection snapshot (gen/slug-fenced against the persistent
  pane host's no-remount switch), so siblings converge before the next save.

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

* fix(store): harden collection optimistic concurrency + web fetch ordering (BUG-2265, Codex round 1)

Address Codex review findings:
- [P1] same-second clobber: now() is one-second precision, so two guarded
  writes in the same second kept an identical token. The accepted write now
  advances updated_at strictly past the token (only when now() hasn't already
  moved on), making a stale-token replay deterministically conflict. Add a
  same-second regression test.
- [P1] tokenless-writer race on Postgres: the advisory lock only serialized
  writers that also took it. Replace it with a `FOR UPDATE` row lock on the
  in-tx re-read (Postgres) — SQLite's BEGIN IMMEDIATE already serializes every
  writer — so a concurrent tokenless UpdateCollection can't slip between the
  re-read and the UPDATE.
- [P2] rename broadcast: only publish collection_updated when the slug is
  unchanged. A rename's old-slug event would make siblings refetch a dead slug
  (404) and a new-slug event can't reach old-slug visibility snapshots; renames
  are handled by the existing navigation path.
- [P2] out-of-order refreshes: ItemDetail and the collection page now use a
  dedicated monotonic refresh counter so two rapid collection_updated fetches
  can't resolve out of order and clobber newer state (loadSeq/loadGeneration
  only bump on route/item loads).

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

* fix(store): make collection updated_at strictly monotonic for ALL writes (BUG-2265, Codex round 2)

The previous same-second advance ran only on guarded updates, so a tokenless
UpdateCollection could write the current second over a forced expected+1s,
regressing the concurrency token and letting a stale guarded client clobber
newer data (Codex P1).

Route every collection update through one small transaction that re-reads
updated_at (FOR UPDATE on Postgres; SQLite BEGIN IMMEDIATE covers it) and
derives the new timestamp atomically: strictly advance past the row's current
value when now() hasn't already moved on. This makes updated_at a reliable
concurrency token for guarded AND tokenless writers. Add a tokenless-monotonic
regression test.

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

* fix: close remaining collection-concurrency gaps (BUG-2265, Codex round 3)

- [P1] Board column reordering (handleGroupReorder) rebuilt the full schema
  from a stale local snapshot and wrote it with no token — a lost-update path
  identical to the bug being fixed. Now sends expected_updated_at and, on 409,
  refetches, re-applies the reorder onto the fresh schema, and retries once.
- [P2] The workspace settings page seeded EditCollectionModal from a
  page-load-time collections list, so a change that predated editing produced
  a false 409. It now refreshes the list on collection_updated (seq-guarded).
- [P2] collection.updated is now delivered to item-grant-only SSE subscribers
  for collections they can see — it's itemless but leak-free (only the slug),
  so guests' ItemDetail schema/settings snapshots converge too. Filter test
  extended.

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

* fix(web): switch-safety + conflict-merge fixes for collection writes (BUG-2265, Codex round 4)

- Board column reorder now ABORTS on a 409 (with a "reorder again" toast)
  instead of replaying a stale option order onto the fresh field, which would
  silently drop a concurrent option add/remove/rename. Reordering is cosmetic;
  never worth clobbering a real schema edit. Also captures ws/slug/base before
  the await and fences the write against a route switch.
- QuickActionsMenu captures workspace + collection identity BEFORE the first
  await, so a mid-save navigation can't make the 409 refetch/retry target the
  wrong collection (no guaranteed remount).
- Settings-page SSE refresh captures the workspace and drops the result if the
  workspace changed while fetching, so a slow refresh for workspace A can't
  overwrite workspace B's freshly loaded collection list.

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

* fix(store): sub-second collection updated_at token, no future drift (BUG-2265, confirming pass #6)

The same-second monotonic advance manufactured whole-second FUTURE updated_at
values; sustained >1 write/sec on one collection drifted arbitrarily ahead of
wall-clock. collections.updated_at is TEXT on both dialects and never compared
lexically (only via time.Equal + display), so switch the update write to
sub-second nowNano(): same-second collisions become near-impossible, so the
token advances naturally. Keep a strict-monotonicity guard but step by a single
NANOSECOND on the (now near-impossible) coarse-clock/step-back collision, so any
drift is bounded to nanoseconds. Dual-dialect; covered by make test-pg.

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

* fix(server): sanitize + always-broadcast collection event (BUG-2265, confirming pass #2,#3)

- #2 (P1): collection_updated is delivered to item-grant guests, but the event
  carried ActorName/Source, leaking the owner's identity + edit source. Strip
  them — publishCollectionEvent now emits workspace + slug (+ new_slug) only.
- #3 (P2): always broadcast (including on rename), routed by the OLD slug and
  carrying the NEW slug via a new Event.NewSlug field, so remote tabs on the old
  slug can re-target instead of silently 404ing on their next action.
Tests: assert no actor/source leak on a settings update; assert a rename routes
by old slug + carries new_slug.

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

* fix(web): decisive switch-safety + rename handling for collection writes (BUG-2265, confirming pass #1,#3,#4,#5)

- #1 (P1): EditCollectionModal captures the target collection id/slug/name/ws +
  updated_at when the form is SEEDED, and handleSave/handleArchive now operate on
  that captured identity (not the live props). The seed effect re-seeds when the
  collection IDENTITY changes (not on a same-id broadcast refresh), so a reused
  route can't leave A's form saving/deleting to B.
- #3 (P2): on a rename event the collection route navigates to the new slug
  (preserving the pane query) and ItemDetail refetches by new_slug; the SSE event
  type carries new_slug.
- #4 (P2): the reorder-conflict path refetches the collection (reseeds the token)
  before prompting, so a missed SSE event doesn't make every retry 409 forever.
- #5 (P2): QuickActionsMenu only invokes oncollectionupdated when the live
  workspace/slug still match the captured identity, so a reused route can't
  assign an old response to the newly-navigated page.

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

* fix(server): nano token round-trip, rename visibility, publish-before-migration (BUG-2265, confirming pass 2)

Address the round-2 confirming-pass findings (server-only):

1. (P2) The shared update_conflict envelope formatted actual_updated_at with
   second precision (time.RFC3339), truncating the now sub-second collection
   token so the client's retry token never matched — a permanent 409 loop.
   Format with time.RFC3339Nano. Item tokens are zero-nanosecond, so
   RFC3339Nano emits no fractional part — the item 409 wire shape is
   byte-identical and the item path still compares via time.Equal. Added a test
   that the returned token round-trips as a usable retry token.

2. (P2) Rename events are routed by the OLD slug, but a subscriber that
   revalidated after the rename only has the NEW slug in visibleSlugSet, so the
   visibility check dropped the event before the new_slug branch. Accept a
   rename when EITHER the old slug or the (authorized) NewSlug is visible;
   downstream item-grant gating uses whichever slug is visible. Filter test
   extended.

3. (P2) The collection_updated event was published only after field migrations
   succeeded, but UpdateCollection already committed (updated_at advanced). On a
   migration failure clients got a 500 and no refresh, leaving siblings with
   stale tokens that 409 blindly. Publish on the commit (before the migration),
   so siblings always resync regardless of migration outcome.

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

* fix: atomic collection update+migration; modal same-id rename retarget (BUG-2265, confirming pass 3)

Address the round-3 confirming-pass findings; defer cross-tab rename
RE-NAVIGATION to BUG-2272 (placeholder) per coordinator.

1. (P1) Migration atomicity. UpdateCollection committed the schema + concurrency
   token BEFORE MigrateItemFieldValues ran, so a migration failure returned 500
   with the row already changed → the retry was guaranteed to 409 and item
   values were left inconsistent with the committed schema. Made the two ATOMIC:
   extracted applyFieldMigrationsTx and run it inside UpdateCollection's own
   transaction (after taking the workspace seq lock), so a migration failure
   rolls back the schema AND the token — nothing changes, the retry works.
   The handler now passes migrations through instead of running them separately,
   and publishes the event only after the fully-atomic commit. store/tx work →
   make test-pg run green.

2. (P2) EditCollectionModal same-id rename. The round-1 identity capture ignores
   same-id prop refreshes (to preserve edits), but a concurrent RENAME changes
   the slug (not the id), so handleSave/handleArchive PATCHed a dead slug → 404
   before the token could 409. On a same-id prop change whose slug changed, the
   seed effect now retargets the endpoint slug + re-captures the token WITHOUT
   reseeding the form (in-progress edits preserved).

Deferred (BUG-2272, TODO comments added, already broken on main — no regression):
- ItemDetail full-page item URL/collSlug not retargeted after a remote rename.
- Collection route chained-rename events during SSE replay landing on a dead
  intermediate slug.

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

* fix(web): keep seeded token on same-id rename retarget (BUG-2265)

On the EditCollectionModal same-id rename branch, retarget the endpoint
(slug/name/ws) only — drop the token re-capture. Re-capturing let a later
handleSave succeed against the renamed collection and apply the modal's stale
pre-rename full form, silently REVERTING the concurrent rename (the exact
stale-snapshot clobber BUG-2265 prevents). Keeping the seeded token means a
concurrent rename correctly yields a 409 → the non-destructive "collection
changed, reload" message. Slug-retarget without token-recapture gives both:
no 404 (right URL) and no clobber (409 fires).

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

* fix: lock-order deadlock + unified collection-snapshot fences (BUG-2265, confirming pass 4)

1. (P1) DEADLOCK regression. UpdateCollection's atomic migration path took the
   collection-row FOR UPDATE lock and THEN the workspace seq lock, but item
   creation takes them in the reverse order (workspace advisory lock first, then
   the collection-row FK lock on INSERT) — a concurrent item-create +
   schema-migration ABBA-deadlocks on Postgres. Fix: acquire the workspace seq
   lock BEFORE the collection-row FOR UPDATE (matching item-create's order).
   Every store path that locks both now takes them workspace-seq → collection-row
   (tryCreateItem, UpdateItem, MigrateItemFieldValues, UpdateCollection). Added a
   concurrency regression test (item-create racing schema-migration); make test-pg
   green.

2+3. (P2) Cross-generation fence gap. The SSE collection refresh and route/item
   loads used SEPARATE counters, so a stale in-flight load could complete after a
   fresh SSE refresh and revert the collection + its concurrency token. Unified to
   a SINGLE monotonic collection-snapshot generation in BOTH the collection route
   and ItemDetail — every collection-snapshot write (loadCollection/loadData, the
   SSE refresh, reorder, and the quick-action/edit-modal callbacks) bumps it on
   start and gates its assignment on "still latest". ItemDetail's load keeps a
   switch-escape so a stale refresh for the OLD collection can't block loading a
   NEW one. Settings page unified the same way over its collections-list writes.

4. (P2) Settings page fed a stale editingCollection to the edit modal after a
   remote rename (its prop never changed → the same-id-rename retarget never
   fired → 404). The unified refresh now re-points editingCollection at the
   refreshed object for the same id, so the modal's retarget fires.

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

* fix: emit item-changes signal on field migration; QuickActions retry-by-id (BUG-2265, confirming pass 5)

1. (P1) A collection update that runs a field migration mutates item `fields`
   JSON and advances item `seq`, but only collection_updated was published —
   open item views refreshed collection METADATA and returned without
   reconciling the migrated items, so clients kept stale field JSON under the new
   schema and a later full-fields item update could UNDO the migration (a
   clobber). UpdateCollection now returns the migrated-item count; when > 0 the
   handler ALSO emits the existing bulk item-mutation signal (items_bulk_updated,
   Op=migrate) so open views reconcile via /items-changes. Fires only when the
   migration touched >= 1 item — a pure settings/quick-actions update emits
   nothing extra. No store SQL/locking change (Go signature + count plumbing
   only); make test / make test-pg both green.

2. (P2) QuickActionsMenu's 409 retry GET-by-slug 404s if the competing update
   renamed the collection. Resolve the fresh collection by STABLE id (list +
   find by id) before re-appending + retrying, mirroring EditCollectionModal's
   identity approach; the result-propagation guard is now id-based too so a
   rename doesn't spuriously drop it.

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

* fix: uniform sweep of item-grant delivery, rename routing, and 409/404 retries (BUG-2265, confirming pass 6)

One pattern-sweep instead of per-site patches. Audited every event this PR
publishes and every client retry path, applying three patterns uniformly:

PATTERN A (item-grant SSE reconcile) + B (old-slug rename routing): instead of a
SEPARATE items_bulk_updated migration event (which carries op/count for items an
item-grant subscriber can't see and isn't rename-routed), FOLD a SANITIZED
`items_changed` bool onto collection_updated — already item-grant-delivered
(round 3) and already old-slug-routed with new_slug (round 2). On it the client
triggers a /items-changes deltaSync (server-filtered to the caller's grants) and
ItemDetail refetches its open item, so item-grant EDITORS reconcile migrated
field JSON — closing the clobber where a stale full-fields update would UNDO the
migration. Leak surface: "a collection you can see items in changed [+ renamed +
had item changes]" — a bool, no per-item data. Removed the round-5
items_bulk_updated publish. The pre-existing items_bulk_updated (archive/move) is
untouched and correctly stays suppressed for item-grant users.

PATTERN C (409 AND 404 in retries): a competing RENAME can 404 a slug-targeted
write before it can 409, bypassing recovery. Added isNotFoundError /
isConflictOrNotFound helpers; every write/retry path now treats BOTH: QuickActions
save resolves-by-id and retries on either; board reorder reseeds-by-id and aborts
on either; EditCollectionModal save shows the reload prompt and archive
resolves-by-id and retries on either.

Tests: server asserts collection_updated sets items_changed on migration (not on
settings-only) and stays sanitized; the SSE-filter test asserts the migration
variant reaches item-grant subscribers for a visible collection; web unit tests
assert 404/409 classification and a real component-driven not_found -> resolve-
by-id -> retry in QuickActionsMenu. make test / make test-pg / npm run test all
green.

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

* fix: stable collection-ID identity for collection events + request-based items_changed (BUG-2265, confirming pass 7)

1. (P1) Collection events were identified only by MUTABLE, reusable slugs, and
   events replay — so a stale rename event's old slug, once re-owned by a
   DIFFERENT collection, could pass a slug-based match and misroute a client
   (navigate away / load the wrong schema) or leak the new slug. Fix at the ROOT:
   carry the STABLE CollectionID on collection_updated (Event.CollectionID) and
   match by ID everywhere:
   - Server visibility: sseEventVisibleFor matches collection_updated on a new
     visibleCollIDSet (built from the same VisibleCollectionIDs), not the slug —
     so an event for a collection the subscriber can't see by ID is dropped even
     if its (reused) slug is in visibleSlugSet. Filter test proves the slug-reuse
     drop.
   - Clients: ItemDetail and the collection route match `event.collection_id ===
     <their collection>.id`, not slug. Slug(s)/new_slug stay only for the
     rename-navigation URL. Settings refreshes its whole list (already id-safe).

2. (P1) items_changed was keyed off the affected-ROW count, delivered to
   item-grant subscribers → a subscriber whose own items were unaffected could
   infer that HIDDEN items matched the migrated value. Now keyed off whether a
   field MIGRATION WAS REQUESTED (len(input.Migrations) > 0), independent of row
   count — leaks nothing about hidden item values. Reverted round-5's
   UpdateCollection count-return (no longer needed). Test: a migration matching
   ZERO items still sets items_changed.

Deferred with markers:
- NOTE(BUG-2273) at ItemDetail's reconcile-skip AND updateField: the web editor's
  full-fields field write lacks item-level OCC (never adopted IDEA-1480/v0.14),
  so the migration reconcile is best-effort.
- TODO(BUG-2272) at the reorder 404 reseed: it refreshes `collection` but not the
  route `collSlug` (renavigation, deferred).

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

* fix: archive OCC (no destructive wrong-target) + settings load fence (BUG-2265, confirming pass 8)

1. (P1) EditCollectionModal handleArchive resolved the target by stable id but
   the server DELETE re-resolves by the MUTABLE slug — a rename that re-owned
   that slug before the delete landed would archive the WRONG collection.
   Close the TOCTOU with an expected_updated_at OCC on the delete, mirroring the
   update OCC: DeleteCollection re-reads updated_at under a lock (FOR UPDATE on
   Postgres) and 409s on mismatch; the handler validates the token + maps the
   409; the client sends it as a query param; handleArchive passes the seeded
   token (and the fresh token on the resolve-by-id retry). A reused slug or a
   concurrently-changed target now yields a clean 409 → the reload message,
   never a wrong-collection archive. Server test: stale token 409s (and the
   collection survives); current token 204s; malformed 400s; no token 204s.

2. (P2) settings load(): the generation was bumped AFTER awaiting setCurrent, so
   a slow load for workspace A could resume after B's load and clobber B's
   name/context/collections/members. Capture a dedicated loadGen at load() ENTRY
   (before any await) and fence EVERY continuation on it; the collections write
   additionally respects collectionsGen so it can't revert a fresher SSE refresh.
   Using a dedicated loadGen (not the SSE-shared collectionsGen) means an SSE
   collections-refresh mid-load doesn't drop the name/members writes.

Deferred: TODO(BUG-2272) at the collection route's rename-navigation site — the
global collectionStore (sidebar/pickers) isn't refreshed and the workspace
layout ignores collection_updated, so the sidebar keeps the dead slug. Layout-
level renavigation, deferred.

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

* fix(web): dedicated item-snapshot fence + id-based rename comparisons in ItemDetail (BUG-2265, confirming pass 9)

One comprehensive ItemDetail async-snapshot fence sweep so this file's item/
collection fencing is uniform and ID-based.

1. (P1) The migration item-refetch and loadData shared loadGeneration, so the
   refetch could apply migrated fields and then a stale loadData response
   overwrite them (a later full-fields edit then undoes the migration). Added a
   DEDICATED itemGen (separate from loadGeneration and collectionGen), bumped at
   the start of BOTH loadData's item load AND the migration refetch, and gated
   BOTH `item = ` writes on "still latest itemGen" — neither can stale-overwrite
   the other. Swept the other PASSIVE item snapshot-refreshes onto itemGen too
   (SSE item_updated/archived/restored, onSync deleted/incremental/full, the
   collab refresh) so they're ordered against each other and the migration/load.

2. (P2) A settings update that follows a rename before the rename fetch completes
   requested the OLD slug and bumped collectionGen, cancelling the valid rename
   fetch. Fetch slug is now `event.new_slug || event.collection || slug`.

3. (P2) The loadData collection fence-escapes compared the stale load's SLUG vs
   the freshly-renamed snapshot's slug (they differ on a rename → escape let the
   stale result overwrite). They now compare stable collection IDs; the SSE
   refresh's post-fetch identity check is id-based too.

Audit (site -> generation -> id?): every PASSIVE snapshot-refresh (loadData
item+collection, migration refetch, SSE x3, onSync x3, collab) bumps the correct
dedicated gen (item->itemGen, collection->collectionGen) and compares identity by
id. The DELIBERATE user/action writes (title/field/tag/assignee/role/content/
link/version/restore saves) keep loadGeneration + item-id switch-safety; their
item-snapshot concurrency vs the migration refetch is the deferred item-OCC gap
(BUG-2273, best-effort) — reordering them last-started-wins is orthogonal to that.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 09:43:05 -04:00
xarmian 8e16501e8a fix(security): scope webhook + token mutations by workspace to close cross-workspace IDOR (TASK-266) (#936)
* fix(security): scope webhook + token mutations by workspace to close cross-workspace IDOR (TASK-266)

handleDeleteWebhook, handleTestWebhook, and handleDeleteToken looked their
object up by ID with no workspace-ownership predicate. requireMinRole("owner")
only proves the caller owns the URL's workspace — not that the {webhookID} /
{tokenID} belongs to it — so any owner of any workspace could delete or test
another workspace's webhook, or revoke its API token, given the object ID
(cross-workspace IDOR / integrity + DoS + existence oracle).

Fix, matching the existing pre-fetch-and-compare idiom used by
handleDeleteWorkspaceAttachment / views / comments / links:
- webhooks: pre-fetch via GetWebhook and 404 unless hook.WorkspaceID matches
  the URL workspace (delete + test paths).
- tokens: new store.DeleteAPITokenScoped(id, workspaceID) doing
  DELETE ... WHERE id = ? AND workspace_id = ?, mirroring DeleteUserAPIToken.
  The unscoped DeleteAPIToken (its only caller) is removed.

Adds TestWebhookTokenCrossWorkspaceIDOR: an owner of workspace B cannot
delete/test A's webhook or revoke A's token via B's URL (404, objects survive),
while the legitimate owner still can within their own workspace. Verified
red-before/green-after.

Part of PLAN-259 (pre-open-source security audit). Closes the webhook+token
half of TASK-266; views/comments/links were already scoped.

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

* fix(security): atomic workspace-scoped webhook delete per Codex review (round 1)

handleDeleteWebhook pre-fetched via GetWebhook, which decrypts the HMAC
secret. A rotated/missing encryption key or corrupted ciphertext would make
the delete return 500, leaving a broken webhook undeletable. Replace the
pre-fetch-and-compare with an atomic store.DeleteWebhookScoped(id, workspaceID)
(DELETE ... WHERE id = ? AND workspace_id = ?) — same idiom as the token fix,
no decrypt on the delete path. The unscoped DeleteWebhook (its only caller) is
removed. handleTestWebhook keeps GetWebhook since it needs the decrypted hook
to dispatch.

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

* fix(security): scope test-webhook lookup before decrypt per Codex review (round 2)

handleTestWebhook fetched via GetWebhook (SELECT + decrypt by ID) and only then
compared workspace, so an undecryptable foreign webhook returned 500 rather than
404 — a residual cross-workspace existence oracle. Add store.GetWebhookScoped(id,
workspaceID) which applies the workspace_id predicate in SQL before decrypting;
a foreign/missing ID returns (nil,nil) → 404 without touching the ciphertext.
Strengthen the regression test's victim webhook with a non-empty secret so the
scoped-before-decrypt path is exercised.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 23:24:55 -04:00
xarmian c72fe5a663 feat(items): add unparented filtering contract (#926)
* feat(items): add unparented filtering contract

* fix(items): preserve unparented projection state

* fix(views): preserve reserved filter on reset

* fix(items): resync projection scope changes

* fix(items): address PR 926 review findings

- localIndex: fetch snapshot before clearing store/cache in resyncProjectionScope (no data-loss window on fetch failure)
- items: degrade to committed item when post-parent-link readback fails instead of 500
- items: treat unparented=<non-true> as a field filter so a schema field named unparented still filters
- persistence: delete dead persistCursor
- mark validateUnparentedListRequest canonical; cross-reference the 3 early-feedback copies

* fix(items): resync race + purge safety per Codex review (round 1)

- resyncProjectionScope: merge-reconcile instead of blunt clear so a higher-seq
  upsert/delta racing the snapshot fetch is preserved (not erased) and the cursor
  never regresses below it
- recheck generation after persistWipe so a sign-out/403 purge during the wipe
  can't resurrect purged rows via persistDelta
- snapshot rows authoritatively replace local copies (drop is_unparented on
  downgrade); mergeRow's projection-preservation is bypassed for resync

* fix(items): sanitize projection bit on preserved racing rows per Codex review (round 2)

When a projection resync lands a restricted snapshot, strip is_unparented from
any racing higher-seq row kept by the seq guards — the old scope no longer grants
it. Keep the row itself (dropping it would reintroduce the racing-mutation data
loss; server 403 enforces real visibility).

* fix(items): transactional cache replace in resync per Codex review (round 3)

Replace wipe()+persistDelta() in resyncProjectionScope with a single
persistReplace() transaction (clear + write in one tx). Avoids the
deleteDatabase() onblocked cross-tab hang where a pending delete stalls the
following reopen+write indefinitely, wedging the resync promise. wipe() stays
for the sign-out / schema-mismatch full-teardown paths.

* fix(items): drop-and-replay resync reconciliation per Codex review (round 4)

Rework resyncProjectionScope: drop every row absent from the authoritative
snapshot (not just older-than-cursor ones) and pin the cursor to the snapshot
cursor. A post-snapshot mutation the client can still see is re-fetched by the
next /items-changes?since=cursor under the NEW scope, so visible rows return and
old-scope-hidden rows stay gone — no old-scope row survives the resync, and
nothing is permanently lost. Present-in-snapshot racing edits are still kept
(is_unparented stripped under a restricted scope).

* fix(items): continue delta poll after resync so replay actually fires (round 5)

The drop-and-replay resync (round 4) pins the cursor to the snapshot cursor so
post-snapshot mutations re-fetch under the new scope — but both poll loops broke
out / returned immediately after the resync, so the replay never ran until an
unrelated sync/reload. Both callers now continue the loop from the pinned cursor;
resync already aligned the scope so the branch can't re-fire, and the existing
50-iteration cap bounds it.

* fix(items): keep pendingResync set until replay catches up (round 6)

resyncProjectionScope cleared pendingResync after installing the snapshot but
before the pinned-cursor replay drained. If that replay later failed or hit the
50-page cap, pendingResync stayed false and the next bootstrap() no-opped with
racing mutations still missing. Let the reconcile loop's caughtUp logic own the
flag instead.

* fix(items): set pendingResync when any resync begins (round 7)

Round 6 removed the premature clear but only the bootstrap path pre-sets
pendingResync; a page deltaSync resync ran with it false, so a failed/capped
replay there wouldn't trigger a bootstrap resume. Set pendingResync=true at the
start of resyncProjectionScope so any caller marks catch-up pending; the
reconcile loop clears it on caughtUp.

* fix(items): fence stale optimistic writes + epoch-guard resync catch-up (round 8)

Adds a resync-epoch + fenced-id mechanism to close the last two race classes:

- fencedIds: a resync records the ids it dropped (hidden under the new scope).
  upsert() refuses a fenced id, so a stale old-scope create/update response
  resolving after the resync can't resurrect a now-hidden row that no new-scope
  delta would evict (P1). An authoritative applyDelta re-add un-fences; the next
  resync recomputes the set (re-upgrade clears it). Self-contained in the store —
  no epoch threading through the optimistic callers.
- scopeEpoch: bumped when a resync installs a new snapshot. Both reconcile loops
  capture it before each /items-changes and skip treating a response that raced a
  concurrent resync as caught-up, so a stale in-flight delta can't clear
  pendingResync without validating the pinned cursor (P2).

Regression test covers fence → reject stale upsert → authoritative re-add
un-fences → later edits accepted.

* fix(items): bump scope epoch before resync fetch (round 9 P2)

scopeEpoch advanced only after listIndex() returned, so a reconcile response
racing the fetch saw the old epoch and could clear the pendingResync the resync
set at start. Bump the epoch before the network await instead.
2026-07-13 22:46:55 -04:00
xarmian bfa32dde5a fix(security): encrypt webhook HMAC secrets at rest, mask in responses (BUG-2057) (#915)
Webhook signing secrets were stored plaintext in the webhooks.secret column
and echoed back in every API response. Encrypt them at rest (reusing the
existing AES-256-GCM store helpers, same pattern as TOTP secrets) and return
the raw secret ONLY in the creation response; list responses now mask it and
expose a has_secret flag instead.

- store: encrypt on CreateWebhook, decrypt on Get/ListWebhooks so the
  dispatcher still signs with the plaintext secret. Reuses the secret column
  with the "enc:" prefix — no new column/migration. Keyless self-host stays a
  no-op fallback (encrypt returns plaintext; decrypt passes legacy rows
  through unchanged).
- BackfillEncryptWebhookSecrets encrypts pre-existing plaintext rows on
  startup once a key is configured (idempotent), mirroring the TOTP backfill.
- model: add HasSecret so masked responses still signal presence.
- handlers: mask secret on list; document raw-only-on-create.
- tests: encrypt-at-rest round-trip + HMAC validity, list decrypt,
  plaintext backfill/back-compat, and the API mask-except-on-create contract.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-11 00:10:27 -04:00
xarmian 3bb50ab27f fix(security): make TOTP login codes single-use (BUG-2054) (#914)
The 2FA verify path accepted a valid TOTP code with no consumed-step
tracking, so within a code's ~30s window (plus skew) the same code was
replayable, and unlike the recovery-code branch the TOTP branch had no
per-challenge attempt cap.

Add a nullable users.totp_last_step column and an atomic compare-and-set
Store.ConsumeTOTPStep: a code's derived time-step must be strictly greater
than the stored watermark, and the winning UPDATE advances it in the same
statement so two concurrent requests can't both consume one step. The
handler derives the exact step a code matched (pinned within the ±1 skew
window, not the current step) and rejects a replay with the same
invalid-code response — no replay signal is leaked. Also caps TOTP attempts
per challenge token by reusing the existing RecoveryCode limiter.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 23:56:59 -04:00
xarmian bed933d7fd feat(items): field-level PATCH + conflict envelope + read-only version history (TASK-2022) (#876)
* feat(items): field-level PATCH + conflict envelope + version history

Adds three related item-update primitives (TASK-2022 / IDEA-1480):

- Field-level merge: PATCH `fields_patch` shallow-merges onto the item's
  current fields INSIDE the write transaction (null deletes a key), so
  concurrent single-field updates no longer clobber each other via the
  full-blob read-modify-write. `pad item update` and the MCP `pad_item.update`
  action now send only the changed keys.
- Optimistic concurrency: optional `expected_updated_at` on update; on
  mismatch the store returns *UpdateConflictError and the handler emits the
  pad-structured-error/v1 conflict envelope (HTTP 409, code=update_conflict).
  Surfaced on CLI (`--expected-updated-at`) and MCP (`expected_updated_at`).
- Read-only version history: `pad item history <ref>` (alias `versions`) and
  MCP `pad_item.history`, reusing the existing item_versions store + versions
  endpoint (no new store, no schema change).

MCP ToolSurfaceVersion bumped 0.9 -> 1.0 (new action + param; update
behavior change). No migration required.

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

* fix(items): address Codex review — dispatcher fields_patch, OCC ordering, date/required guards

Round 1+2 review fixes for TASK-2022:
- HTTP MCP dispatcher (dispatch_http_advanced.go) now sends fields_patch (only
  changed keys) instead of a client-side merged full fields blob, and forwards
  expected_updated_at — remote MCP callers get the same race-free merge +
  optimistic concurrency the CLI/HTTP paths do.
- ValidatePartialFields rejects null-deleting a schema-declared REQUIRED field
  (would otherwise persist a blob the full-update validator rejects).
- Open-children guard on the fields_patch path merges the patch onto the IN-TX
  locked row inside the precheck (not a stale pre-lock preview), so a
  priority-only patch can't false-fire the guard.
- Optimistic-concurrency check now runs BEFORE the open-children precheck in the
  store, so a stale expected_updated_at yields update_conflict (not
  open_children) — single in-tx re-read shared by both.
- Date auto-population on the patch path only fills an EMPTY current date; an
  existing end_date the caller isn't touching is preserved.

Tests added for each fix.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:47:34 -04:00
xarmian c846cff4fd feat(project): agent-accessible activity feed (pad project activity + MCP action) (#877)
* feat(project): agent-accessible activity feed (pad project activity + MCP action)

Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.

- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
  backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
  (handler parse + store SQL clause) so limit/actor/since behave
  identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
  instructions.md) and add a SKILL.md querying-guidance line.

Tests: store since-filter test, HTTP dispatch test, catalog action test.

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

* fix(mcp): mark pad_project.activity read-only in tool surface

Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:26:41 -04:00
xarmian e071b6273f fix(store): workspace-level guard for N-hop parent cycles (BUG-2074) (#871)
BUG-2073 (PR #870) closed the 1-hop A<->B parent-cycle race by folding
the child key into a sorted per-endpoint lock batch and re-reading under
lock. But that batch only covers the two endpoints of the edge being
written, so a cycle closed via an edge on an item that NEITHER endpoint
locks still slips through the per-endpoint checkParentCycleQ ancestor
walk. Concrete N-hop reproduction (Postgres): with A->B and C->D already
committed, two concurrent adds SetParentLink(B,C) and SetParentLink(D,A)
lock the DISJOINT sets {B,C} and {D,A}, so both cycle walks pass on stale
snapshots and both inserts commit, forming A->B->C->D->A.

Fix (workspace-level cycle guard):

- New acquireWorkspaceParentLinkLock — a Postgres advisory xact lock keyed
  on a DISTINCT namespace ('pad:parent-link-cycle:' || workspaceID) that
  serializes ALL parent-edge-ADDING transactions in a workspace. With no
  two adds running concurrently, checkParentCycleQ always walks a
  consistent, non-racing ancestor snapshot and catches arbitrary N-hop
  cycles.
- Acquired OUTERMOST (before the seq lock and the per-item parent-children
  batch) in the parent-edge-adding paths: setParentLinkOnce (SetParentLink)
  and updateItemWithParentLinkOnce (UpdateItemWithParentLink, only when it
  actually adds a parent). Global lock order is therefore
  cycle -> seq -> parent-children in every transaction that takes them, so
  no AB/BA inversion can form.
- CreateItemLink was the SECOND parent-edge adder and a hole: with
  link_type="parent" it appended a raw parent row with NO cycle check and
  NO workspace lock — it could form cycles even single-threaded, could give
  a child multiple parent rows (the schema only uniques
  (source_id,target_id,link_type)), and checkParentCycleQ follows only ONE
  arbitrary parent per source so the extra row could hide an N-hop cycle.
  Now CreateItemLink routes link_type="parent" through SetParentLink, which
  gives it the full guarded protocol: single-parent DELETE-then-INSERT, the
  workspace cycle lock + checkParentCycleQ under lock, and the
  errParentSetChanged retry wrapper. Non-parent link types (blocks /
  supersedes / implements / related) keep the append-only INSERT — none are
  followed by the cycle walk, so none can form a cycle.
- Scope is edge-ADDERS only: edge removals (clear/detach) and plain field
  updates / status flips can't create a cycle, so they don't take the lock
  — the common UpdateItem path stays un-serialized. The BUG-2073
  per-endpoint locks remain (they still bound the re-read-under-lock
  open-children invariant); the workspace lock is the outer guard that
  closes the N-hop gap.
- Rejected alternative (locking the full ancestor chain per write):
  complex, deadlock-prone under concurrent reparents, hard to keep in
  canonical sorted order.

Postgres-only for the concurrency races (SQLite serializes all writers via
BEGIN IMMEDIATE). Tests:
- TestSetParentLink_ConcurrentNHopNoCycle and
  TestCreateItemLink_ConcurrentNHopNoCycle build the disjoint-lock
  A->B->C->D->A quad via parallel goroutines and assert no cycle forms;
  both verified to reproduce the bug with the guard disabled (6/64 and
  14/64 quads cycle) and pass with it.
- TestCreateItemLink_ParentSingleParentAndCycle (dialect-agnostic) asserts
  CreateItemLink(parent) now enforces single-parent (latest wins) and
  rejects a direct cycle.
golangci-lint, go test ./..., and the full Postgres suite all green.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 14:46:15 -04:00
xarmian 55bfed543e fix(store): close parent-link cycle & stale-old-parent TOCTOU races (BUG-2073) (#870)
The public SetParentLink/ClearParentLink paths and the shared
acquireParentChildrenLocksForUpdate helper had two residual TOCTOU
races (pre-existing on main; the NEW atomic UpdateItemWithParentLink
path was already made cycle-safe in PR #868 / BUG-2013):

1. Cycle race: SetParentLink acquired only the old+new parent advisory
   keys, never the CHILD's own (itemID) key. Concurrent
   SetParentLink(A,B) and SetParentLink(B,A) locked disjoint keys
   ({B} vs {A}), so both cycle walks passed on stale snapshots and both
   inserts committed — forming an A<->B cycle.

2. Stale-old-parent race: oldParent was read BEFORE the parent-children
   locks were acquired and never re-read. A concurrent reparent of the
   same child committing while this tx waited on locks let it DELETE the
   newly-committed parent link without holding that real old parent's
   lock, breaking the open-children guard serialization. The shared
   read-then-lock helper (UpdateItem/RestoreItem/MoveItem) had the same
   defect: it read the parent set before locking itemID.

Fix, consistent with the PR #868 pattern (sorted lock batches, tx-scoped
cycle walk), and deadlock-free:

- setParentLinkTx / clearParentLinkTx: fold itemID into the lock set and
  acquire {itemID + old + new parent} in ONE sorted batch, then RE-READ
  the old parent under the (now-held) child lock. New readParentLinkTarget
  helper.
- acquireParentChildrenLocksForUpdate: after the sorted acquisition,
  re-read the parent set under the itemID lock (keysNotIn detects any
  parent that appeared during the acquisition window).
- When a re-read shows the parent set moved, signal the errParentSetChanged
  sentinel instead of acquiring the moved key out of the canonical sorted
  order (which could deadlock). The tx-owning callers — SetParentLink,
  ClearParentLink, UpdateItemWithParentLink, RestoreItem,
  MoveItemWithPreCheck — wrap their bodies in retryOnParentSetChanged,
  which rolls back (releasing every advisory lock) and retries from a
  fresh read. Every acquisition stays a single in-order sorted batch. The
  signal fires before any commit, so a retry never leaves partial state;
  bounded by maxParentLockRetries.
- RestoreItem: route through acquireParentChildrenLocksForUpdate so it
  also holds the item's own lock and gets the re-read correction.
- CreateItemLink / DeleteItemLink: for child link types, lock the SOURCE
  item's key in addition to the target's. Attaching/detaching sourceID as
  a child mutates sourceID's parent set, so sourceID's own lock must be
  held for the "the child lock freezes an item's parent set" invariant the
  re-read/retry above depends on. Both keys go through the sorted helper,
  so the two-key grab stays deadlock-free.

Postgres-only races (SQLite serializes writers via BEGIN IMMEDIATE), so
the new concurrency tests are gated on the Postgres dialect. They pass
with the fix and reproduce the A<->B cycle without it.

Out of scope (pre-existing, tracked separately): cycles closed via an
edge on an item that NEITHER endpoint locks (e.g. A->B->C->D->A) still
slip through the per-endpoint cycle walk — a documented limitation of the
per-endpoint lock scheme, not the direct A<->B race this bug names.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 13:55:03 -04:00
xarmian a7994fc374 fix(store): make item field + parent-link update atomic (BUG-2013) (#868)
* fix(store): make item field + parent-link update atomic (BUG-2013)

handleUpdateItem committed the field write, then ran SetParentLink/
ClearParentLink as a SEPARATE store transaction. A failure there
(cycle discovered late, DB error) returned 500 with half the patch
already applied — a code-acknowledged partial-commit window.

Fold the parent-link mutation into the SAME transaction as the field
write:

- store: extract setParentLinkTx / clearParentLinkTx from the public
  SetParentLink / ClearParentLink (which keep their own tx). Add
  UpdateItemWithParentLink(id, input, precheck, *ParentLinkUpdate) —
  UpdateItemWithPreCheck now delegates to it with a nil link. The
  link write runs after the field UPDATE but before COMMIT, so a
  failing link write rolls the field write back too.
- lock ordering: the NEW parent's advisory key is folded into the
  update's initial sorted AcquireParentChildrenLocks batch (extraKeys
  on acquireParentChildrenLocksForUpdate), so setParentLinkTx's later
  re-lock is an idempotent no-op and the combined update stays
  deadlock-free. checkParentCycle is parameterized over the queryer
  so the cycle walk reads inside the tx.
- handler: restructured into validate / atomic-write / post-commit
  stages. The parent-link directive is built once and threaded through
  all three UpdateItemWithParentLink call sites; the post-commit
  SetParentLink/ClearParentLink block is removed.

Works on both SQLite and Postgres (advisory locks are pg-only; SQLite
gets atomicity from BEGIN IMMEDIATE). Adds store tests proving a
failing parent-link write rolls back the field change (no partial
state) and that the happy path commits both together.

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

* fix(store): check parent cycle under lock in setParentLinkTx (Codex #868)

Codex review flagged a cycle-check TOCTOU: setParentLinkTx walked the
ancestor chain BEFORE acquiring the parent-children advisory locks, so
two concurrent reparents could each pass on a stale snapshot, block on
the lock, then both insert — forming a cycle (A→B→C→A). Move the cycle
check to AFTER lock acquisition; under the lock the tx-scoped walk sees
the edge the just-unblocked peer committed and rejects the cycle.

Pre-existing behavior (the old SetParentLink checked cycles on s.db
before even beginning its tx), hardened here since this function was
already being refactored. Residual: cycles closed via an edge on an
item neither endpoint locks remain possible — a limitation of the
per-endpoint lock scheme, tracked separately.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 12:18:01 -04:00
xarmian 48104a5eff perf(server): collapse dashboard/bootstrap N+1 into set-based queries (BUG-2002) (#847)
The dashboard builder (also reused by the bootstrap endpoint and every
pad_set_workspace) ran ~1000 queries on a large workspace: one
GetItemLinks + per-link GetItem for every non-done item (blocked
attention + suggested_next filter), a GetChildItems per active plan
(progress + suggested_next), a GetItemIncludeDeleted per recent-activity
row, a GetCollection per visible collection, and a per-collection COUNT
via ListCollections whose result the dashboard never uses.

Replace every per-item/per-row loop with a set-based query:
- GetBlocksEdges: one workspace-wide JOIN of blocks-links -> blocker
  essentials, ordered created_at DESC to preserve the old first-active-
  blocker selection. Drives both blocked attention and the suggested_next
  blocked-filter (retires itemBlockedByActive).
- GetChildItemsForParents: one IN query grouping all active-plan children
  by parent (progress + suggested_next; no-content projection).
- GetItemsByIDsIncludeDeleted: one IN query batch-hydrating recent-activity
  items (include-deleted).
- ListItemsParams.NoContent: skip loading full markdown bodies on the
  count/summary scans (allItems, plans, stalled, orphaned).
- ListCollectionsMinimal now also selects slug; the dashboard uses it in
  place of ListCollections, dropping the unused per-collection COUNT N+1
  and the GetCollection-per-visible-id loop.

Per-item N+1s are gone; query count is now constant in workspace size.
Verified byte-identical dashboard + bootstrap JSON against three live
workspaces (docapp/claude/apm); dashboard latency ~376ms -> ~198ms on the
1907-item docapp workspace. New store methods are unit-tested.
2026-07-07 17:18:54 -04:00
xarmian 375e3b5369 perf(store): batch parent-lineage enrichment into one scoped query (BUG-2003) (#846)
enrichItemsWithParent loaded every parent link in the workspace and then
called full-row GetItem once per unique parent (151 in the live workspace),
despite a comment claiming a bulk fetch. A ?limit=1 list took 34-39ms vs
~2ms for a single GET — a ~20x tax to return one row, hit on every list
request including the /items-changes sync endpoint the local-first client
polls.

Scope the parent IDs to only the parents of the returned item slice, then
hydrate title/ref/slug/collection in one skinny WHERE id IN (...) query via
the new Store.GetItemLineageByIDs. Enrichment output shape and best-effort
(missing parent never fails the list) behavior are preserved; the visibility
filter now runs against the batched projection's collection_id.
2026-07-07 16:51:48 -04:00
xarmian 0aa431f132 fix(server,cli,mcp): default item list to per-collection non-terminal filter (BUG-2001) (#845)
The CLI's default `pad item list` (no --status/--all) sent a hardcoded
~20-status allowlist as the status filter. Collections with custom status
vocabularies (blog: drafting/scheduled; human-tasks: todo) fell outside the
list and had their open items hidden. MCP inherited the same bug via the
CLI default and the HTTP route table's mirrored allowlist.

Replace it with a server-side `non_terminal` filter: ItemListParams.NonTerminal
resolves each collection's terminal set from its schema's terminal_options
(falling back to DefaultTerminalStatuses) and keeps only items NOT in that
set — reusing the existing doneFiltersForWorkspace + buildChildrenDoneExpr
machinery, applied in both the normal and FTS query paths.

The CLI default and both MCP dispatch paths (ExecDispatcher via the CLI,
HTTPHandlerDispatcher via mapItemList) now send non_terminal=true. --status X
and --all semantics are unchanged.
2026-07-07 16:45:00 -04:00
xarmian 8c609be2e3 feat(store): guard against schema-ahead downgrade + pre-migration snapshot + upgrade docs (TASK-2006) (#843)
The migration runner only applied missing embedded migrations and never
detected a DB that was AHEAD of the binary, so a brew/docker downgrade
silently ran old code against a newer schema. It also took no backup
before migrating, and there were zero upgrade docs.

- guardSchemaAhead: refuse to start when schema_migrations contains a
  version that sorts after the highest embedded migration (a downgrade).
  Escape hatch: 'pad start --force' / PAD_ALLOW_SCHEMA_AHEAD=1. Applied
  to both the SQLite and Postgres migration paths.
- snapshotBeforeMigrate (SQLite only): copy the DB file to
  <db>.pre-<VERSION> before applying pending migrations, but only when
  upgrading an existing DB (pending AND already-applied migrations).
  WAL-checkpointed, atomic temp+rename copy, and preserves an existing
  snapshot on retry so a failed multi-step upgrade can't clobber the
  original rollback point. Postgres is skipped (pg_dump/PITR is the DBA's).
- Docs: 'Upgrading Pad' in README + an 'Upgrading' section in
  docs/deployment.md (forward-only rule, guard behavior, snapshot, flow).
2026-07-07 16:32:20 -04:00
xarmian f5b437a65f feat(server): workspace restore + deleted-list endpoints (TASK-1970) (#827)
Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A
workspace delete only stamps workspaces.deleted_at; items/collections/
members are untouched, hidden transitively. Restore clears deleted_at so
everything re-surfaces intact.

Store (internal/store/workspaces.go):
- RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=?
  AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no
  soft-deleted row matched (already live or purged).
- ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within
  the window, ordered deleted_at DESC. Account-deleted workspaces have no
  live owner, so they never leak.
- GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal
  resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404.
- Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists).

Handlers (internal/server/handlers_workspaces.go):
- POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable,
  403 non-owner, 200 + restored workspace; logs a "restored" activity.
- GET /api/v1/workspaces/deleted: owner-scoped list with per-entry
  purge_at + days_left, both derived from workspacePurgeRetention so
  restore and the purge sweeper share ONE 30-day window (no drift).
- Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which
  resolves only live workspaces); restore enforces owner authz inline.

CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces.
TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace.

Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window
boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403,
404 live/unknown, 200 restore, owner-scoped deleted-list). Green on
SQLite and Postgres (make test-pg); golangci-lint clean.

Closes TASK-1970

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-06 10:07:34 -04:00
xarmian b73ba63752 feat(store): hard-purge soft-deleted workspaces after 30 days (TASK-1966) (#825)
The /privacy policy promises owned workspaces are removed from live
systems within 30 days, but DeleteAccountAtomic and DeleteWorkspace only
SOFT-delete (workspaces.deleted_at) and nothing ever expunged them —
a right-to-erasure gap. Add a scheduled sweeper that hard-purges
workspaces soft-deleted longer than a named 30-day retention constant.

- Store: ListPurgeableWorkspaces (soft-deleted + past cutoff; never
  touches live rows), WorkspaceAttachmentBlobs, CountAttachmentsForHash-
  OutsideWorkspace (content-addressed dedupe guard), and PurgeWorkspace-
  Data — a transactional cascade that deletes every workspace-scoped
  child row in FK-dependency order (items/comments/versions/links/
  reactions/stars/yjs op-log/wiki-links/grants/transitions/moves/views/
  collections/documents+versions/agent_roles/webhooks/invitations/
  templates/share_links+views/oauth join rows/report layouts/members/
  member access/api tokens/attachments/activities), de-identifies
  mcp_audit_log, and refuses to touch a non-soft-deleted workspace.
- Server: a periodic sweeper modeled on the orphan GC — captures blob
  keys before the purge, cascades the DB rows, then reclaims blobs
  through the attachment store abstraction (FS + S3 safe) with the
  orphan GC's cross-workspace dedupe + in-flight-upload guards. Failure
  isolated per workspace; idempotent.
- Dual-dialect (SQLite + Postgres); partial index on
  workspaces(deleted_at) — migrations/073 + pgmigrations/051.

Both delete paths (account + manual workspace delete) purge on the same
30-day clock: identical deleted_at mechanism, both owner-initiated, and
the orphan GC already reclaims their attachment blobs at 30 days.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 22:23:12 -04:00
xarmian 366d4fb7e5 fix(store): harden account-deletion FK cascade (TASK-1959) (#821)
* fix(store): harden account-deletion FK cascade (TASK-1959)

DeleteAccountAtomic could 500 with nothing deleted when a user had rows
referencing them via foreign keys with no ON DELETE action — notably
activities.user_id (the audit/history log, including the session_ip_changed
rows the auth middleware writes on the very request that deletes the
account). The delete-account tests only passed by working around this
(pinning RemoteAddr to loopback, scrubbing activities.user_id).

Audit every table with a FK to users(id) and handle each in the delete
transaction:

  - de-identify (UPDATE ... SET NULL) audit/history rows: items
    created/modified, comments, comment_reactions, item_links,
    item_versions, share_link_views
  - delete owned/transient/audit rows: sessions, api_tokens,
    workspace_members, sent invitations, password/email tokens, issued
    grants, created share links, mcp_audit_log, oauth_connections
  - rely on existing ON DELETE CASCADE / SET NULL for item_stars,
    user_report_layouts, {collection,item}_grants.user_id,
    items.assigned_user_id

Migrations 072 (SQLite) / 050 (Postgres) give activities.user_id an
ON DELETE SET NULL FK so the highest-write-frequency audit table can't
block a delete via a row written concurrently during the request. SQLite
rebuilds the table (022_audit_trail pattern); Postgres never had the FK,
so it is added after an orphan scrub so validation passes.

Remove the test work-arounds now that the cascade holds: deleteAccountReq
deletes from a changed IP so the session-IP-change audit row exercises the
fix, and the partial-delete test injects its post-cancel failure via the
sidecar hook instead of the (now-fixed) FK gap. Add a store-level test that
deletes a fully-populated user atomically.

Closes TASK-1959

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

* fix(store): harden account-deletion FK cascade (TASK-1959)

DeleteAccountAtomic could 500 with nothing deleted when a user had rows
referencing them via foreign keys with no ON DELETE action — notably
activities.user_id (the audit/history log, including the session_ip_changed
rows the auth middleware writes on the very request that deletes the
account). The delete-account tests only passed by working around this
(pinning RemoteAddr to loopback, scrubbing activities.user_id).

Audit every table with a FK to users(id) and handle each in the delete
transaction:

  - de-identify (UPDATE ... SET NULL) audit/history rows: items
    created/modified, comments, comment_reactions, item_links,
    item_versions, share_link_views
  - delete owned/transient/audit rows: sessions, api_tokens,
    workspace_members, sent invitations, password/email tokens, issued
    grants, created share links, mcp_audit_log, oauth_connections
  - rely on existing ON DELETE CASCADE / SET NULL for item_stars,
    user_report_layouts, {collection,item}_grants.user_id,
    items.assigned_user_id

Migrations 072 (SQLite) / 050 (Postgres) give activities.user_id an
ON DELETE SET NULL FK so the highest-write-frequency audit table can't
block a delete via a row written concurrently during the request. SQLite
rebuilds the table (022_audit_trail pattern); Postgres never had the FK,
so it is added after an orphan scrub so validation passes.

Log the account_deleted audit row with an empty user_id (deleted id kept
in metadata): the user row is already gone by then, and the new
activities.user_id FK would otherwise reject the insert and silently drop
the row. This makes the account_deleted event actually recorded on both
dialects.

Remove the test work-arounds now that the cascade holds: deleteAccountReq
deletes from a changed IP so the session-IP-change audit row exercises the
fix, and the partial-delete test injects its post-cancel failure via the
sidecar hook instead of the (now-fixed) FK gap. Add a store-level test that
deletes a fully-populated user atomically.

Closes TASK-1959

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 19:23:02 -04:00
xarmian e6eec608e0 feat(web): default agent-connect modal to MCP setup, steer zero-grant users (#817)
Reorder ConnectWorkspaceModal so MCP setup is the default and first tab —
most users landing here have never connected an agent, so the OAuth "fresh
agent" path is their real first step. CLI is second; the claim code moves to
a third "Connect code" tab, reframed as a scoped-grant add-on rather than the
(misleading) "recommended" default it was.

Close the zero-grants dead end: add Store.HasActiveConnectionForUser and
surface has_any_connection on the claim-code endpoint, so a user who opens the
Connect-code tab with no connected agent gets steered to set one up first
instead of a live-looking but unredeemable code. Hide the MCP + code tabs on
self-host deployments without a public MCP URL (both depend on the remote
OAuth server), leaving CLI as the sole, default path there.

Verified: go build ./..., go test ./internal/server/ ./internal/store/,
web npm run check (0 errors), and a Codex review all pass clean.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 12:05:24 -04:00
xarmian 4a7c054223 feat(server): cloud email self-registration + verify-email/resend endpoints (TASK-1938) (#808)
Wave 3b of PLAN-1933 turns ON Pad Cloud email/password self-registration
with mandatory email verification.

- DR-6: relax handleRegister to allow self-serve signup when
  cloudMode && emailConfigured. emailConfigured = s.email != nil AND a
  USABLE public base URL (non-empty, not a 0.0.0.0/:: bind-all host), so
  no unverifiable user is ever created. Self-serve is the ONLY path that
  writes email_verified_at = NULL (UserCreate.Unverified); admin-created
  and invited signups stay verified. Mints + sends a verification email.
- DR-5: POST /auth/verify-email (ConsumeEmailVerification → flips
  email_verified_at → returns fresh user) and POST /auth/resend-verification
  (always-200, enumeration-safe; minting a new token invalidates the prior
  one). Both wired into the rate-limiter path switch (PasswordReset bucket).
- DR-1: handleAcceptInvitation verifies an unverified account on accept
  (email-bound invite proves email control), via new store method
  SetUserEmailVerified.
- DR-11: keep the existing clear 409 on duplicate email at signup.

Session freshness: currentUser is re-read fresh from the DB per request
(ValidateSession → GetUser), so flipping email_verified_at unblocks the
same session's subsequent mutations immediately under RequireVerifiedEmail
(Wave 3a) — no session-row rewrite needed. Test covers verify → same-session
mutation succeeds.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 03:32:48 -04:00
xarmian b0eeef16ce feat(store): email_verification_tokens + SendEmailVerification + token reaper (TASK-1936) (#806)
Wave 2 of PLAN-1933 — verification-token infrastructure (pure infra; no
endpoint consumes it until Wave 3).

- Migration 071 (SQLite) / 049 (Postgres): email_verification_tokens table,
  cloning the password_resets shape (id/user_id FK/token_hash/expires_at/
  used_at/created_at + token_hash + user_id indexes), per-dialect created_at
  default.
- Store email_verification.go: 256-bit crypto/rand token, padver_ prefix,
  SHA-256-at-rest, non-destructive Lookup, atomic UPDATE...RETURNING Consume.
  Deltas from password_resets (DR-2): 24h TTL, keep invalidate-prior-on-mint
  (resend burns the old link), consume side-effect sets users.email_verified_at
  (RFC3339-with-Z, same format Wave 1's migration used) in one transaction —
  no password reset, no session mint.
- Email SendEmailVerification: clones SendPasswordReset, "1 hour" -> "24 hours".
- Token reaper (DR-5): lifecycle-safe background sweep (mirrors orphanGC/opLogGC
  — self-registers on Server.bg, context-cancellable via stop channel, started
  only from cmd/pad/main.go so unit tests don't leak goroutines) calling the
  four previously-unwired CleanExpired* methods (email verifications, password
  resets, sessions, CLI auth sessions) hourly. Adds CleanExpiredEmailVerifications.
- Audit consts ActionEmailVerified + ActionEmailVerifiedByAdmin.

Gates: make check + make test-pg green (store + migration on both dialects).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 01:19:41 -04:00
xarmian 6a63fba188 feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805)
Wave 1 of PLAN-1933 (email verification). Pure infra — nothing reads the
column until Wave 3, so this is behaviourally a no-op and mergeable early.

- Migration 070 (SQLite) / 048 (Postgres): add nullable email_verified_at
  TEXT, mirroring disabled_at. UNCONDITIONALLY backfill every existing row
  to verified (RFC3339 'Z'-suffixed) so no existing / OAuth / self-host
  account is write-locked on deploy (inverted vs password_set's conditional
  backfill). SQLite ALTER without IF NOT EXISTS; Postgres with it.
- SAFE default = verified (DR-3): CreateUser / CreateOAuthUser write a
  verified timestamp unless UserCreate.Unverified is explicitly requested
  (only the future cloud self-serve branch will set that). A missed call
  site fails SAFE (verified), not write-locked.
- models.User.EmailVerifiedAt + IsEmailVerified() (mirror IsDisabled).
- Update userColumns + BOTH scan sites (scanUser AND the inline SearchUsers
  scan) so the admin user list keeps working.
- Expose derived email_verified bool in sessionUserPayload for a later wave.

Gates: make check + make test-pg both green (dual-dialect verified).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 00:45:48 -04:00
xarmian f3335adea2 fix(server): filter handleListUserGrants through caller visibility (BUG-1928) (#799)
handleListUserGrants returned a target user's raw collection/item grants
(including collection_id/item_id) to any workspace owner unconditionally,
letting a restricted owner (collection_access="specific") enumerate
hidden-resource IDs — the disclosure half of the primitive BUG-1923's
handlers closed the action half of.

Filter the response through the caller's visibility when caller != target:
collection grants against guestResourceFilter's strict full-access set
(same set requireCollectionFullyVisible narrows to — item-grant-only
collections don't qualify), item grants via a bulk item_id->collection_id
lookup (GetItemCollectionRefs, state-agnostic so soft-deleted parents stay
listed) plus the existing isItemVisibleToGuest set-membership check.
Self-queries and unrestricted callers stay unfiltered, the latter via a
cheap short-circuit.

GetDeletedItemsWithCollection's query had no deleted_at filter despite its
name; renamed the shared implementation to GetItemCollectionRefs and kept
the old name as a wrapper for its existing delta-sync caller.
2026-07-03 20:18:29 -04:00
xarmian 20544fdd44 perf(test): build the SQLite migration chain once per test binary (IDEA-1914) (#788)
* perf(test): build the SQLite migration chain once per test binary (IDEA-1914)

internal/server's -race suite spent ~30 minutes replaying all 69
migrations + 3 backfills per test (~2.7s each, 622 store-backed tests,
BUG-1913). Add internal/store/storetest, which runs the full migration
chain once into a checkpointed, sidecar-free template DB (sync.Once)
and hands every test a plain file copy opened via store.New. Wire it
into internal/server's testServer/testServer_Stop_DrainsRateLimiterCleanup
and internal/store's own testStore (duplicated inline there — an
import cycle rules out sharing storetest with store's white-box
tests). Postgres-mode tests are untouched.

internal/server -race: 1819s -> 183s.

* fix(test): plug template-dir leak and Cleanup race in storetest fixture

Codex round 2 on IDEA-1914: buildTemplate/buildSQLiteTemplate left the
MkdirTemp'd template dir on disk if store.New/checkpoint/journal_mode
failed after mkdir succeeded — now removed via a disarm-on-success
defer in both mirrored copies. Also guard Cleanup()/removeSQLiteTemplate
against racing an in-flight build+copy with a sync.RWMutex (read-locked
across build+copy, write-locked for removal) in both places.
2026-07-03 10:15:49 -04:00
xarmian 584ac9a806 fix(web): treat CLI/MCP-created workspaces as agent-connected (BUG-1557) (#781)
`pad init` connects an agent (installs the skill, stores credentials) and
creates a workspace, but the web UI still showed the "connect an agent"
banner and onboarding launchpad. The only signal for "agent connected" was
has_agent_activity — an item existing with source cli/mcp — and a fresh
pad-init workspace has zero items, so the UI nagged to connect an agent the
user already had.

Give the server a truthful signal: a workspace created through an agent
surface already has an agent wired up before it creates its first item. Add
a `source` column to workspaces (web/cli/mcp), attributed authoritatively
server-side from the request auth shape (actorFromRequest) — never from the
request body, so a web client can't spoof "cli" to self-suppress the
prompts. The dashboard ORs source in (cli,mcp) into has_agent_activity when
the cheap item check comes up empty.

- migrations 069 (sqlite) / 047 (postgres): workspaces.source NOT NULL
  DEFAULT '' (legacy rows stay "unknown", never treated as agent-created)
- models.Workspace.Source + WorkspaceCreate.Source (json:"-", server-set)
- thread source through the CreateWorkspace INSERT + all 7 workspace scan
  sites (workspaces.go, workspace_members.go)
- handleCreateWorkspace derives source from actorFromRequest
- OnboardingLaunchpad step 1 collapses to "Agent connected" when the agent
  is already wired up, shifting emphasis to "tell it to set up"

Web modal and cloud-signup auto-create flows are unchanged and still
correctly prompt to connect (source web / empty).

Tests: store source round-trip across reads; dashboard reports
agent-connected for a cli-created workspace with zero items; web-created
stays not-connected until an agent item exists; a web body-spoofed source
is ignored.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 23:25:24 -04:00
xarmian b7bedc89df fix(web): resolve item-detail wiki-links via local-first index, not full /items (#770)
Detail pages loaded the full content-bearing /items (~4.7MB) just to resolve
[[wiki-links]], stalling/timing out the page; list pages were fine because they
use the local-first localIndex read model. Move the detail page + editor [[ picker
onto localIndex (getAll accessor; zero extra fetch on warm nav). Harden SQLite:
bound the connection pool + periodic wal_checkpoint(TRUNCATE). Codex review clean
(P1 collab-flush ws, P2 inline-create ws — both fixed).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-06-27 20:37:58 -05:00
xarmian 22d901c823 fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)
On a fresh instance, `pad init` / `pad auth setup` created the admin
account in the browser and dropped the operator on the console, then
printed a SECOND "authorize the CLI" URL back in the terminal that a
user who'd moved to the browser never saw — forcing a ctrl-C + re-run.

Collapse it into a single browser tab: the CLI mints the pending CLI
auth session up front and hands /setup a validated `next=/auth/cli/<code>`
target, so account creation flows straight into the approval page where
the just-bootstrapped admin approves in one click and the CLI connects.

- internal/cli/bootstrap.go: thread `next` into the /setup URL (query
  before the #token fragment); raise bootstrapPollTimeout to 20m to
  match the setup session TTL.
- cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates
  the session and polls it; `pad workspace init` drives local setup inline.
- cmd/pad/init.go: `pad init` routes through the unified handoff.
- internal/store + internal/server: grant a setup-specific 20m CLI auth
  session TTL when UserCount==0 so the combined create-account + approve
  window can't expire mid-flow; normal logins keep the 5m default.
- web/src/routes/setup: honor a validated local `next` redirect (open-
  redirect guarded), preserved across the token-fragment scrub.

Reviewed via Codex loop (3 rounds → clean).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-20 23:51:43 -04:00
xarmian 99b4649bb6 fix(items): surface archived items instead of masking them as missing (BUG-1791) (#733)
A soft-deleted (archived) item still appears in include-archived list
results (all=true) but 404'd on get/update/move and was absent from search
and status-filtered lists — all=true is the only read path that includes
archived rows. With no archived marker in list output and a bare "Item not
found" on get/update, this looked like index/FTS corruption (the report's
diagnosis). It is not: every read path was behaving correctly for an
archived item. The root cause is observability, not a desync.

- scanItems now scans i.deleted_at; all six feeding SELECTs select it
  (ListItems, listItemsFTS x2 dialects, getChildItems, ItemsModifiedSince,
  ListStarredItems). Archived rows in include-archived results now carry
  deleted_at so callers can tell them apart from live rows; the
  deleted_at-filtered paths are unaffected (value stays NULL there).
- GET item resolves include-deleted, returning an archived item read-only
  (200) with its deleted_at marker rather than 404 — an agent can read it
  and see it is archived.
- UPDATE/DELETE/MOVE of an archived ref return a clear 409 "archived"
  (restore first) instead of a bare 404; visibility is enforced exactly as
  the active path so an archived item is never revealed to a caller who
  can't see it.
- CLI shows an (archived) marker in lists and an Archived line in detail.

Tests: store IncludeArchived populates DeletedAt; server GET archived -> 200
with deleted_at, UPDATE/MOVE archived -> 409 "archived". Verified on SQLite
and Postgres (make test-pg).
2026-06-15 14:44:58 -04:00
David Barkhausen 03d73478ed fix(store): allow NULL api_tokens.workspace_id on SQLite for account tokens (#732)
Rebuild api_tokens on SQLite with workspace_id nullable, matching Postgres and the Go contract. Fixes the 500 on POST /api/v1/auth/tokens (workspace-agnostic account-token creation) on SQLite deployments.

Fixes #731.

Co-authored-by: b4rk13 <b4rk13@users.noreply.github.com>
2026-06-15 11:31:45 -04:00
xarmian 33e49434ed fix(server): non-fatal UA session binding + sliding session renewal (#727)
Two root causes behind users being logged out:

- UA session binding was unconditional and fatal — any User-Agent change
  (browser/WebView update, DevTools device emulation, mobile rebuild)
  silently de-authenticated the session. Now log-only across all three
  enforcement sites (TokenAuth, SessionAuth, and the validateSessionCookie
  helper used by CLI-auth/account/session-check routes), mirroring the
  default IP-change handling. (BUG-1815)

- Sessions had a fixed absolute TTL with no refresh on activity, so even an
  active user hit the cliff at 7d (web) / 30d (CLI). Adds sliding renewal:
  RenewSessionIfStale extends expires_at when past the half-window threshold,
  capped at created_at + 90d (SessionMaxLifetime), CAS-guarded and only
  reported when RowsAffected confirms the write. The middleware re-issues the
  session + CSRF cookies on renewal. New renew_ttl_seconds column (sqlite +
  pg migrations); legacy rows (0) keep their fixed expiry. (TASK-1816)

Reviewed by Codex (clean). Tests: store + server suites pass.
2026-06-14 21:15:35 -04:00