mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-29 15:57:01 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
221 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
527fa47e0f |
fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) models.User no longer carries the secret. GetUserQ and its siblings, the member and backlink reads and the mention-recipient check therefore cannot fail on a secret that will not decrypt. checkSecondFactor loads it through the new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP branch (an empty one never verifies), a decrypt failure logs once without secret material and fails closed, and the recovery-code branch is untouched. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1) - checkSecondFactor writes its 500 directly: writeInternalError logged the same error a second time. T2 counts every ERROR record except the request logger's per-request line. - T1's Stripe lookup now matches the undecryptable user (it looked up an unknown customer and could not go red), and ListUsers and SearchUsers are pinned to return the row with its 2FA flag. - The appstore boundary allowlist no longer says GetUserQ decrypts, and drops the deleted decryptUserTOTP. - Two comments no longer claim a single reader of the column. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2) An enabled row whose secret is empty cannot verify a code, so the user is held to recovery codes; checkSecondFactor now logs one warning naming the user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and the test placeholder helper drops its unused argument. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3) user.TOTPEnabled is the request's snapshot, and a disable landing after it also reads as no secret, so the warning no longer says 2FA is enabled; it says there was no secret to verify a code against. T5 asserts the new text. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019) The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty case wrote '' and returned at stored=="" first, and encrypt("") returns "" rather than sealing it, so dropping the guard left every test green. The store test now writes a hand-sealed empty plaintext to an enabled row and expects ErrNoTOTPSecret; a server test refuses a code computed from "" on that row. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5) EnableTOTP also decrypts the stored secret (to compare) and fails on one that will not decrypt, so the header no longer says the second-factor check is the only thing that fails. TOTPSecretForVerify remains the only reader that returns the plaintext. Comment only. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 |
||
|
|
5aa9e49ab4 |
feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b) (#2273)
* feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b)
Registers the two needs scorers in server.New. Each fills needs.Score
{Value, Hint, Why} on every row, reading through the part 1a batch reads
and a request-scoped memo, so one request does each read once.
- Importance: round(100 x (.25P + .20B + .20D + .15S + .10H + .10A)).
B is a breadth-first fan-out over blocks links (visited set, depth 5,
cap 99). It counts and expands only targets visible under the scope and
still open, and returns a count, never refs. S is 0 until the
high_stakes eval (TASK-4011, ruling 440f5c63), so importance is capped
at 85 and not renormalised.
- Simplicity: (3 - effort)/3 x 100. The effort level is the argmax and the
hint comes from a fixed table (cap 40). With no decision runner, effort
is the text heuristic, with no hint.
- needs.Viewer gains Now, set from asOf, as the scorers' clock.
- Why emits only a fixed vocabulary, listed in docs/api.md; a test holds it
to that list.
The PLAN-3535 guard classes the new IsTerminalItem call as "keep".
Rulings 99c2bcfe and 6901f2f5 on the TASK-3959 trail.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): pass the workspace to the scoped decision and job reads (TASK-3959, part 1b)
Follows part 1a's round-1 fix, which scopes LatestDecisionsForItems and OwedDecisionJobs to a workspace. The needs memo passes its own workspace id.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): fail closed on scope and unknown collections, local-day deadline, request ctx (TASK-3959, part 1b r1)
Leak test through the router for a restricted viewer, fail-closed fan-out
pins, isOpen fails closed for an unknown collection, the question-hash read
takes the request ctx, D measures the server-local day like the dashboard,
H is clamped to [0,1], and docs/api.md's degraded prose names the real causes.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): pin every importance input, overdue parity with the dashboard, snapped rounding (TASK-3959, part 1b r2)
Per-input contribution table; overdue decided by itemOverdue; weighted sum
snapped before rounding; injectable location instead of mutating time.Local;
restricted-viewer score compared exactly to the hidden-item-deleted score;
docs: overdue rule and the WorkspaceFlagsForItems degraded path.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* test(needs): pin the deadline key pick, priority case and unreadable effort; scores at as_of (TASK-3959, part 1b r3)
- The importance parts table pins the earlier of due_date and end_date
(either key earlier, end_date alone) and the case-insensitive priority.
- A current effort answer whose level cannot be read falls back to the
text heuristic with no hint; a test now pins it, and docs/api.md says so.
- docs/api.md: scores are computed at the request's as_of, which the
cursor carries to later pages.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): as_of reaches the scorers through the handler; threshold, key and date pins (TASK-3959, part 1b r4)
- A request with a cursor scores at the cursor's as_of, end to end through
ServeHTTP (page 1 at the wall clock is 33; a cursor a week after
waiting_since is 28).
- "judged to need a human" from the attention threshold up, not below.
- Unreadable effort keys -1 and x fall back to the heuristic, like 7.
- A date too short to parse scores D=0.
- blocks() states the lock it runs under.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): the as_of check is its own test, so the binding test stays one claim (TASK-3959, part 1b r4)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): a failed read degrades both real scorers; short-date overdue; effort without a medium (TASK-3959, part 1b r5)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* docs(needs): every scorer sharing a memo passes the same rows (TASK-3959, part 1b r6)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* fix(needs): needs-human weighs only an answer the inbox's flag rule holds current (TASK-3959, part 1b r7)
H and "judged to need a human" read needs_human through the flag path
(WorkspaceFlagsForItems at threshold 0 over the rows), so an answer made
stale by an edit, with no refresh owed, no longer weighs or says so while
the router does not flag the item. Effort keeps question-hash currency;
docs/api.md states both rules. EffortMediumMinProb is pinned at exactly .5.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): a needs-human answer held while its refresh is owed; the asked-row exception in the docs (TASK-3959, part 1b r8)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
|
||
|
|
104f809e67 |
feat(store): enqueue push notifications at four store sites (TASK-3937 part B) (#2268)
* feat(store): enqueue push notifications at four store sites (TASK-3937 part B)
notifications.go writes queue rows on the caller's transaction, only for a
user with a registered device: item assignment (create and update),
comment and reply, fenced (app) comment, and reminder fire. The account
claim also deletes the old holder's devices, queue and settings.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* fix(store): comment recipients collected without a closure so the appstore boundary walk resolves them (TASK-3937 part B)
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* docs(store): with the enqueue built, the Go comments say it exists (TASK-3937 part B)
A (
|
||
|
|
9af942a169 |
feat(store): push notification schema and settings store (TASK-3937 part A) (#2265)
* feat(store): push notification schema and settings store (TASK-3937 part A)
Tables push_devices, user_notifications, user_notification_settings
(sqlite 155 + generated NUL triggers 156, pg 124), the settings model and
store, the migrated.go left-out/refusal entries, and the cascade tests.
The enqueue side is part B.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* test(store): ScanNUL cost figure counts the push tables' columns (TASK-3937 part A)
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* fix(store): index the push queue's actor, say retention is the dispatcher's, cover the queue in the purge test (TASK-3937 part A r1)
- idx_user_notifications_actor on both dialects: deleting a user runs
actor_user_id's ON DELETE SET NULL against the queue.
- 155's comment no longer claims a 7-day retention that nothing
implements yet: the sweep lands with the dispatcher (TASK-3939).
- user_notifications joins wsChildTables and is seeded by
seedFullWorkspace, so the no-orphan and no-over-purge tests cover it.
Shown able to fail: with workspace_id's cascade removed, the purge
test reports "table user_notifications left 1 rows".
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* fix(store): isolate push_devices' user cascade in a test; dispatcher prose says it is TASK-3939's (TASK-3937 part A r3)
- TestPushDevices_UserCascade binds a device to another user's session
and deletes the device's user, so only push_devices.user_id's cascade
can clear the row. TestPushTables_Cascades could not: account deletion
removes the user's sessions first. Shown able to fail: with the cascade
removed from 155, the delete is refused (FOREIGN KEY constraint failed).
- TestPushTables_Cascades' header lists exactly what it kills.
- migrated.go, 155, models and nulcolumns no longer describe a
dispatcher or 7-day sweep as if they ran today: both land with
TASK-3939, and until then nothing reads or deletes the queue.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* docs(store): the push-delivery note names the census test; five groups are left out (TASK-3937 part A)
Rebased onto
|
||
|
|
9d563aaf09 |
fix(store): collection settings must be a JSON object at every write door (BUG-3985) (#2260)
* fix(store): the collection store doors refuse non-object settings (BUG-3985)
The HTTP decoder already refuses collection settings that are not a JSON
object (IDEA-1488). CreateCollection (via createCollectionTx, which app
provisioning also uses) and UpdateCollection did not, so a Go caller that
never passes through the decoder could store [], "x", 7, null or non-JSON
text. Both now refuse such a value with models.ErrSettingsNotObject
(introduced by BUG-3976) before writing; "" stays the empty sentinel
written as "{}".
Workspace import is unchanged: it already repairs these values to "{}"
(coerceJSONForImport), the IDEA-1488 log-and-coerce posture for restoring
data. No current internal caller writes a non-object value.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(server): whitespace-only collection settings are refused over HTTP too (BUG-3985)
flexJSONToString passes a whitespace-only settings string through as the
"" sentinel, so it reached the store. Before the store doors refused it, a
PATCH with {"settings":" "} replaced the stored settings with " " (invalid
JSON on SQLite); a create repaired it to an object. Both now answer 400
validation_error with ErrSettingsNotObject and write nothing; the new HTTP
test pins both doors.
Comments that called the create arm unreachable over HTTP, or named
workspace import as a source of non-object stored settings (import repairs
them to {}), are corrected.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(models): whitespace-only collection settings decode as the "" sentinel (BUG-3985)
The previous commit let the store refuse whitespace-only settings, which
turned a create that used to answer 201 (repaired to an object) into a 400.
Instead, CollectionCreate and CollectionUpdate now read a whitespace-only
settings string as "", so create keeps its repair and a PATCH behaves
exactly as "" does: the settings become {} with tracks_work and
awaits_human carried. The store refusal stays as defense in depth.
Settings only. The other fields decoded by flexJSONToString (traits, item
fields and tags, view config) are BUG-3992.
The HTTP tests pin "" and two whitespace shapes on both doors, and the
create handler's comment is restored to "unreachable over HTTP" with the
reason updated.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
|
||
|
|
dd412a022d |
feat(needs): GET /me/needs, the cross-workspace needs-you inbox and its router (TASK-3920) (#2253)
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920) GuestWorkspaceIDs and GetWorkspacesByIDs for the fan-out; NeedsCandidateItems (visibility scope in SQL), OpenMentionsForItems, NeedsSnoozesForItems and NeedsMarkersForItems as batches, WorkspaceOwnerIDs, LatestAssignmentTimes. Snoozes are joined through items.workspace_id and read only for ids the caller already scoped by visibility (day-19 rulings on TASK-3920). Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * feat(needs): the routing rule, row types and cursor for the needs inbox (TASK-3920) Resolve applies asked, mentioned, assigned, admin_fallback in strict exclusive order over pure inputs (TestResolve_Order). WaitingSince is the earliest open claim. Score.Value is an int 0-100; Scorer takes the viewer and scope. The cursor carries as_of, view and ws and is position only. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * feat(server): GET /me/needs, the cross-workspace needs-you inbox (TASK-3920) Fans out over the caller's member and guest workspaces (token allow-list, and a token bound to one workspace stays in it), reads candidates under the caller's visibility, routes them with needs.Resolve, applies the caller's snoozes (joined through items.workspace_id), and pages a total order. degraded is set only on a provider error. Documented in docs/api.md. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(server): classify the needs inbox's route and lifecycle call (TASK-3920) Adds GET /me/needs to the compress census and its IsTerminalItem call to the PLAN-3535 lifecycle classification, and drops an unused helper and test field the linter found. Follows the previous commit; both ride the same PR. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(needs): a mentioned row is built from the caller's own mentions; attention flags read only caller-visible items (TASK-3920) The mentioned rule built one routing for every mentioned person, so a caller's row carried the earliest mention of anyone and other people's commenters and comment ids. Resolve now builds each person's view from their own mentions (Routing.For), and the handler takes the caller's. The attention read evaluated the whole workspace before visibility, so an error from an item the caller cannot see set degraded. A new Runner.WorkspaceFlagsForItems filters decision rows to the given ids before any state read; the handler passes the caller-visible ones. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(needs): only surfaced needs_human rows set a claim; clamp a forged future as_of (TASK-3920) The needs_human claim time was filled from every latest decision row, so a candidate whose row did not surface (below threshold, a no, stale) got a false needs_human reason whenever the workspace had any truly flagged item. Record and apply it only for flagged items. A cursor as_of later than now is clamped to now so the defer snapshot cannot be forged forward. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * test(needs): restricted-scope store test; document the display-name assignee match (TASK-3920) Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd |
||
|
|
a16d76f9d5 |
fix(server): a typed settings member over non-object stored settings answers 400 (BUG-3976) (#2256)
PATCH /collections/{slug} with tracks_work or awaits_human merges into the
stored settings. When those are not a JSON object (import, a hand edit, an
old row), the merge failed with a plain error and answered 500. It now
wraps models.ErrSettingsNotObject, and both collection handlers map it to
400 validation_error with the same text. A full settings write still
repairs the row.
Over HTTP only the flags-only update can reach it: the request decoder
already refuses incoming settings that are not an object (IDEA-1488), so
the create mapping is defensive and commented as such. The tests pin the
decoder's 400 for those doors so they cannot regress to a 500.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
|
||
|
|
16aaaf5450 |
feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919) A collection whose items are asks of a person (IDEA-3906): the needs-you router will treat an open, assigned item in it as "asked". Stored as settings.awaits_human like tracks_work: absent means false, a settings write that omits it keeps the stored value, a typed member on create and update sets it alone, and a non-boolean is refused 400. No migration (A3 default). - models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool setting helpers that the tracks_work functions now use too - store: create and update apply it; update carries it under the row lock - CLI: pad collection update --awaits-human[=false], checked on the response - web: "Items are asks of a person" toggle on the editor's Display tab - docs/api.md Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX * fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919) A plain bool field made a stored non-boolean awaits_human (an import is not validated) fail the whole CollectionSettings unmarshal, so a reference collection read as work. CollectionAwaitsHumanJSON now reads the one key. Also corrects the --awaits-human comment: the flag shape is not MCP-safe. Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX * test(store): assert the returned settings after turning awaits_human off (TASK-3919) staticcheck SA4006: the UpdateCollection result was assigned and never read. Claude-Session: https://claude.ai/code/session_01Fbkk7f7M5JqZE1Gc4ZSSYj * fix(cli): pad collection create takes --awaits-human (TASK-3919) Codex r2 P2: only `collection update` had the flag, so marking a human-tasks collection took a second call. create now sends the typed member and, like update, refuses to report success when an older server ignored it. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd |
||
|
|
ffc11387e1 |
feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)
Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
removed / changed / superseded / current state, and the advisory
possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
criterion hash is stored against the body it came from, via resolved
in-workspace and visibility-checked, activity "verified", item_updated
SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
and reconcile.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* docs(api): the verifications endpoint (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)
- F1: when the item moves between the criteria read and the write, the
retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
item that keeps moving (409), and a grant revoked mid-retry (404).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)
- The retry re-runs the via resolution and its visibility check, the same
re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)
- F1: VerificationExport, exported for the bundle's live items and
imported with the item and via ids remapped, every other value
verbatim. Each bundle row is checked before its insert (a refused
statement on Postgres loses the whole import); a row that fails, or
whose item did not import, is dropped and counted. Tests: a SQLite round
trip, the migrate-to-pg path (SQLite export into a Postgres import with
the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)
The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)
#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
|
||
|
|
eb7f8a200c |
feat(needs): per-user dismiss/defer storage and the needs/snooze routes (TASK-3918) (#2231)
IDEA-3906 half A, unit A2.
- Migrations 153 (SQLite) and 122 (Postgres) add item_needs_snoozes, one
row per (user, item), FKs cascading, indexed on item_id.
- A deferral holds until defer_until (an RFC3339 time after now and within
366 days), whatever changes before then (IDEA-3906 A2). A fractional time
is stored rounded up to the next whole second, never early.
- A dismissal holds until something happens after its marker: a live
comment by anyone else newer than seen_comment_at, another such comment
at that same second (seen_comment_ids), or a later status transition
(MAX(status_transitions.seq); items.seq and updated_at don't move on a
comment). The user's own human comments never break it; an agent's
comments under their credentials do, and so does the user's own status
change. Imported comments are history and never break it.
- The marker comes from the client, clamped to the server's values so it
can only claim less was seen. No marker means the current state.
- PUT|DELETE /api/v1/workspaces/{ws}/items/{slug}/needs/snooze, beside
stars and watches: requireItemVisible (404 when hidden) and no edit
check, so viewers and item-grant guests may snooze. Read-scoped tokens
are refused by tokenScopeAllows. DELETE is idempotent.
- nulcolumns classifies the table's TEXT columns as server-written.
- A migrate-to-pg marked SQLite file refuses snooze writes: the table joins
migratedRefusalTables, since models.WorkspaceExport does not carry it.
TestMigratedDatabaseRefusesNeedsSnoozeWrites drives the store's own verbs
rather than iterating the list, so removing the entry goes red.
- Workspace purge relies on the FK cascade; the purge census
(wsChildTables, seedFullWorkspace) now counts item_needs_snoozes.
- docs/api.md documents the endpoint.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
|
||
|
|
9dc679e786 |
feat(server): per-user catch-up digest and read cursor (TASK-3615, PR A) (#2219)
* wip(store): workspace_read_cursors table and cursor store (TASK-3615, PR A part 1) The per-user, per-workspace read cursor behind the catch-up card. seen_at moves only on Mark caught up (Dave, 10-17) and never backwards; reading never writes (lead ruling, day 12). Untested checkpoint commit; the digest endpoint, PUT /me/seen and tests follow. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * wip(server): catch-up digest store query, GET /catch-up and PUT /me/seen (TASK-3615, PR A part 2) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(server): catch-up digest sections, window, cursor, visibility, needs_you, agents (TASK-3615, PR A) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(store): read activity metadata as text on Postgres in the catch-up queries (TASK-3615) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(server): catch-up fixtures for reopen, abandoned, own and unrelated comments, old cursor, done-but-blocked, scoped needs_you (TASK-3615) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(server): needs_you skips reference items; classify the catch-up and blocker lifecycle sites (TASK-3615, PLAN-3535) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(store): catch-up window's lower bound is inclusive, so a same-second event is re-shown, never hidden; document the endpoints (TASK-3615) Review round 1 (hunter, Low): an event stamped in the cursor's second could have happened after the press and was never shown. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(server): classify GET /catch-up as compress in the route census (TASK-3615) Its response carries refs, titles, display names and timestamps: no token, code, signed URL or secret. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ |
||
|
|
02806238d6 |
fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) (#2194)
* fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) Workspace import stored each archive item's fields as carried, and nothing checked unique_scope: workspace_collection. An import creates a new workspace, so a duplicate can only come from inside the archive (an export taken after the BUG-3812 race stored one, or a hand-edited bundle). The new workspace then held two live items with one unique value, and every later write to either that carried it was refused 409. A duplicate invocation_slug failed the whole import instead, on idx_items_invocation_slug_per_collection, as a 500 on the JSON door and a 400 carrying the raw constraint on the bundle door. As ruled: drop and report, the BUG-2367 rule for carried values. The first item in archive order (export order: created_at, id) keeps the value; each later item loses only that key. The unique keys are every unique_scope: workspace_collection field plus invocation_slug, whose index covers every collection whatever its schema declares. invocation_slug is compared as both dialects' indexes compare it: Postgres by its ->> text, SQLite by json_extract's typed value. A value collides if either would, so an archive that imports on one dialect imports on the other. A blob with nothing dropped is stored byte for byte. Each drop is reported as (item, field, value, holder), refs in the new workspace, in a new additive dropped_unique_values member of both import doors' 201. pad workspace import prints each one in move's "dropped:" shape. Import checks no required fields, before or after this change, so a dropped value on a required field leaves the item without it, reported like any other drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import compares invocation_slug numbers exactly, as each index reads them (BUG-3825 r2) Codex r2 M: float64 rounding made 2^53 and 2^53+1 one value, so a value neither index equates was dropped. Hunter r2: Postgres's ->> prints a jsonb number with the scale written, so 1.0 equals the string "1.0" there, and the normalised slot missed it. Numbers now claim their exact value (SQLite) and their numeric output text (Postgres), with no float. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import compares invocation_slug numbers as SQLite's REAL does, and bounds the exponent (BUG-3825 r3) Codex r3 H / hunter r3 M1: SQLite's json_extract returns an int64 literal as INTEGER and anything else as a binary64 REAL, so 0.1 and 0.10000000000000000001 are one value there; the exact big.Rat slot kept both and the index failed the import. The SQLite slot is now the int64, or the float64 (as an integer when it is exactly one). Codex r3 M / hunter r3 M2: big.Rat expanded 1e999999 into a million digits per item. Postgres's numeric text is now built from the literal's digits, and an exponent beyond +-1000 stands for itself. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import keys a Postgres number by value and scale, not expanded text, and checks the slot model against both databases (BUG-3825 r4) Codex r4 M: the +-1000 expansion cap missed 1e1001 against its expanded digits on Postgres. Two jsonb numbers print the same ->> text exactly when value and scale match, so the Postgres slot is that pair, which does not grow with the exponent; a string in numeric's output form shares it. Hunter r4 asked whether SQLite's own decimal parser and Postgres's numeric limits match the model. TestBUG3825_SlotsMatchTheDatabase asks the database under test, for every pair of 58 values, whether its index would see them equal, and requires that dialect's slots to collide exactly then. It caught one mismatch on Postgres: jsonb renders an object as {"a": 1}, so objects and arrays are compared on Postgres only with each other. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): bound the numeric key's exponent; the slot oracle skips only Postgres's out-of-range refusal (BUG-3825 r5) Codex r5: an exponent near MaxInt overflowed len(intPart)+exp; beyond 2^24 the literal now stands for itself (Postgres refuses such a number). Hunter r5: the oracle skipped every Postgres error; it now skips only SQLSTATE 22003, counts them, and covers negative decimals and zero with an exponent. The composite residual is documented for both dialects. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
01021632c9 |
fix(store): version rows record their body's hash, and the walk refuses what it cannot verify (BUG-3820) (#2192)
When a reverse patch fails, the walk resolves the next older row against the newer body (BUG-3655). diff-match-patch matches fuzzily, so when the failed row was a real link in the chain, the older patch can apply to the wrong base and return a wrong body with no error. That body was served in History, diffable and restorable. Every version row now records body_sha256, the hash of the body it stands for (migrations 148 / PG 118, nullable, never backfilled). Create, update and fenced-create rows stamp it; export carries it, and import keeps a well-formed one. The walk checks every resolved body against it. A mismatch is treated as a failed apply. An unhashed diff row below a failure or mismatch is served with its best-effort body, flagged unreconstructable with reason "unverified", and refused for restore and diff like a placeholder. A full-body or verified row re-anchors the walk. The BUG-3745 rebase leaves a hashed row whose rebuilt body does not match alone. The attachment remap verifies by hash, and re-mints a hash only for a body that verified. The History card renders the diff's 409 version_unreconstructable as "this edit's changes can't be shown" instead of a load error, and clears that state when a later fetch of the diff succeeds. It keeps Restore offered: the diff also reads the next newer row, so a verified, restorable version whose newer neighbour failed is refused by the diff. The restore checks only the version and decides; its refusal shows as "Restore failed: <message>". Out of scope: rows already wrong from pre-BUG-3745 throttle damage with no failure above them (no signal exists), and the legacy documents versions table. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
d75db30515 |
fix(server,store): unique_scope values are checked again inside the write, under its lock (BUG-3812) (#2184)
* fix(server,store): unique_scope values are checked again inside the write, under its lock (BUG-3812) The server checked a unique_scope value on the pool before the write, with nothing held between the check and the write, and only invocation_slug has a database index behind it. Concurrent writers carrying the same value all passed and all wrote. The race tests show it at origin/main on both backends: 8 of 8 concurrent creates stored one value on SQLite, and up to 7 concurrent PATCHes did. ItemCreate and ItemUpdate carry an internal UniqueCheck (schema and fields). The store runs it inside the write transaction, after the workspace seq lock on Postgres (SQLite's BEGIN IMMEDIATE holds the write lock already). Every item insert, update, move and copy in the workspace takes that lock. A writer that loses the race gets UniqueFieldHeldError, the same 409 sentence the check before the write answers. Wired doors: create (createItemChecked, which also serves artifact import and library activate), PATCH with fields and with fields_patch, and the bulk field update. Bulk move now re-checks under the move's lock, as single move has since BUG-3665. Copy and the app installer already checked inside their transactions. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(server): the BUG-3812 race legs assert each loser's answer, and cover fields_patch (codex r1 Low) - PATCH: one 200 and seven 409s per round, for both `fields` and `fields_patch`. The items are addressed by ref. A slug like item-7 is read as a ref and named CODE-7, a different item, so two writers hit one item. - Bulk move: one row kept, and every other row either refused with "conflict" under the lock or moved with the value dropped and reported in not_unique (BUG-2367 drops carried collisions). Mutants: without the update check, both PATCH shapes fail. Without the bulk-move re-check, the bulk leg fails (8 holders on SQLite, 5 on Postgres). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
69d2be8429 |
fix(versions): restoring or diffing a version whose patch failed is refused with 409 (BUG-3815) (#2180)
* fix(versions): restoring or diffing a version whose patch failed is refused with 409 (BUG-3815) A version whose reverse patch does not apply resolves to a "[patch error: ...]" placeholder. Restore took that text from the resolved list and wrote it as the item's body; the diff endpoint answered 500. The resolving walk now flags such a version (Version.Unreconstructable, not serialised), and restore and diff refuse it with 409 version_unreconstructable. The flag replaces sniffing the text, so a real body that starts like the placeholder still restores. Only the failed row is refused: rows below it resolve past it, as BUG-3655 rules (the lead's narrow ruling). The risk that one of them resolves wrong is BUG-3820. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(versions): the version reads mark an unreconstructable row; diff control for a lookalike body (BUG-3815) Codex r1 on 3057a4601. The version reads served the "[patch error: ...]" placeholder as content with nothing to tell it from a body. The flag is now serialised as unreconstructable (omitted otherwise), documented, and tested on both reads. Adds the passing diff control for a real body that starts like the placeholder. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
3a49a84908 |
fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687) (#2169)
* fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687) The workspace activity feed applied collection and item-grant visibility in Go, after the SQL LIMIT, so a restricted member whose newest events were hidden got a short or empty page, and the web feed reads a short page as the end. The dashboard's recent activity read 30 rows and filtered them the same way, so the same run of hidden rows emptied it. ActivityListParams.Visibility carries the caller's scope (visible collections, full-access collections, granted items) into ListWorkspaceActivity, so LIMIT and the keyset cursor count visible rows. The feed's Go filter is gone, since the clause is that filter keyed on collection ID instead of slug. The dashboard keeps its filters, because its guest grant rule is narrower than the clause, and sets ExcludeWorkspaceLevel for a guest, whom it never shows workspace-level rows. The census of filter-after-LIMIT lists in internal/server found these two callers and no others; the table is on BUG-3687. Tests: a restricted member's feed under a run of 40 newer hidden rows (page sizes 30 and 2), an item grant narrowing its collection, and the dashboard for a restricted member and for a guest. Pre-fix code and mutants N1-N5 are recorded on BUG-3687. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(activity): the dashboard query skips rows of items that no longer exist (BUG-3687) Codex round 1 (Medium): rows naming an item that no longer exists pass the clause as workspace-level, but the dashboard drops them, so 30 of them could still fill its window. ExcludeUnresolvedItems drops them in the query; the dashboard sets it, and the feed keeps showing them as before. Tests: the dashboard under 40 newer rows of a missing item (with the feed still showing them), and a bearer-authed admin who is a restricted member (round 1 Low, coverage). Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(activity): an unscoped dashboard also skips rows of items that no longer exist (BUG-3687) Codex round 2 (Medium): the exclusion rode on the visibility scope, which a cookie-session admin does not have, so 30 rows of missing items could still fill that dashboard's window. ExcludeUnresolvedItems is now a top-level ActivityListParams flag, applied for every caller, and the dashboard always sets it. Test: a cookie-session admin's dashboard under 40 newer rows of a missing item. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
ff9b9f3579 |
fix(account): account export reads run inside the deadline, from one snapshot per workspace (BUG-3659) (#2160)
* fix(account): every account export read runs inside the export deadline, and an attribution failure aborts it (BUG-3659) The deadline covered only the collection and item reads. The workspace listing and the per-workspace access checks ran before it and the app attribution read after them, so a read held by a lock (on Postgres, an ACCESS EXCLUSIVE lock on app_installs) kept the request open with nothing bounding it. All three now run through withinExportDeadline: before the body a timeout answers 503 unavailable, after it the export aborts (the BUG-3626 door). The attribution read used to log its error and ship the items without it in a file that read as complete; that now aborts too. Seams for the three reads; TestBUG3659_EveryExportReadIsBoundedByTheDeadline stalls each, TestBUG3659_AttributionErrorAbortsTheExport injects a failure. All four red with the wrapping and the error propagation undone. Passing ctx into the queries themselves is BUG-3632, not this change. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(account): each owned workspace is exported from one snapshot that re-checks access (BUG-3659, codex r1 Mediums) Two findings on the first commit. Access was checked once before the body and the workspace read later, so access restricted in between still exported what it now hides; and collections and items were separate pool reads, so an item created between them appeared without its collection. Each owned workspace is now read by accountExportWorkspace from one read-only snapshot (the BUG-3654 BeginReadSnapshot): the access re-check, collections, items and app attribution all on that transaction, inside the deadline. A restriction found there aborts the export (the body has started). The preflight still answers 403 before any byte. Store: ListCollectionsQ, ListItemsQ (the plain listing; search, field filters and NonTerminal read through pool helpers, so it refuses them), ItemsCreatedViaAppQ, and BeginReadSnapshot exported. Server: visibleCollectionIDsQ. Tests: restriction applied after the preflight; a collection and item created between the collection and item reads. Both red with the pool reads and no re-check restored; both pass on SQLite and Postgres. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(account): the snapshot test fails if the export lacks its workspace (BUG-3659, codex r2 Low) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test: reclassify the census sites the BUG-3659 refactor moved, and pin the app ceiling on the export snapshot (BUG-3659, CI) Two call-site censuses failed on #2160. PLAN-3535: nonTerminalFilter now runs in listItemsQ (ListItems delegates; ListItemsQ refuses NonTerminal). TASK-3401: the store visibility call moved from visibleCollectionIDs into visibleCollectionIDsQ, which carries the app-ceiling branch verbatim and is reached by visibleCollectionIDs and by the account export's snapshot re-check. TestBUG3659_SnapshotAccessCheckAppliesTheAppCeiling pins it: with an app context whose ceiling hides the workspace's collection, the pool path and the snapshot path both read the caller as restricted and the snapshot refuses the workspace; red with the ceiling branch disabled. The full internal/models, internal/store and internal/server packages pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
5caf11f829 |
fix(collab): a REST content replacement fences tabs seeded from the old body, as a restore does (BUG-3746) (#2134)
* fix(collab): a REST content replacement fences tabs seeded from the old body, as a restore does (BUG-3746) A prune-and-replace content write (content route and patch route, via PruneAndApply) pruned the op-log and replaced items.content, but stamped no seed boundary. A browser that fetched body A before the write and joined after it, with since=0 and its old content_seq, was admitted, seeded A into the empty document and flushed it over B. Join already force-refreshes a client whose content_seq predates the last restore (BUG-2264); replacements now stamp that same boundary: MarkRestoreBoundary on the write (durable last_restore_seq, in its tx) and SetLastRestoreSeq in memory, under the item lock Join takes, so no join lands in between. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(collab): the BUG-3746 boundary test uses a collab-enabled server (BUG-3746) testServer has no collab manager, so the PATCH took the ordinary write path, never reached PruneAndApply, and the test failed with the fix in place (CI on #2134). Production always runs with collab; the test now does too. The seeded op-log row is dropped: the boundary does not depend on it. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(collab): the patch route has its own seed-boundary leg (BUG-3746) The patch route prunes and replaces through its own PruneAndApply call, wired separately from the content route, so a future edit dropping fenceSeedsBefore there would pass the content-route test. Its twin asserts the boundary moves past the seeded body's seq to the patch's. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(collab): a REST replacement admits a tab seeded at the new seq and refuses one seeded before it, through a real join (BUG-3746) Heron, GO-blocking on #2134: the fence must not over-fence, or every fresh tab after a REST replace would be force-refreshed, possibly in a loop. Per route (CONVE-19), after the replacement a tab joining with content_seq = the new seq gets op_log_cursor, and one at seq - 1 gets force_refresh. Mutant: the in-memory boundary stamped one past the write fails both admit legs ("the fence over-fences"). Counterfactual: with the fence removed, both refusal legs fail. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
a6f96107f4 |
feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.
A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.
- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
The server checks the shape (400 invalid_worker_id) and always matches it
together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
next/ready foreign filter, COALESCEd so its negation is exact. A
worker-bound lease is ours iff worker id + account match; a name-only one
keeps the label + account rule, and a worker refreshing its own name-only
lease binds it. A name-only caller never matches a worker-bound lease,
even under the same label (the collision fixed). Migrations SQLite 142 /
PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
?worker_id query, then the remote MCP caller. The claim answers with the
caller's own worker_id; reads and the 409 show only the SHORT id
("wk_3f2a"); a name-only caller colliding under its own name is told how to
get a worker id.
- CLI: `pad session register` mints the id into the session's registry
record, keeps it when the same process re-registers (a /clear: same pid
and start token) and mints a new one for a new process; older records get
one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
--worker-id on claim, release, project next and ready (the local-stdio MCP
path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
and the MCP session; an explicit `worker_id` (pad_item claim/release,
pad_project next/ready) overrides it and is the resume path across
connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
renders no lease today; the TS type gains the short worker for a future
badge.
Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
aa7bdd4f15 |
feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b)) (#2094)
* feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b))
An agent rebuilt this context by hand: bootstrap, the item, its parent plan
or spec, linked items, and trigger-matched conventions (which need --full
for bodies). Each agent did it differently. The endpoint returns it in one
call, with no LLM in the loop:
- item: fields, body, seq;
- parents: the visible ancestor chain, nearest first, each with acceptance
criteria parsed by internal/acceptance (#2091), and the nearest one's body;
- conventions whose trigger is always or the intent's, with bodies, sorted
must, then should, then nice-to-have, then by ref;
- playbook metadata;
- deps with statuses, decisions with `current`, and linked items;
- omitted and conflicts.
The intent resolves against the workspace's declared trigger options (on-X,
then X); an unknown one is 400 unknown_intent with the valid list. The role
must be a slug and filters by the agent-role column (lead ruling).
The budget admits whole parts in a fixed order and stops at the first that
does not fit. That part and all later ones are listed in omitted, so a small
part never slips in after a refused one. The item always ships.
Visibility: the item uses requireItemVisible (hidden equals missing).
Ancestors, deps and links use itemVisibleFor plus the app-token collection
ceiling and live rows only; conventions use bootstrap's visibility sets.
Invisible parts are absent and not counted in omitted, so the endpoint is
not an existence oracle.
Conflicts are structural only: open_blocker, open_under_closed_parent,
stale_decision, role_and_global_convention.
Tests cover the brief, the refusals, budget order (the stop rule's mutant is
caught, after the first version of the test was found blind to it), a guest
seeing nothing ungranted with a positive control, and hidden-equals-missing.
CLI and MCP surfaces are slice (c).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(server): an archived item's context follows the single-item GET (PLAN-3598 slice (b))
Codex round 1 at
|
||
|
|
493498c977 |
feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585) A replayed create whose item has since been deleted answers 410 idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no stdio marker for it, so it reached agents as a generic error. It is now allow-listed, mapped on the remote 410 arm, and marked on stdio, with a hint shared between internal/cli and internal/mcp. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(items): idempotency keys for comment create (TASK-3585) POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key. The key's answer (the comment id) is recorded inside CreateCommentWithActivity's transaction, so a retry whose first answer was lost returns that comment instead of a twin. A refused comment releases its key; a replay of a comment deleted since answers 410 idempotency_target_gone. `pad item comment --op-key` (a fresh key per invocation by default) retries once behind the idempotency_keys capability; MCP comment forwards op_key on both transports. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(items): idempotency keys for note and decision appends (TASK-3585) PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or Idempotency-Key, but only on an append-only update (append_implementation_note and/or append_decision, plus source); a key with any other member is refused 400. The answer (the item id and the minted entries) is recorded in the update's own transaction, so a retry whose first answer was lost echoes the first entries in `appended` instead of appending twice. The built-in and patch callers, which reach this handler with their own key, are untouched. `pad item note|decide --op-key` (fresh per invocation by default) retry once behind the idempotency_keys capability; MCP note/decide forward op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410 now being structured). docs/api.md covers create, comment and append keys. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c46ecc4cf8 |
feat(items): idempotency keys for item create (TASK-3585) (#2065)
A create carries op_key (body) or Idempotency-Key (header). The key is claimed before the write and its answer (the created item's id) is stored inside the create's own transaction, so a retry whose first answer was lost returns that item instead of creating a twin. A refused create releases its key; a key reused for a different create is 422 idempotency_key_reused; one still in flight is 409 idempotency_in_flight. A replay whose item has since been deleted answers 410 idempotency_target_gone. CLI: `pad item create --op-key`, a fresh key per invocation by default, and one same-key retry of a 5xx or transport error, only when the server advertises idempotency_keys. MCP: pad_item create accepts op_key on both transports (remote maps it into the body; stdio passes --op-key). ToolSurfaceVersion 0.90 -> 0.91 (additive). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
7ec0191d45 |
feat(items): idempotency keys for patch writes (TASK-3585) (#2062)
* feat(items): idempotency keys for patch writes; an unconfirmed patch resolves only through its own tab (TASK-3585) A patch may carry an idempotency key (Idempotency-Key header, or op_key). The key is claimed per (workspace, user, key) before any write-path guard, with a lease, and its answer is recorded inside the write's own transaction, guarded by the claim token: done holds exactly when the write committed, so an expired claim committed nothing and may be taken over. A retry of the same request replays the answer (Idempotent-Replayed); a key reused for another request is 422; a live claim is 409 in flight; a refused write releases the key. When an open editor received the patch and never confirmed it, the key records the patch id and a boot-scoped reference to that connection. A retry goes only to that connection, whose tab remembers the patches it applied and re-acks one it is sent again; with it gone the answer is patch_outcome_unknown with the diff since, and nothing is re-planned, since an insert-shaped edit would keep its anchor elsewhere. The CLI sends a key per invocation and retries once with it on a transport error or 5xx. MCP 0.90 adds op_key and three codes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli): retry a keyed write only when the server advertises idempotency_keys (TASK-3585) An older server ignores op_key, so the CLI's single same-key retry of a 5xx could apply a patch twice against a server that serves patch but not keys. The retry now requires the idempotency_keys capability. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
78729db1a9 |
feat(items): pad item patch --undo, anchored on each edit's context (PLAN-3578 step 3) (#2058)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
0cc033edd8 |
feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.
expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).
CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
bc329fb7f4 |
feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.
- server: `replace` request member; pad_content_writes_unguarded_total
{door,kind}; warnings.unguarded_content_write on an agent door's unguarded
content write; a seq update_conflict on a content write (applier route
included) carries content_write, changed_at, changed_by_kind and a
"do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
on both transports; the update description leads with read, edit, write
with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
mcp.md, deployment.md, README, update help, plan and onboard playbooks.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
c0e66c6700 |
fix(models): the fallback terminal lists cover every "won't fix" spelling (BUG-3546) (#2033)
A collection whose status option is the hyphenated wont-fix, with no terminal_options declared, never closed those items: the fallback lists spelled it only wontfix / won't fix. Seen in the APM workspace, where plans needed --force to close over wont-fix children. DefaultTerminalStatuses and NegativeTerminals now list wontfix, wont-fix, won't fix and won't-fix: exact spellings rather than a normalizing compare, because the list also feeds SQL IN (...) filters, which match exactly. The web mirrors (types/index.ts) carry the same values, and TestTerminalListsMatchWeb parses them and fails on any drift; until now a comment asked for that. The CLI and board colour the new spellings as negative terminals. Pad's own web Bug Tracker template, which uses wont-fix, now declares terminal_options [fixed, wont-fix] and abandoned_options [wont-fix]. childProgress.test.ts pinned DEFAULT_ABANDONED_STATUSES to the old literal; its expected value now includes the new spellings. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
a55d15dbea |
feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391) (#2014)
* feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391) Companion to Dependabot's cooldown (TASK-1390), which holds a release for 7 days before PROPOSING it but cannot see a version added by hand. cmd/depages dates every Go module (go.sum), npm package (web/package-lock.json, registry packages) and action pin (`uses: owner/repo@<sha>`) a change adds, from the Go module proxy, the npm packument and the GitHub commits API, and fails when one is younger than --days and not allowed in .github/dep-age-allow.txt (which requires a reason per line). --base <ref> checks only what is new since the ref; a dependency that cannot be dated is a warning, not a failure. CI runs it on pull requests as "Dependency age (advisory)", against the merge commit's first parent. NOT a required check (lead ruling): red means wait or allow with a reason. Rewritten rather than promoted: the /tmp scanner the task names is gone. Its edge cases are kept (pseudo-versions included, scoped npm names, sub-actions sharing a repo SHA, the full packument for per-version times). A real run against main as of 2026-09-01 dated 185 dependencies and flagged one: golang.org/x/net v0.60.0, #1922's security bump, the case the allowlist is for. * fix(ci): depages covers aliases, composite actions and lockfile versions; the token stays on GitHub (TASK-1391) Codex round 1: - An npm alias is checked as the package it installs (the entry's name). - A lockfile older than v2 is refused instead of read as empty. - Every YAML file under .github is scanned, so a local composite action's uses: pins count too. docker:// images are out of scope and documented. - GH_TOKEN is sent only to api.github.com, whatever DEPAGES_GITHUB_API says. - The base-diff test now covers a go.sum module, a bumped npm package and a composite action's new pin in one change. * fix(ci): depages scans action.yml anywhere in the repo; its README tables the coverage (TASK-1391) Codex round 2: a local composite action can live outside .github. Every action.yml / action.yaml in the repository is scanned too (node_modules and .git skipped). Not changed, with the reasons in the README's coverage table: docker:// images (no SHA, no publish time) and dating a pin by its commit (the threat is fresh code, and an old commit is old code). * feat(ci): depages prints the allowlist line to paste for each young version (TASK-1391) Lead, on the first real run flagging #1922's x/net security bump: the job's output now says the check is advisory and prints, per flagged version, the exact .github/dep-age-allow.txt line with a <reason> placeholder. * test(models): classify cmd/depages' getJSON in the raw-decode guard (TASK-1391) CI: TestReservedFieldDecodeSitesAreClassified found cmd/depages/main.go's getJSON decoding into an interface. It decodes registry and GitHub API answers into their own structs, never a collection definition; allow-listed with that reason, and the receipt count goes 26 -> 27. |
||
|
|
47ca9e521a |
fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) Lead ruling: a repeated or empty field key or select option is a broken schema, and the web renders fields and options as lists keyed by them, so one repeat blanked the view (BUG-3538). PR A made the web render such a schema; this stops new ones being written. - Collection create and update refuse, with 400 validation_error naming it, a schema in which a field has no key, two fields share a key, or a select field lists an empty option or the same option twice (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions). WRITE check only: a stored schema with a repeat still reads, and an update that sends no schema still applies. Live census on :7777: 0 of 213 collections affected. - Dynamic Client Registration registers a repeated redirect URI once (it is unauthenticated, and the connected-apps page lists URIs keyed by value). - ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and ["1"] apart while both scanned to "1". - Pins that convention commands and surfaces are deduped on read: they are already (normalizeItemConventionMetadata); the census verdict that said otherwise was wrong. Not done, deliberately: refusing repeated app-manifest event names or companion field keys. appmanifest.Parse also re-reads a STORED manifest on upgrade, so a new refusal there would fail a read of an installed app; PR A made both web lists render repeats. ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400. * fix(server): hold companion and template schemas to the key/option rule (TASK-3539) Codex round 1: writers that bypass the collection handlers could still store a schema that repeats or empties a key or option. - An app's companion collection schemas are checked in the install/upgrade preview, on the NEW manifest beside the reserved-key check, and refused 422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an installed app's stored manifest. - Every template and default collection schema the repo ships is checked by a test (they seed through the store, not the handlers). - The legacy-schema test also reads and creates items in a collection stored with a repeat. Workspace and artifact import, and the library option widening (which already dedupes), are unchanged: refuse on write only. * docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539) Codex round 2: the current-version labels said v0.83, the history began at v0.82. * docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539) Codex round 3: the intro still said v0.81, stale since before this change. * fix(server): the option check covers select and multi_select only (TASK-3539) Codex round 4: it refused empty or repeated options on every field type, but only a select or multi_select's options are a rendered list. Another type can carry a legacy options list (the old field DSL put a third part there), and refusing it would block an unrelated schema save. Tested both ways. * test(collections): the template schema check is independent of the validator (TASK-3539) Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was circular. It now uses its own check. Also records, at the write, why the library option widening is not held to the validator: it only appends missing words (never a repeat), and refusing would fail an item create over a legacy schema. |
||
|
|
560ab27b33 |
feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of: - parent progress (GetItemProgress / GetAllItemProgress SQL, and childProgressState for the restricted recompute and the dashboard); - the open-children close guard (all four doors share runOpenChildrenGuard); - a default report (resolveReportCollections: !tracksWork replaces IsSystem); - the dashboard's active items, stalled / overdue / orphaned / blocked / content attention, suggested_next and by_role (store and visible recount), and so next / ready / stale and the bootstrap dashboard; - standup and changelog completed work and in-progress, server and CLI; - the attention decision set's eligibility; - the admin items_open count. Sidebar and summary totals, item lists, search, links, the graph, stars and reminders keep counting every item. A workspace bundle written before the key existed imports its system collections as reference, as the migration marked them. Guards: a three-phase behavioural fixture (work / reference / deleted) over every excluded surface, each proven sensitive; and a name-based classification of every lifecycle-helper call site in Go. MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
f5a00d5b13 |
feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1) The switch, with no counting change yet (PR 2 server, PR 3 web). CollectionSettings.tracks_work: false makes a collection REFERENCE material; absent means work, today's behaviour, so old rows, old writers and a client/server skew are unchanged. One Go predicate (models.CollectionTracksWork) and one TS twin (collectionTracksWork). - New collections: the creator's value, else system collections are reference and others are work iff their done field can finish. New template Docs collections are created as reference (Dave). - Migration 135 / PG 107: existing Conventions and Playbooks become reference; everything else, Docs included, stays work. - Settings are written wholesale, so an update whose settings omit the key KEEPS the stored value (CarryTracksWork), for every writer. - A typed top-level `tracks_work` on POST/PATCH sets just that key server-side. CLI: --tracks-work=true|false (a string flag, so false survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A non-boolean is refused 400; the CLI checks the response, since an older server ignores the member. - Web: the Display tab's "Track as work" toggle; the editor now starts its settings write from the STORED settings (buildCollectionSettings), so keys it does not render (content_template, tracks_work) survive. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex) The update now computes the carried settings from the row it re-read under the write lock, so a concurrent settings write cannot be reverted by a carry read before the lock. The SQLite migration evaluates json_type/json_extract only inside a CASE guarded by json_valid, so malformed settings on a system row cannot abort it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(models): a collection whose options the default terminals close is work (PLAN-3535) DefaultTracksWork required declared terminal_options, but every lifecycle reader falls back to DefaultTerminalStatuses when a done field declares none, so a `status: open, done` collection finishes items and was being created as reference. Work now means one of the done field's options is terminal by the same resolver the readers use. Found by PR 2's store tests, whose fixture collection is exactly that shape. Also corrects api.md's MCP version (0.81). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
4226e8c5ef | feat(activity): the collection and actor filters run on the server, and the web/CLI badge says which (TASK-2219) (#1948) | ||
|
|
136fac6609 |
feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896) (#1923)
* feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896)
A bundle import that hits a DATA error after pad-export.json was imported
keeps the partial workspace (the rule is now written in docs/api.md:
transport failure, validation reject and pre-creation refusal roll back;
only a post-mint data error keeps). The 400 that said "kept" was the only
record, and a client may never read it.
- workspace_import_status (side table, SQLite 131 / PG 104): instance-
local, never exported, purged with the workspace.
- The keep path writes {status: partial, note}. The note is SANITIZED:
a fixed category and a correlation id (imp-...); the raw error goes to
the server log under that id.
- GET /workspaces/{slug} carries import_status (omitempty); the note only
for owners and instance admins. The list carries the status only.
- PATCH /workspaces/{slug} {"clear_import_status": true} clears it
(owner-only, like every workspace PATCH).
- pad workspace list marks it "partly imported" (text only; the JSON
mirrors the MCP workspace-list resource).
- Web: a banner on the workspace landing page and settings, with the
reason for owners, "Keep it" (clear) and "Delete workspace".
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(import): a rollback that cannot remove the workspace marks it too; the docs name what keeps (TASK-896, codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(import): only members who are admins read the note; the banner's note fetch is fenced on identity (TASK-896, codex r2)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
ee288cbebd |
fix: item lists refuse a filter key that names no field (BUG-3480) (#1880)
* fix: item lists refuse a filter key that names no field (BUG-3480) The item list endpoints read every query parameter they did not know as a field filter, so a parameter the server never had (the web Library page's `all`, WebMCP's `collection`) answered 200 with an empty list. A filter key must now be declared by a schema in scope (the collection's, or any visible collection's on the workspace-wide list), or be stored by an item in scope, which is honoured and named in the X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI). Otherwise 400 validation_error, "invalid list filter ...". A key that is not a field key is refused too; the store used to drop it and answer the unfiltered list. WebMCP pad_item:list now lists a collection through its path. ToolSurfaceVersion 0.73. Co-Authored-By: Claude <noreply@anthropic.com> * test: type the listByCollection mock so svelte-check can index its calls (BUG-3480) --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
7b68125a30 |
feat: schema edits say what they do to items; removing an option is never a rename (TASK-2188, TASK-2187) (#1877)
* feat: schema edits say what they do to items, and removing an option is never a rename (TASK-2188, TASK-2187) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: import the field-usage type; keep the collection tool description inside the tools/list budget (TASK-2188) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: the counts landing no longer close an open schema-edit confirm; pin that an option added after a removal is no rename (TASK-2188 codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the BUG-2129 migration-race spec confirms the rename before its PATCH (TASK-2188) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
15c9f76366 |
feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)
* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189) codex r1. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
deec62235e |
feat(decision): executable conventions, the server half (TASK-3119 U1a) (#1859)
* wip(decision): conventions set (TASK-3119 U1a) * chore(eval): executable-conventions eval through the production question path (TASK-3119 U0) Measures, before any chip exists, how often the decision provider says a hand-labelled item breaks a convention at the 0.9 threshold: per convention precision and recall (all cases and real-only), the false 'Breaks' rate of a behaviour convention, and the calls and tokens spent. State is decision.BuildItemState, the question a decision.Noul worded as the U1 contract says, the model decision.DefaultModel. The hand labels were posted to TASK-3119 before any score existed; the trail comment carrying them is dropped from every state, and the dry run refuses a state that still carries them. Reads TYPESAFE_API_KEY only. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(eval): -links and -only for the conventions eval (TASK-3119 U1a) -links builds each state as the conventions set does, with the item's links (from the dump: the parent link only; blocking links are not in pad item show's JSON). -only runs one convention. Used for the lead's U1a gate: CONVE-1286 rerun with links. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(decision): liveness before EACH split call (TASK-3119 U1a, codex r1) A split set makes several provider calls; the liveness check ran once before them, so an item or workspace deleted between calls was still sent in the later ones. It now runs immediately before every call. A test deletes the item from inside the first call and the second is not sent; checking only once fails it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(decision): decision_check has no default; allow-list the eval's decode (TASK-3119 U1a) A default "on" was written into every new convention: imports gained a decision_check field (artifact import parity) and an activated built-in no longer matched its seed (TASK-3462 would read every one as diverged). Absent already reads as on, so the field carries no default, in the template and both migrations. The conventions eval's readJSON is a raw decode the reserved-field guard counts; allow-listed with its reason, like the attention harness. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c17865462f |
feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)
U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.
U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.
* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)
* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)
* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)
* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)
* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)
* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
|
||
|
|
4fdc6ed625 |
feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89). - Placement: a first-time card on /console's empty state (Start a new project) and in the workspace launchpad's step 2 (Set up your workspace with /pad onboard); each vanishes with its screen. The stable way back is a Tutorials entry in the user menu's Resources block, right after Docs. - Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list (models.UIDismissalKeys), read-modify-written under the users row lock, so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only. - Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it same-origin at /tutorials, with posters proxied at /tutorials/posters/ {slug} from www.getpad.dev only. The page CSP gains frame-src for youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load player and chapters; transcripts link to getpad.dev. - Self-hosted: both routes 404, no outbound request, no CSP change; every surface links to getpad.dev/learn. Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
1d94d038a4 |
fix: the first-run launchpad follows an onboarding agent live (BUG-3447) (#1837)
* test: collection create publishes an event; the launchpad and connect banner follow agent writes live (BUG-3447) * fix(web,server): collection create publishes collection_updated; the launchpad and connect banner follow agent writes live (BUG-3447) * test(web): a debounce armed in one workspace does not fire into the next (BUG-3447, codex r1) * fix(web): the live reload and the banner re-check fire only for the workspace they were armed in (BUG-3447, codex r1) * test(web): an event during the first load or check still triggers the live refresh (BUG-3447, codex r2) * fix(web): an event before the first load or check commits still triggers the live refresh (BUG-3447, codex r2) * test(web): a failed quiet re-check that overtook the first check leaves the banner showing (BUG-3447, codex r3) * fix(web): a failed quiet re-check from the unknown state falls back to showing the banner (BUG-3447, codex r3) * test: an agent's collection is agent activity (dashboard, banner, e2e incl. the Agent-connected step) (BUG-3447, lead) * fix: a collection records its source; an agent-created collection is agent activity for the launchpad and the banner (BUG-3447, lead) * test(e2e): the launchpad follows an onboarding agent from outside the tab, red on main for each outcome (BUG-3447, lead) * test(store): collection provenance survives export/import; the census sees the new column (BUG-3447, codex r5) * fix(store): collection provenance travels in the workspace bundle, validated on import; census records the column (BUG-3447, codex r5) * test(store): an unrecognised bundle source is stored as '' (BUG-3447) |
||
|
|
a91fcce7f9 |
fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446) The blank template seeds Conventions with trigger [always] / scope [all] (Playbooks [manual] / [all]) and nothing widened them, so all 18 library conventions were refused on every door: CLI and MCP library activate, the web Activate button, and the onboard playbook's literal B3 create. A create in a system collection declaring the convention or playbook artifact kind now adds a missing trigger/scope value to the select's options when the LIBRARY itself uses that word (collections.LibraryOptionVocabulary, computed from the library). The write goes through UpdateCollection with its concurrency token, ahead of validation, and is reported in warnings.options_added (CLI: stderr) and in the created activity's metadata, which the item timeline renders. Any other value is refused as before. Editors may trigger it (lead ruling). The onboard playbook's B3 now says how an owner adds an invented trigger. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive) Lead ruling: a create in a system conventions/playbooks collection now adds a library trigger/scope word instead of refusing it, and its result gains an omitempty warnings.options_added. No tool name, enum or param moved; tools/list golden bytes are unchanged, initialize moves for the version and the instructions note. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
2f3cf402b1 |
fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437) (#1832)
* fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437) TimelineCommentCard showed 'commented on update' whenever the comment had an activity_id, and every comment has one: a comment or reply written on its own links to its own 'commented' activity, so a plain 'pad item comment' read as if it were attached to a change. The store's comment list join (the one that already reads the agent name off the linked activity) now also reads its action into Comment.LinkedActivityAction (json:"-"), and the timeline sets an additive, omitempty TimelineEntry.comment_on_update when that action is anything but 'commented'. The card keys the label on it. The comments API, the CLI and MCP keep their shape (the new field is never serialized outside the timeline, which MCP does not expose). Tests: the server test writes a standalone comment, a reply and an update carrying a comment through the real routes (web, CLI and MCP share the comments route) and checks the flag, with the old rule and an always-false mutant each red; the web test pins the card and the entry list's pass-through, each red with its half reverted. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(timeline): a comment that arrives live keeps 'commented on update' (BUG-3437) The label comes from the server-set comment_on_update. Comments that arrive while the timeline is open come through the SSE refresh, which re-reads the timeline endpoint (nothing builds a comment entry client-side), so they carry the flag. This pins it: red if the entry list stops passing the flag. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
8c8deeeb89 |
fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)
* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)
* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)
* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)
* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)
* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)
* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)
* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)
* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)
* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
|
||
|
|
1082c4aa16 |
fix(store): reads exclude soft-deleted collections' items (BUG-3425) (#1820)
* test(store): ListItems, its search and the graph edges must exclude soft-deleted collections (BUG-3425, red) * test(server): graph nodes exclude soft-deleted collections; the account export keeps them (BUG-3425) * fix(store): ListItems, its search and the graph exclude soft-deleted collections; the account export opts back in (BUG-3425) * test(store): role counts exclude soft-deleted collections; search honours the opt-out (BUG-3425, codex r1, red) * fix(store): role counts exclude soft-deleted collections, as the role lanes do (BUG-3425, codex r1) * docs(store): doneFiltersForWorkspace's comment no longer names GetRoleBreakdown as counting deleted collections (BUG-3425, codex r2) * fix(store): keep SQL comments out of the role-count query (an apostrophe broke PG rebind); BUG-3333 expects a deleted collection's doc unlisted (BUG-3425) |
||
|
|
f6938b33a5 |
feat(webhooks): owner webhooks keep per-endpoint delivery state; 408/425/429 are retried (TASK-3409) (#1809)
* fix(webhooks): 408, 425 and 429 are transient on the owner and app delivery paths, through one shared RetryableStatus (TASK-3409) * feat(webhooks): owner hooks keep per-endpoint delivery state: a retry goes only to endpoints still owed, a 4xx is final for the event, and endpoints still owed when retention gives up are counted as dropped (GET /webhooks, pad webhook list) (TASK-3409) * fix(webhooks): an owner endpoint deactivated while an event is still owed to it is recorded and counted as dropped, not silently skipped (TASK-3409, codex r1) * fix(store): the automatic deactivation settles the endpoint's owed deliveries as dropped and counts them, in its own transaction (TASK-3409, codex r2) |
||
|
|
db8fb3aabf |
fix(store,server): an item write re-checks its fields under the seq lock when the schema it was validated against has moved (BUG-3407) (#1806)
* fix(store): an item write re-validates the keys it sets against the schema read under the workspace seq lock, so a schema change between the handler's validation and the lock no longer lets a forbidden value in (BUG-3407) * fix(store): the fenced app write takes the workspace seq lock before reading the companion schema it validates against (BUG-3407, the app path) * fix(store): a move re-validates its destination fields under the seq lock too (BUG-3407, codex r1) * fix(store): the re-check judges reserved system metadata by no schema, as the handlers do, so a grandfathered declaration refuses nothing (BUG-3407, codex r2) * refactor(store): drop a redundant reserved strip; UnmarshalItemFieldSchema already makes it, and the comment says why that decoder (BUG-3407) * fix(apps): BUG-3407 becomes an optimistic schema compare: handlers pass the exact schema bytes they validated against, the store re-checks the set keys only when the schema under its lock differs, refusals answer 400; a census holds every field-writing handler to it (lead ruling, day 86) * fix(store): with no ValidatedSchema the store does not decode the fields blob at all, so its own guards and contract are untouched for such callers (BUG-3407) * fix(store): a stored fields blob that is not an object carries nothing, so a full write repairing it is compared as all-set rather than refused (BUG-3407, BUG-3163 repair path) * fix(store,server): a supplied schema takes the seq lock and its movement is decided under the locks; the content-route and collab-snapshot paths answer the schema refusal with 400 (BUG-3407, codex r3) |
||
|
|
41be1bf113 |
feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) Items (the creating app), comments and version rows carry via_app (the install id) and via_app_name (the bot's name, else the origin; kept after uninstall) on the web API, the MCP get / list summary / history summary projections, and the TS types, with WorkspaceApp naming the members apps array. ToolSurface 0.65, additive. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs: README tool catalog at v0.65 (TASK-3413) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
bec344822a |
feat(apps): app upgrade: staged preview with a classed diff, one-transaction confirm that re-derives it (TASK-3397, SPEC-6 U8b2) (#1777)
* docs(store): the barrier's early state refusal is load-bearing for the uninstall lock order (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): U8b2 upgrade diff: classes, release of removed companions, the additive-field rule (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): U8b2 upgrade preview and confirm: staging with upgrade_of, diff in the preview, one-tx apply that re-derives and compares (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b2 upgrade end to end; null and absent JSON are the same declaration (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b2 upgrade lock order against a fenced write, moved-install refusals, upgrade records never cross the install route (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b2 codex round 1: refuse drifted companions, bump the epoch on restrictive upgrades, check existing data and the done field for additive fields, union duplicate event names (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b2 codex round 2: archived items' values count for the additive-field check (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b2 registers its two schema DEFINITION decodes with the reserved-field guard; NUL baseline for upgrade_of (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(apps): drop the unused buildAppPreview wrapper (lint) (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
213ecc0fe3 |
feat(apps): app install lifecycle: two-phase disable and rotate, re-enable, uninstall with the tombstone (TASK-3397, SPEC-6 U8c) (#1775)
* feat(apps): app install lifecycle: two-phase disable and rotate, re-enable, uninstall with the tombstone (TASK-3397, U8c)
Phase 1 (BeginInstallTeardown) holds the install row FOR UPDATE, moves the
install to disabling or uninstalling, bumps auth_epoch and revokes every
client grant; the delivery drain is a TODO(U10) no-op; phase 2 re-takes the
row and requires the phase-1 state, so a repeated call resumes. Rotate mints
a new install code; re-enable never moves the epoch back. UninstallAppTx is
one child-first transaction: the client (connections by request_id first),
the bot's membership and credentials, the bot DISABLED but kept for
attribution (unlike the account-deletion purge), and the install row kept as
the tombstone. Owner doors: POST .../apps/{installID}/disable|enable|rotate|uninstall.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(apps): U8c codex round 1: rotate bumps the epoch again, teardown kills unconsumed install codes, uninstall takes the bot rows first (TASK-3397)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(apps): U8c fence test lives in internal/store, the one package besides appstore that may open a fence; gofmt (TASK-3397)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(apps): U8c issuance racing uninstall cannot deadlock, red if the barrier lock-reads the user before its state check (TASK-3397)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
52dc41b96a |
feat(apps): app install provisioning, install codes and redeem (TASK-3397, SPEC-6 U8b) (#1772)
* feat(apps): app install provisioning, install codes and redeem (TASK-3397, U8b) Confirm re-normalizes the staged pack, compares it with the reviewed preview, and provisions in ONE store transaction that re-checks every state-dependent fact: the install row, companion collections (via_app), the bot (editor/viewer, collection_access specific over the companions), the install client, the artifacts as owner drafts stamped with their pack and digests, a one-time install code, and the pending record's deletion. A refusal names the artifact or collection, the field and the holder, and writes nothing. Redeem is mounted outside the auth stack with a 4 KB whole-body cap and per-address and per-install limiters; it consumes the code and rotates the client secret. Owners can issue a new code to recover a lost redeem. * fix(apps): U8b codex round 1: re-check the destination schema in the provisioning tx, a dead install code never charges the install limiter, a reserved companion slug is refused (TASK-3397) * refactor(apps): U8b provisioning re-derives the normalization inside its transaction under locks (TASK-3397) Replaces the per-fact re-checks (codex rounds 1-2 kept finding facts the list missed) with a re-derivation: ProvisionAppInstall takes the pending row, the workspace lock, then on Postgres FOR SHARE on every collection row and the owner's membership row, re-runs the preview normalization on the transaction through the caller's derive function, locks the relation targets it resolved, and derives again; the caller compares each pass with the reviewed digests. SQLite is covered by BEGIN IMMEDIATE. The normalization path takes a Queryer (relation passes, unique check, slug probe, kind lookup); the pool versions delegate to it. Parity is pinned over the 8 importer fixtures plus a relation-bearing case captured before the refactor, the derivation is proven write-free under the write-capture harness, and the lock table is pinned on both dialects. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b codex round 3: compare the reviewed changes list, lock the caller's visibility rows, take items before the membership (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(apps): U8b lock table matches the round 3 order (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * refactor(apps): U8b locks break dependency cycles, not every read (TASK-3397) Lead ruling, day 86, superseding "lock every read": the visibility locks added in round 3 created deadlock cycles with member removal and account deletion and could never cover an inserted grant or a cached role (codex round 4). Provisioning now takes the pending row, the workspace lock and FOR SHARE on the workspace's collection rows, reads the owner role plainly, and derives once. The serializability argument for each raced writer class is in the lock comment. The two deadlock scenarios stay as tests. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b codex round 5: lock the confirming owner's users row first, account deletion is a real cycle (TASK-3397) Account deletion reads the bot and membership provisioning writes and deletes the owner provisioning read, and its FK cascade from users reaches the pending row. Provisioning now takes the owner's users row FOR SHARE before the pending row; deletion's first statement locks the same row, so the two serialize whole in either order. Tested through the real deletion in both orders. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b account-deletion cycle test uses the canonical owner (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): U8b adoption re-stamps via_app, only from an uninstalled install (TASK-3397) Lead ruling, day 86 (from the #1771 review): companions are via_app = install, so an adopted companion is re-stamped to the new install inside the provisioning transaction. Any holder that is not uninstalled is refused (app_already_installed, naming the state). Lock 3 becomes FOR NO KEY UPDATE so the re-stamp never upgrades a share lock. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b redeem stays outside the app API token router (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): bot lookups key on app_installs.bot_user_id, the address as the agreement check (TASK-3397) The U5/U8b agreement: whichever landed second switches the issuance barrier, AppPrincipalForInstall and the members list from the bot's address to the bot_user_id provisioning sets, keeping the address as an agreement check. An install without bot_user_id (made before provisioning) falls back to the address. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(apps): drop a dead assignment in diffChanges (lint) (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |