mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
fc9ceae92f8eca26edfa2e28dc35bb5aa3ee1e7f
168 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fc9ceae92f |
fix(api): children, starred and shared-collection lists are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) (#2153)
* fix(api): the children list, the starred list and a shared collection are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) Each returned every row with its body: the children of an item, the caller's starred items, and every item of a shared collection to an anonymous reader. They now take the BUG-3704 contract: limit defaults to and is capped at 1000, offset skips rows, and X-Pad-Has-More / X-Pad-Next-Offset carry the paging state (the share payload, a JSON object, also says has_more and next_offset in the body). Each order ends on the id, so offset pages neither repeat nor skip rows that tie. Children: store.ListChildItems applies the caller's visibility in the query, so the window and has-more count only visible children, and the has_children probe reads one visible grandchild instead of every grandchild with its body. Starred: read without bodies, filtered for visibility and terminal status (which stays in Go), windowed, and bodies are loaded only for the page (store.ItemBodies). Share link: ListItems takes the window. Callers walk every page: the web children and starred lists by offset until a short page (request() exposes no headers; rows de-duplicated by id), the web share page by next_offset (merging items and attachment refs), including the password retry, and pad item starred by the server's next-offset. Each walk fails past 50 pages rather than return a partial list. MCP pad_item action=starred never pages (lead ruling, as BUG-3704 for list): both transports ask for one row past the 300 cap (remote ?limit=301, stdio the new `pad item starred --limit`) and say has_more with the truncation note. ToolSurfaceVersion 0.99. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(api): an offset past the end of the starred and children lists is [] (BUG-3789, BUG-3790; lead check) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
ec5b60679a |
fix(auth): an OAuth sign-in without a provider subject is refused, bound or not (BUG-3797) [deploy AFTER pad-cloud #81] (#2152)
* fix(auth): an OAuth sign-in without a provider subject is refused, bound or not (BUG-3797) [deploy AFTER pad-cloud #81] BUG-3671 refused a subject-less sign-in only on an account with a subject already bound for that provider. Every account not yet bound, which is all of them until their first subject-bearing sign-in, still signed in on its email alone, a new address still made an account, and a never-verified registrant could still be claimed. oauth-login now refuses an empty subject before any account lookup, with 400 oauth_subject_required and an audit row (reason subject_missing). The bound-only check is unreachable and removed. oauth-link keeps the subject optional. Deploy order: pad-cloud first (#80 plus #81, which sends a subject on every sign-in and refuses a provider answer without one), then this. The reverse refuses every social sign-in from a pad-cloud that sends no subject. Test fixtures that signed in without a subject now send one, as pad-cloud does. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(auth): postOAuthLogin sends exactly what it is given; every sign-in fixture names its subject (BUG-3797, Codex r1 Low) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(auth): postOAuthLogin's comment describes what it does (BUG-3797, Codex r2 Low) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
9bd191048d |
fix(api): /changes is bounded at 1000 and continues by a cursor on the write order (BUG-3791) (#2151)
* fix(api): /changes is bounded at 1000 and continues by a cursor on the write order, so sync can page without missing a change (BUG-3791)
GET /workspaces/{ws}/changes?since= returned every item modified since
with its body, unbounded, plus a second unbounded query for deleted ids.
It now returns at most `limit` items (missing, invalid or above it: 1000,
as BUG-3704 ruled for the item lists), and when more remain says
has_more with an opaque next_cursor.
The cursor is a position in (seq, id) order, not an offset and not
updated_at. Every write that moves updated_at also moves seq, under the
workspace seq lock on Postgres and the single writer on SQLite, so a
change committed while a caller pages gets a seq past every committed
row and is returned by a later page of the same walk. Every page repeats
the first page's server_time, so the caller's next since is unchanged by
paging.
Visibility moves into the query: a window over hidden rows would let the
has-more probe count them and put a hidden row's position in the cursor.
The deleted list is derived from the page's archived rows (archiving
bumps seq and updated_at), replacing the second query.
The web client's api.changes.since walks every page (walkChanges): it
keeps each item's last row, derives deleted from it, and throws past 50
pages, which every caller already answers with a full refresh.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(api): a /changes cursor continues only the workspace it came from (BUG-3791, Codex r1 Medium)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
fdce754f20 |
fix(store): an item list with a search term keeps include_archived and sort, and the tag filter matches exactly (BUG-3686, BUG-3706) (#2147)
BUG-3686: any list with a non-blank search runs listItemsFTS, which
hard-coded i.deleted_at IS NULL in both dialect branches and always
ordered by FTS rank, so include_archived and sort were silently ignored
on /items?search= and the collection item list. (Its dropped OFFSET and
unstable ties were BUG-3704's, fixed in #2143.) The archived clause now
follows params.IncludeArchived as ListItems does, and an explicit sort
goes through buildItemSort (which ends on i.id); relevance plus i.id
stays the default. Only the HTTP list parser sets Search, so no in-app
caller changes order.
BUG-3706: SQLite's JSONArrayContains was `tags LIKE '%"<tag>"%'`, so %
and _ in a tag were wildcards, case was ignored, and a tag holding a
quote never matched (it is stored escaped). Postgres built the JSON
argument by hand, so a quote made invalid JSON (ListItems errored: a
500) and `a","b` asked for two elements. SQLite now matches a json_each
element of type text exactly, on a valid JSON array only (a malformed
row matches nothing instead of failing the query); Postgres json-encodes
the argument. Tags are stored as written, so SQLite's tag filter is now
case-sensitive, as Postgres always was and as `pad item list --tag`
already documents ("exact match"). The legacy documents list uses the
same dialect call and gets the same fix.
Reproduced red on main on both dialects (TestBUG3686_*, TestBUG3706_*;
on Postgres the quote case is a ListItems error). Per-defect mutants on
SQLite each fail only their own test: the main dialect.go fails the tag
test, dropping the sort branch fails the sort test, dropping the
include_archived switch fails the archived test. Full internal/store
green; internal/server -run Item|Tag|List|Search|Document|Census|Guard
green; the new tests pass on make test-pg.
docs/api.md states what ?search= keeps and how tag matches.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
|
||
|
|
d4e69a566b |
fix(auth): reset-password, register and bootstrap refuse a cross-site form, as login does (BUG-3680) (#2146)
* fix(auth): reset-password, register and bootstrap refuse a cross-site form, as login does (BUG-3680) /api/v1/auth/reset-password is CSRF-exempt and a completed reset sets a web session cookie. decodeJSON does not check the content type, so a cross-site text/plain form whose name=value serializes as JSON could complete a reset of the attacker's own account in the victim's browser and sign the victim into it (login CSRF). Only login and 2FA login-verify called refuseCrossSiteSignIn (BUG-3350). The population, by every handler that creates a session: - login, 2fa/login-verify: guarded already. - reset-password: unguarded. Guarded now. - register: CSRF-exempt without a session, and an anonymous signup signs the browser into the new account. Same class. Guarded now. - bootstrap: CSRF-exempt and signs the browser into the admin it creates. On a fresh instance reachable by loopback, or in open-bootstrap mode, a cross-site form could plant the attacker's admin. Guarded now. - oauth-login: requires the sidecar's cloud_secret, so not browser- reachable. Left as is. - rotateSessionsAfterCredentialChange callers are authenticated, CSRF-checked writes; the CLI approval session is not a browser cookie. Every legitimate client of the three already sends application/json with no Origin (CLI) or this server's origin (the SPA), and pad-cloud calls none of them. Tests, one per door: text/plain, urlencoded, foreign Origin and null Origin are refused, set no session cookie and change nothing (the reset token survives and then completes; no account is created; no admin is created). Same-origin and headless JSON still complete. Removing one door's guard fails exactly that door's test. TestBUG3350's login cases still pass. docs/api.md names the refusal for all five sign-in doors. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(auth): the cross-site sign-in guard keeps an allowed CORS origin, a remote token bootstrap and an admin's SPA register working (BUG-3680) Codex r2 on #2146 found the new tests did not exercise these legitimate callers, so a guard that broke them could pass. Each control is paired with a refusal on the same door, so it proves the guard's discrimination and not a door that ignores Origin. Mutants: dropping the CORS list fails the CORS test; requiring an Origin fails the remote token bootstrap. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
241dd316f9 |
fix(auth): a social sign-in without a provider subject is refused on an account with one bound (BUG-3671) [deploy BEFORE pad-cloud #80] (#2144)
* fix(auth): a social sign-in without a provider subject is refused on an account with one bound (BUG-3671) TASK-3351 binds a provider subject on the first subject-bearing sign-in of a linked provider and refuses a different one, but a sign-in carrying NO subject still reached the account on its email alone. Once a subject is bound for that provider, that is refused too (oauth_subject_mismatch, with TASK-3351's recovery route). A caller that never sends subjects binds none and keeps the email path, so this is inert until pad-cloud sends subjects (deploy this server first, then pad-cloud). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(api): oauth-login/link subjects and the BUG-3671 refusal Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(auth): a subject-less sign-in to a bound TOTP account gets no challenge; a subject-less link keeps the bound subject (BUG-3671, Codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
858bd8514e |
fix(api): item lists are bounded at 1000 with has-more paging, and filter-key probing is bounded (BUG-3704, BUG-3703) (#2143)
* fix(api): a list request's field filters are bounded, and the stored-key probe stops at the first absent key (BUG-3703) checkListFilterKeys asked StoredFieldKeys about every undeclared filter key, each a scan of the list's scope, and refused only afterwards: N absent keys cost N scans. The first absent key already decides the 400, so the probe stops there (one scan, however many keys), and a request carrying more than 50 field filters is refused before any store work. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(api): item lists are bounded at 1000 with has-more/next-offset headers, page stably, and page on ?search= too (BUG-3704) A missing, zero, negative or invalid limit on GET /items and the collection item list returned every matching item with its body. It now takes the maximum (1000, lead ruling: an old client loses rows only on a list past it), an explicit limit is clamped to it, and a limit+1 probe sets X-Pad-Has-More (always) and X-Pad-Next-Offset (when more). The list sort always ends on i.id, so an offset page cannot repeat or skip rows that tie, and the ?search= path applies the offset it used to drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli,mcp): item lists report the server's exact has-more; pad item list gains --offset; pad_item list says when it is truncated (BUG-3704) pad item list keeps its 200 default (TASK-2000) and replaces the len==limit guess with the server's X-Pad-Has-More, naming the next step (--offset N) on stderr in every format; --offset is new, matching item search and backlinks. pad project standup's in-progress section pages through next-offset (ListAllItems), since a report needs every row. pad_item list asks for one row past its limit and, when it comes back, trims it and sets has_more with a note; it never pages (lead ruling). An untruncated result is unchanged. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.98, pad_item list says has_more when truncated (BUG-3704) Additive on the v0.53 grounds; golden initialize bytes regenerated (the version and the instructions heading are the only moves). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): pages that need a whole collection page through it; WebMCP pad_item list is bounded and says has_more (BUG-3704) listAllByCollection walks a collection in pages of the server's 1000 cap. The library page's active-convention/playbook maps, both playbook pages' invocation_slug uniqueness lists and the conventions page use it, since each silently misbehaves on a short list. WebMCP pad_item list had no default at all; it now mirrors the Go catalog (50, max 300, one probe row, has_more + note when cut short). docs/api.md documents the bounded, paged list and the 50-filter cap. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(web): page mocks and identity-gate rows follow listAllByCollection (BUG-3704) The library, playbooks and conventions page tests mocked listByCollection, which those pages no longer call. The TASK-3097 identity gate pins library loadData (and its three fetch continuations, whose hash takes the 120 characters before the call) and the playbook editor's loadPlaybooks: re-reviewed, the checks still run after the one awaited call, so a result whose pages straddled an identity change is discarded. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(mcp,web): a fractional pad_item list limit takes whole rows (BUG-3704, Codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
1fe9a15037 |
fix(deploy): Kubernetes keeps attachments on a PVC, and uploads get a writable /tmp (BUG-3714) (#2141)
* fix(deploy): the Kubernetes manifest keeps attachments on a PVC, and gives uploads a writable /tmp (BUG-3714)
/data holds every attachment's bytes in PostgreSQL mode too, but the
manifest mounted it as an emptyDir, so replacing the pod (an upgrade, a
drain, an eviction) lost them all while their rows stayed. It is now
a ReadWriteOnce PersistentVolumeClaim (deploy/k8s/pvc.yaml); the single
replica and Recreate strategy suit RWO.
On the way: with readOnlyRootFilesystem and no writable /tmp, every
upload failed with a 500 ("create upload temp: open /tmp/...:
read-only file system"). /tmp is now an emptyDir.
docs/deployment.md: pvc.yaml in the apply order, the StorageClass
prerequisite, back up the volume as well as the database, and a tar
block that carries attachments from an emptyDir install to the PVC.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(deploy): the emptyDir-to-PVC migration block stops on a failed export and checks the restored count (BUG-3714, Codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(deploy): the migration recovery line ends with the count check (BUG-3714, Codex r2)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
59772ffc34 |
fix(members): a collection-restricted owner cannot grant beyond its scope through invitations or role changes (BUG-3634) (#2116)
* fix(members): a collection-restricted owner cannot grant beyond its scope through invitations or role changes (BUG-3634) A workspace owner restricted to specific collections could invite a second account (or any address). Accepting makes a member with collection_access 'all', who could read and write the hidden collections and then lift the first account's restriction. Promoting an unrestricted viewer gave write access over collections the owner could not see. - Invite: a restricted caller gets 403, since an invitation carries no scope (requireCallerCanSetCollectionAccess with "all"). - Role change: a restricted caller may change the role only of a member whose scope lies within its own, by the same helper. - Accept: AcceptWorkspaceInvitation re-reads the inviter's CURRENT scope in its transaction, so an invitation sent before this fix, or before the inviter was restricted, also grants nothing. It is refused, not narrowed: there is no scope to narrow to, and a silent downgrade would leave access nobody chose. All three doors answer inviter_scope_changed: the two accept routes with 403; register-with-code with 403 and the account rolled back when the code was the admission, or 201 with invitation_not_joined when registration is open. The invitation stays pending. The app-install door (bot membership with 'all') is the same class and is BUG-3768. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * docs(members): why an admin inviter is exempt from the accept-time scope check (BUG-3634, codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(members): the accept share-locks the inviter's membership while it reads its scope (BUG-3634, codex r2) Round 2: requireInviterScopeTx read the inviter's collection_access without a lock, so a restriction committing alongside an accept could land after the read and before the membership. On Postgres the read is now FOR SHARE, and the restriction's UPDATE waits for the accept or is seen by it. A Postgres test holds a pending restriction and requires the accept to wait on it and then refuse. SQLite's single writer already orders the two. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(members): a restricted owner's role change re-checks the target's scope under the write's row lock (BUG-3634, codex r3) Round 3: the handler checked the target's scope and then wrote the role in a separate step, so an unrestricted owner widening the target in between let the role apply across collections the caller cannot see. UpdateWorkspaceMemberRoleWithin carries the checked set into the transaction. At the point where the UPDATE locks the row anyway (FOR UPDATE on Postgres, so the lock order is unchanged), it requires the target to be still restricted to a subset of that set, or refuses with ErrMemberScopeChanged (409 member_scope_changed). Unrestricted callers pass nil and check nothing. Each of the two guards was removed in turn, and the test went red each time. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * docs(api): the role change's 409 member_scope_changed (BUG-3634, codex r4 nit) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
b6f2d41043 |
fix(server): an account export that times out or fails says so, instead of arriving short (BUG-3626) (#2109)
* fix(server): an account export that times out or hits a store error fails the download instead of arriving short, and says it is complete only when it is (BUG-3626) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(server): the account export deadline bounds a stalled store read, not just the gaps between workspaces (BUG-3626, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(server): the account export hooks live on the Server, not in package variables, so the race detector sees them ordered (BUG-3626, CI -race) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
559d9d1422 |
feat(server): the item brief carries only the decisions Pad would surface, and counts the rest (TASK-3618) (#2107)
The brief listed every typed-decision row for an item with no threshold filter. On 40 recent docapp items that came to 2210 rows, 2202 of them below the threshold at which any surface shows them, and all of them were pushed into `omitted` by the conventions that fill the budget first. - decision.surfacing is the one row test: the question is current and the Noul reaches the threshold. WorkspaceFlags (the dashboard) and the new Runner.SurfacedDecisions (the brief) both call it. - QuestionSet.Threshold carries each set's surfacing threshold from the existing named constants (AttentionThreshold, ConventionThreshold). - The brief adds decisions_below_threshold and decisions_stale_question counts. It raises stale_decision only for a surfaced answer, and it keeps a surfaced answer whose item has moved on (current=false), which WorkspaceFlags drops. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
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
|
||
|
|
d94a38978f |
feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612) (#2100)
* feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612)
The pieces of a move to Cloud existed (export a bundle, import it through
New Workspace) but nothing connected them, and nothing said whether the move
was complete: the import answered with the bare workspace and the CLI printed
fixed text. An attachment whose rehydrate failed was a logged skip under a 201.
- Server: the bundle import's 201 carries an additive import_summary
{in_bundle, imported}, counted for collections, items, comments and
attachments. in_bundle comes from the bundle's arrays and manifest;
imported from the new workspace's rows (store.CountWorkspaceContents),
with the export's own filters, never from the import loop's bookkeeping.
- CLI: pad workspace import prints both columns and marks any difference.
- Web: the import toast states the counts and turns into an error when they
differ. A guide at /console/move-to-cloud (export, sign in, import, check
the counts, reconnect agents, invite the team; it is a copy, and members
do not carry). Linked from settings General on self-host and from the
Import tab on Cloud.
- Docs: api.md, backup.md (its export section still described JSON), README.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(import): a count mismatch is named as a difference, not a shortfall; the guide's Cloud sign-up link is gated off in the mobile apps (TASK-3612)
Codex r1 (two P2s): an imported count can also EXCEED the bundle's, so
the CLI and the guide now say the counts differ rather than that
something fell short.
Lead ruling (day 92) on the TASK-3299 classifier: mcp.getpad.dev is
KEPT (a connection address), app.getpad.dev is GATED. The guide links to
the Cloud sign-up only off Cloud and outside a mobile app's web view; on
Cloud it names the session's own MCP address. A page test asserts the
link is absent in the native shell, against a browser control.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
fbba819eb8 |
feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.
- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
prints the server's body verbatim; the default markdown render lists the
item, parents with parsed acceptance criteria, conventions, playbooks,
dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
stdio, and the HTTP route with intent / role / budget on the query.
New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
cmdhelp, ChatGPT exclusion, compact description header, instructions.md
(cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
files regenerated.
Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
1204a31a73 |
feat(mcp): pad_item link-pr links a PR from supplied metadata, and unlink-pr removes it (TASK-3611) (#2098)
MCP had no PR-link action: the `field` description sent agents to a human, because `pad github link` reads the local branch and asks `gh`, and a field write of github_pr is refused (BUG-2696). An agent that opened a PR could not record it on the item. - `pad github link <ref> --pr-number N --pr-url U [--pr-title --pr-state --pr-branch --pr-repo]` links a PR the caller names. Nothing is fetched from GitHub, and it needs neither a checkout nor `gh`. Without the flags the command behaves as before. Under --format json, link and unlink answer with the item. - pad_item.action=link-pr / unlink-pr, on both transports. Remote PATCHes the typed github_pr / clear_github_pr members, and unlink-pr refuses an item with no link, as the CLI does. The catalog's `ref` is passed as the commands' item-ref positional. - github link / unlink leave the remote CLI-only table; github status stays. The BUG-2696 guard is re-aimed: these two must be routed, and no remaining hint may advertise the github_pr field write. ToolSurfaceVersion 0.95, additive (0.93 is #2096, 0.94 is #2097). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
bcb8d93516 |
feat(mcp): pad_item reminders lists an item's reminders and unremind disarms one (TASK-3611) (#2097)
An agent could arm a reminder and acknowledge a fired one over MCP, but
listing an item's reminders and disarming one were CLI-only, so an agent
that deferred work could not see or cancel what it armed without a
terminal. Two pad_item actions close that:
- reminders (ref): `pad item reminders`, GET /items/{ref}/reminders,
read-only;
- unremind (reminder_id): `pad item unremind`, DELETE /reminders/{id},
which needs edit permission on the item, as on the CLI.
Re-arm stays CLI-only.
ToolSurfaceVersion 0.94, additive (0.93 is #2096).
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
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
|
||
|
|
f2a1ca75e3 |
feat(mcp): structured and typed field values write over local stdio too (TASK-3611) (#2096)
pad_item create and update over the stdio transport refused a `fields` value that is an object or array (BUG-2850), because the CLI's --field key=value carries text; a number or boolean went through as text too, typed only if the schema said so (codex r1). The stdio door now passes every non-string `fields` value as one --fields-json object, a new flag on `pad item create` / `update` that keeps JSON types: the shape the remote door already sends. Strings still go as --field; the moved keys are dropped from --field, and the flag goes ahead of the `--` before the positionals. Any other action still refuses a structured value. --fields-json refuses null (as the MCP `fields` param does) and a key also set by --field or a dedicated flag. ToolSurfaceVersion 0.93, additive. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
6a29039f13 |
docs(mcp): changelog entries for ToolSurfaceVersion 0.91 and 0.92; no hard-coded current version (#2095)
docs/mcp.md said "currently 0.90" in two places, while #2065 and #2068 moved the constant to 0.92 without a changelog entry. The entries are added from version.go's own notes, and the "currently" figures (catalog, ToolSurfaceVersion, CmdhelpVersion) now point at the constants in internal/mcp/version.go, so they cannot go stale again. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
199fd208a8 |
fix(items): undo of an emptied line; a patch that changes nothing is refused (PLAN-3578 retro fixes 5+6) (#2089)
* fix(items): undo of an emptied line, and a patch that changes nothing is refused (PLAN-3578)
Retro CONVE-735 findings on #2058 and #2055.
Undo: an edit that deleted a line's only content recorded empty,
line-clipped context on both sides, so its inverse had no anchor and the
undo was refused as an empty anchor. ApplyWithContext now reaches across
the line breaks when the clipped context around an edit that wrote nothing
is empty. A patch that empties the WHOLE body still leaves nothing to
anchor on; its undo now says so and points at History instead of "old is
empty".
No-op: an edit whose old equals its new, or edits that cancel out, used to
answer success and advance seq with no version behind it, against "every
patch is a version". Both are refused 400, writing nothing: per edit up
front in the handler, and the combined case through bodypatch.ErrNoChange
on the no-room path.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(items): a no-op patch through an open editor is refused too (PLAN-3578)
Codex round 1 on
|
||
|
|
ccd915f1cc |
feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.
A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).
CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
a05197c795 |
feat(items): idempotency keys for cross-workspace copy and move (TASK-3585) (#2070)
POST .../items/{ref}/copy accepts op_key (body) or Idempotency-Key. The
key is claimed after every pre-write refusal; its answer is the WHOLE
first ItemCopyResult (the dropped-field and attachment warnings cannot be
recomputed), written inside the copy's transaction through the new
CrossWorkspaceCopyRequest.AfterWriteTx, so a retry replays the first copy
and copies nothing again. The response is built by the extracted
copyResult().
A retried MOVE finds its source already archived, which the shared copy
ladder refuses (409 archived) before it reads the body. replayArchivedSourceCopy
runs first, only when the source resolves archived and visible, exactly
when the ladder would refuse, and settles the request through the same
claim: the stored answer replays, a different request is 422, one in
flight is 409, and a key that never copied this item is released and gets
the ladder's own 409 archived. The ladder (shared with the preflight) is
unchanged.
CLI: `pad item copy --op-key` (fresh per invocation by default) keys the
mutating call only; CopyItem makes ONE keyed retry after an unknown
outcome, and only against a server that advertises idempotency_keys.
DR-13's other guards (dedicated client and transport, unreplayable body,
no redirects) are unchanged.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
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 |
||
|
|
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 |
||
|
|
bc3e23d55a |
feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)
A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)
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
|
||
|
|
3e8bf36f3d |
feat(collab): count failed op-log appends (TASK-3574) (#2051)
pad_collab_append_failures_total counts each batch of sync frames whose op-log append failed. The frames are still broadcast, so if the originating tab closes before its catch-up, a later SyncStep2 can be the only stored copy of the edit, and the item reads clean. The lead ruled this counter the trigger for the SyncStep2 coverage check: any nonzero week where that case could have occurred means build it. Wired as an optional AppendFailureObserver on the TASK-3501 observer, like OverflowObserver; the Room calls onAppendFail, set by the manager. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
db6f0cde1f |
fix(collab): a schema rebuild sets aside the WHOLE op-log, so set-aside edits can be replayed (TASK-3574 (4)) (#2047)
SetAsideAndClearOpLog kept only the content-bearing rows above the flush watermark. Those rows build on structs in the rows below it, so replayed alone they leave every edit pending and render nothing (measured: pending, ''), and no decoder could ever recover them from the set-aside rows; the base existed elsewhere only as markdown in items.content. When the log holds any unflushed content-bearing row, the rebuild now sets aside the WHOLE op-log, every row verbatim and in op-log order, the flushed base and the non-content-bearing frames included (a SyncStep2 can be the only copy of an edit, TASK-3574 (2); the rest replay as no-ops). A fully flushed log still sets aside nothing and the item stays clean. Not live while SCHEMA_VERSION is frozen; TASK-3246's decoder now gets a replayable quarantine. Tests: the store sets aside all four rows of [u1 flushed, u2, step2, u3] verbatim and in order; a flushed log sets aside nothing; a vitest shows those rows replay to 'alpha beta gamma' while the old suffix replays to ''. TEST CHANGE (the ruling replaces what it pinned): TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked's 'only unflushed content-bearing rows' subtest now asserts the whole log, in order. Counterfactual: with the old selection the store keeps 2 of 4 rows. docs/api.md updated. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
6fd59627bd |
fix(deploy): the Kubernetes manifests run one Pad replica with Recreate and no autoscaler, since collaboration needs a single writer (BUG-3573) (#2039)
deploy/k8s ran replicas: 2 with an HPA up to 10, but real-time collaboration keeps each item's room in the serving process (an in-process bus, op-log appends serialized per item): two pods hold independent rooms for one item, miss each other's edits and leave gaps in the op-log. A RollingUpdate overlapped the old and new writer too. deployment.yaml: replicas 1, strategy Recreate (the old pod stops first; a brief outage per upgrade). hpa.yaml removed. secret.yaml's encryption-key note no longer cites two replicas (the key must still be set: a pod's /data is not kept across restarts). docs/deployment.md says why, how to remove an HPA applied from an earlier version, and that scaling out needs a shared collab bus, which is not built. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
f17aaf26e2 |
fix(server): a workspace invite in the setup window answers setup_required, not 500 (BUG-3544) (#2034)
An invitation records who sent it. On a fresh install with no account yet,
requests run without auth, so pad workspace invite reached CreateInvitation
with no inviter, failed the inviter foreign key, and answered 500, which
reads as transient and retryable. handleInviteMember now refuses a request
with no resolved user: 409 setup_required with sign-in's own message
("This Pad instance must be initialized with pad auth setup") when no account
exists, else 401. Nothing is written.
ToolSurfaceVersion 0.85, a BEHAVIOR bump on the v0.58 / v0.45 grounds:
pad_workspace.action=invite over local stdio now gets that refusal where it
got server_error. docs/api.md notes it on the invite route.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
28872c2410 |
feat(admin): the MCP audit page names users and connections instead of truncated UUIDs (TASK-2255 C110) (#2032)
GET /admin/mcp-audit gains user_name (the name, else the email) and connection_name (the OAuth client's name, else the connection's label; a PAT's name), resolved in one IN-list query per kind per page and omitted when the id no longer resolves. The page shows them, falling back to the short id. docs/api.md documents the route. MCPAuditNames reads oauth_refresh_tokens and oauth_connections, so it is classified in the TASK-3399 reader census as censusPerGrant: it is addressed by request ids already present in audit rows and never lists a user's chains. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
9a7b38ed12 |
build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034) (#2031)
* build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034)
docker-compose.yml built this checkout, so every self-hoster ran an unpinned
source build that reports its version as "dev (unknown)". It now runs
ghcr.io/perpetualsoftware/pad:${PAD_VERSION:-latest}: the multi-arch image
the release pipeline builds and cosign-signs. `latest` moves only on stable
releases (goreleaser's tag template), so an unset PAD_VERSION never pulls an
RC. .env.example documents PAD_VERSION and recommends pinning it.
Contributors keep a source build through a new override,
docker-compose.build.yml, which tags the result pad:local so it never
shadows the released image.
deploy/k8s/deployment.yaml pins 0.17.2 instead of `latest`. With several
replicas (hpa.yaml), `latest` lets pods restarted after a release run a
different version from the rest against one database.
README and docs/deployment.md describe the pin, the upgrade
(`docker compose pull && up -d`) and the contributor build.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* build(deploy): the source-build override never pulls pad:local (TASK-2034, codex P3)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
f3b753e3a8 |
fix(server): a non-cloud boot no longer makes a Pad Cloud database's free users unlimited (TASK-3551) (#2028)
A self-hosted boot converts free and empty plans to `self-hosted`, which has no limits. Nothing recorded which kind of instance owned a database, so one non-cloud boot of a Pad Cloud database (a missing PAD_MODE, a restore tested locally, a debug boot) made every free user unlimited for good: a later cloud boot fills only empty plans. - Every cloud-mode boot marks its database cloud-owned (platform_settings.instance_mode = cloud), sticky and before its backfill. - A database with no marker counts as cloud-owned when any user carries Pad Cloud billing state (a stripe plan source or a stripe customer id). - The non-cloud boot's conversion (store.BackfillSelfHostedPlans) checks ownership in its own transaction and converts nothing on a cloud-owned database. The boot logs an error and continues: a debug or restore boot is not an outage, and free users keep free-plan limits. - `pad db release-cloud [--force]`, on the server host, is the deliberate way to move a Cloud database to self-host. It refuses while billing state remains unless forced; a later cloud boot marks the database again. The plan step of `pad server start` moves into bootPlanBackfill so it can be tested as the server runs it. docs/backup.md documents the behaviour. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
5960cf1978 |
fix(collab): an open tab refuses a content write over typing the server has not stored (BUG-3542) (#2016)
A content PATCH routed to an open tab (the designated applier) replaced typing that was still on the wire. The BUG-3133 pending-edits check reads the op-log when the PATCH arrives and cannot see it. The server now marks the applier_request `guarded` unless the caller sent overwrite_pending_edits. A tab whose edits may be missing (WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a barrier ack) answers applier_refuse instead of applying. The server takes that as final (no re-election) and the PATCH answers 409 content_not_applied with apply_reason unconfirmed_edits and content_landed false; other fields land, as on every content_not_applied. Tokenless writes are refused too, by ruling; overwrite_pending_edits is the only escape. ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
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. |
||
|
|
0b0398b2c0 |
feat(mcp): count admitted MCP requests by method and client (TASK-2307) (#2003)
* feat(mcp): count authenticated MCP requests by method and client (TASK-2307)
The go-sdk port would run its Streamable HTTP transport stateless, and a
stateless transport answers GET with 405, which ends the standalone SSE
stream. No unit test can say whether a real client opens that stream, so
before any engineering time goes into the port this measures it:
pad_mcp_http_requests_total{mount, method, client}, incremented on entry for
every authenticated request on /mcp and /mcp/chatgpt.
On entry, because pad_http_requests_total records a request when it ends,
and a GET holding an SSE stream open ends when the stream does. After
MCPBearerAuth, so refused requests stay with pad_mcp_preauth_denied_total.
The client label is a closed set read from the User-Agent (claude-code,
claude, codex, openai, cursor, windsurf, vscode, mcp-remote, mcp-inspector,
python, node, go, curl, okhttp, other, none), never the header itself.
* fix(mcp): read the User-Agent through requestUserAgent (TASK-2307)
The BUG-2803 call-site guard refuses a raw User-Agent read; the class is
computed from the sanitised value. The guard's exact count moves from 7
call sites to 8, re-pinned with the reason: like fromNativeShell, the counter
only substring-matches the header into a closed label and binds nothing.
* docs(mcp): name what the request counter admits, not 'every authenticated request' (TASK-2307)
Codex round 1: a caller refused by the per-token rate limit inside
MCPBearerAuth is authenticated and is not counted. That is the intended
boundary (a refused GET opens no stream, and the refusal is already counted
in pad_mcp_authz_denials_total{reason="rate_limited"}), so the comment, the
metric help and docs/deployment.md now say what is counted.
* test(mcp): a rate-limited MCP request is not counted (TASK-2307)
Codex round 2: the documented boundary (MCPBearerAuth's per-token 429 is
not an admitted request) had no test. Counted equals admitted; the mutant
moving the counter ahead of auth counts the 429 and fails.
|
||
|
|
f61a07fe9a |
feat(search): include_archived on /search, and an is:archived search token (TASK-2864) (#2001)
* feat(search): include_archived on /search, and an is:archived search token (TASK-2864) /search hard-filtered soft-deleted items on every query path, so the web search's Show archived toggle could not reach a body: query. It now takes include_archived=true, which widens the full-text query, the ref and number lookups, the total and the facets alike; an archived result carries its deleted_at. The permission filter is unchanged. An individual item grant names live items only, so a guest sees an archived item only through a collection grant, as on the item index's include_archived. The web search box recognises is:archived (the token the TASK-1367 prefix parser had prototyped and pulled): the collection page shows archived rows for it as the toggle does, sends it on body: queries, and fences the response on it; the command palette sends it and strips it from the text it sends. The collection identity gate re-records 16 hashes: 14 units reach the items view, which now reads the token, and none moved an await or a check. * test(search): drive is:archived through the palette and page bindings (TASK-2864) Codex round 1: the parser tests could not see the call sites. The palette test mounts CommandPalette and reads api.search on the body, cold-index and content-supplement paths; the collection fence harness reads the body search. Mutants (filter dropped, token left in the text on each path, the page ignoring the token or not sending it) each turn a case red. * test(search): an item-grant guest with a live grant sees no archived ungranted item (TASK-2864) Codex round 2: the all-archived access cases short-circuit to empty access before the store, so they could not catch include_archived widening a genuinely applied item-grant filter. Mutant (skip the permission filter when IncludeArchived) turns the new case red. * docs(search): the archived filter comment does not claim facets cover direct hits (TASK-2864) |
||
|
|
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 |
||
|
|
1d752e0ba6 |
fix(mcp): remote pad_item.import keeps its artifact (BUG-3533) (#1992)
* fix(mcp): remote pad_item.import keeps its artifact (BUG-3533) The catalog action is written for stdio: it spills the artifact to a temp file and dispatches the CLI's `file` positional. It also dropped `artifact` from the dispatched input, and the remote transport's HTTPHandlerDispatcher reads the artifact text from exactly that input, so every remote import answered "artifact is required". `artifact` now stays in the input. The CLI never sees it (BuildCLIArgs emits only declared args and flags; the existing no-`--artifact` test still holds). The dispatcher's own tests passed `artifact` to it directly, so the binding was untested. The new test drives the catalog action through the real dispatcher and server. The scope population test's knownBrokenOverHTTP exemption for import is removed, so import now carries the full write-control and read-refusal legs. * chore(mcp): ToolSurfaceVersion 0.80, remote pad_item.import works (BUG-3533) |
||
|
|
f9de1871dd |
fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534) (#1988)
* fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534) The MCP transport (mcp-go's StreamableHTTPServer) reads a POST body whole into memory and has no cap of its own, so any caller, before auth, could make the server buffer any amount. Both MCP mounts now refuse a body over max(3 x the 2 MiB JSON cap, 6 x the artifact import cap) + 1 MiB with 413 and a JSON-RPC error, before auth and before anything reads the body. A declared length is checked without a read; a chunked body is read up to the limit and replayed. The bound is derived: every tools/call's payload lands in a downstream request already capped at 2 MiB of JSON (up to 3x once a client \u-escapes it) or in an artifact (up to 6x as control characters inside a JSON string), so no call that could otherwise succeed is refused. It follows PAD_IMPORT_ARTIFACT_MAX_BYTES. A sweep of every request-body reader (the body-reader census plus every reader outside internal/server) found mcp-go's the only uncapped one. * fix(server): cap the MCP body after auth, so an unauthenticated body is never read (BUG-3534, codex r1) * test(server): pin that a 413 is audited; narrow the unread claims to the limiter (BUG-3534, codex r2) |
||
|
|
5c9c650ac3 |
feat(server): a read-scoped token may run a playbook (TASK-2863) (#1987)
* feat(server): a read-scoped token may run a playbook (TASK-2863)
POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.
Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.
* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)
* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
|
||
|
|
add4b624bd |
feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) The /mcp 401 challenge now names scope="pad:read pad:write", so a client asks for the tiers the consent screen offers instead of falling back to scopes_supported. resource_metadata stays the last parameter. An OAuth connection whose grant cannot write that calls a write action (any catalog action outside readOnlyActions) is now answered HTTP 403 with an error="insufficient_scope" challenge, before dispatch. Until now the refusal could only be a permission_denied tool result, because the dispatcher refuses after mcp-go owns the response. The challenge is what lets a client re-authorize with the wider grant. A read-scoped PAT keeps the tool error, since it cannot re-authorize, and the ChatGPT mount keeps its own tool-result challenge. Every write action was already refused for a read token, pinned by a population test that drives every catalog action through the real dispatcher under a write control and a read token. So nothing that worked is refused now. pad_item.import is exempt there: it fails over HTTP for every token (BUG-3533). ToolSurfaceVersion 0.77 -> 0.78 (behavior bump). * test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308) * test(mcp): use the population test's result-text helper (TASK-2308) * fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1) * docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2) |
||
|
|
2898bff09e |
fix(collab): close a peer the op bus had to drop an op for (TASK-1273) (#1981)
* fix(collab): close a peer the op bus had to drop an op for (TASK-1273) When a subscriber's buffer was full, MemoryOpBus.Publish dropped the op with a warning and the peer stayed connected with a document missing it until it happened to reconnect. The bus doc promised a force-close that was never built. Now the first drop of an op a subscriber needs (anything but awareness) marks it overflowed: Publish delivers it nothing more, because an op after the gap would carry an op-log cursor past the dropped row and the reconnect would resume from there and never replay it. The bus signals the overflow (SubscribeWithOverflow) once, and the room manager closes the peer's socket, so its reconnect replays the gap from the op-log. Awareness drops stay silent. Counted in pad_collab_overflow_closes_total through an optional OverflowObserver. cmd/loadtest-collab gains -stalled N (clients that never read). Measured against a local instance: 5 editors at 200 ops/s of 4 KiB, 2 stalled: both stalled clients closed by the server, editors 0 errors; with the close disabled, both stayed open. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(deployment): pad_collab_overflow_closes_total (TASK-1273) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(collab): a real overflow closes the peer once (TASK-1273, codex) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
416aea2d14 |
fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)
The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.
The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.
Also counts materializer give-ups (pad_materialize_giveup_total{kind}).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)
A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.
Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.
Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.
Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): take the server's data dir from one module (BUG-3523)
The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
f6f588b4d4 |
fix: a convention's stored metadata copy follows its ordinary fields (TASK-2257) (#1964)
A convention keeps its metadata twice: in the ordinary fields (trigger, scope/surfaces, enforcement/priority, category, commands), which agents and bootstrap read, and in the reserved `convention` key, which item.convention prefers. Since BUG-3163 nothing but the create member can write the reserved copy, so a fields_patch changing `trigger` left the copy behind and the two disagreed. UpdateItem now mirrors every mirrored key the write CHANGED into the reserved copy, under the write lock, after the BUG-3163 carry guard. A convention with no reserved copy is untouched. The merged copy is validated with models.ValidateConventionMetadata; a write that would leave it invalid is refused 400 validation_error (new, deliberate: storing an invalid copy is what the reserved key exists to prevent). The web Conventions page's edit form gains trigger / surface / enforcement selects (sent as fields_patch with both spellings) and an "Open item" link to the full item. Drift measured before the change: 194 conventions, 5 with a reserved copy, all 5 agreeing, so no reconcile follow-up was filed. |
||
|
|
480c5d351a |
feat(report): Insights buckets days in the viewer's time zone (TASK-3524) (#1955)
* feat(report): bucket the throughput series in the viewer's time zone (TASK-3524) * feat(web): Insights asks for buckets in the viewer's time zone (TASK-3524) * test(report): the tz parameter is echoed, and an unknown zone or Local is refused (TASK-3524) |
||
|
|
4226e8c5ef | feat(activity): the collection and actor filters run on the server, and the web/CLI badge says which (TASK-2219) (#1948) |