mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
c4d429d14c94de941ac761333cdac0a33895a05f
68 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
34861f8658 |
feat(mcp): ToolSurfaceVersion 0.29, and the drop-reason renderer it exposed (TASK-2878)
PLAN-2857 U1. The bump, its documentation sweep, and the consumer this
change turned from a rare wart into a routine one.
THE BUMP, at 0.29 rather than 0.28. Rebasing onto main found
|
||
|
|
b437cc582d |
feat: item reminders — the fire-at-an-instant primitive, and one overdue rule for all four surfaces (IDEA-2641, closes #1010) (#1244)
* feat(store): item reminders — the fire-at-an-instant primitive (IDEA-2641) Adds the storage, the scheduler tick, and the canonical event for one-shot item reminders (GitHub #1010). Nothing in Pad acted at a target time before this: a due_date makes an item show up as overdue once somebody asks the dashboard, so "revisit TASK-X on the 1st" had to live in an external cron. A TABLE, NOT A SCHEMA-FIELD ANNOTATION. The design sketch proposed marking schema date fields with a `reminds: true` key on models.FieldDef; recon overturned it. Such a key does not survive an ordinary collection edit, two independent ways: the web editor destructures each field into an EditableField and rebuilds a fresh definition key-by-key on save, so unknown keys are dropped (`pattern` and `unique_scope` survive only because two lines were hand-added for them), and models.CollectionSchema has fixed fields with no catch-all, so any Go unmarshal+marshal round-trip strips unknown properties — the hazard retargetRelationFieldsTx mutates raw JSON to avoid. Both failures are silent and both disarm a whole collection's reminders at once. It is the same defect class that moved traits out of the schema column in TASK-2657. The table also gives the lifecycle a home. A reminder is armed, then fired, then acknowledged, and a re-arm returns it to armed — per-reminder state a field definition has nowhere to keep. remind_at is an RFC3339 UTC instant, deliberately not a `date` schema value: those admit both YYYY-MM-DD and full RFC3339 and are compared against the SERVER'S LOCAL calendar day. A fire-at time cannot carry that ambiguity. The remaining timezone question for due_date is filed separately. Firing is one transaction per reminder carrying BOTH the fired_at write and the outbox insert. That pairing is the point: a fired_at committed without its event is a reminder that silently notifies nobody and can never be retried, because the row has left the armed set; an event without fired_at fires every tick forever. The UPDATE's own `fired_at IS NULL` predicate is the arbiter, so two instances ticking at once produce exactly one winner. item.reminder_due is admitted to the closed events/1 set as v1.2, with a new PayloadReminder family and no SSE name. The subject is the REMINDER, not the item: two reminders can be armed on one item, so an item-subject event could not say which fired, and the reminder id is what an acknowledgement addresses. A new payload family rather than reusing the item snapshot for the same reason — a snapshot would validate and still not answer the only question the event exists to answer. No SSE name in v1 because the poll surface is the contract; adding one later is additive, removing one is not. Ack is explicit and nothing else acks. An item reaching a terminal status deliberately does NOT ack: that would make every status write a reminder mutation, and it would silently consume a reminder set to fire after the work was done. * feat(server): reminder surfaces, and one shared overdue rule for all four Second half of IDEA-2641: the HTTP surface, the scheduler tick's wiring, and the fix for the finding that justified the unit — `ready` / `next` did no date handling at all. OVERDUE NOW HAS ONE IMPLEMENTATION. It used to live inline in the dashboard's attention loop, which meant `pad project stale` inherited it (it filters that very list) and the recommendation surface never saw it. So a deadline reached the two surfaces that REPORT on work and never the one an agent PULLS from. overdue.go is now the only place that decides, and all four call it. Two behaviour changes fall out, both deliberate: - An overdue item bypasses the orphan branch's high/critical priority gate. That gate was where a deadline quietly stopped: a low-priority item three weeks late was reported by `stale` and never suggested by `next`. - Overdue sorts above in-progress. The list is capped at three, so a rank below in-progress would not merely order the deadline lower — on any workspace with three things in flight it would keep an overdue item off the surface entirely, which is indistinguishable from not shipping this. The server-local-today comparison is UNCHANGED and known to be wrong for multi-timezone deployments; it is filed as its own item with the cloud case stated. Changing what "overdue" means on every existing instance inside a change about where the rule LIVES is the kind of behaviour change nobody reviews. Fired reminders reach `next` / `ready` two ways, from one filtered list: PendingReminders is the addressable form (it carries the id an ack needs), and a prepended suggestion is the rendered form. They are prepended AFTER the cap rather than entered as ranking candidates — a reminder is not a task competing on priority, and whether it appeared should not depend on how busy the workspace is. Terminal-item reminders are FILTERED from the surface, never acked. Acking on terminal status would couple every status write to reminder state and would consume a reminder armed to fire after the work was done. The row stays exactly as the user left it; the distinction is observable, and asserted. Three guard tests caught this change and each was answered rather than silenced: - The request-body reader guard was right: the handlers now go through decodeJSON, inheriting the NUL refusal and the size cap. - The canonical-events guard was right: item.reminder_due is admitted to the duplicated contract table as SPEC-3 v1.7, with the reminder subject kind and the new payload family. SPEC-3's own text owes the same amendment. - The NUL census asked for a decision on eight new columns. None carries caller text: ids and FKs are server-generated, four are the server clock, and remind_at is now re-parsed and re-formatted in the STORE as well as at the edge — so the stored value is always machine-produced from a parsed time and no caller bytes reach the column. The doc comment that used to say "the caller normalizes" protected nothing. Regenerating the baseline also found that GEN_NUL_BASELINE=1, which the test's own instructions name, was never implemented — the flag did nothing, so the documented path was hand-editing the file. Implemented, so the next reader gets the mechanism the instructions promise. * test(reminders): the lifecycle, the four surfaces, and 22 killed mutants Every test here was designed against a specific mutation and the mutation was RUN. A green suite proves nothing about a suite nobody tried to break, and three of the mutants I first wrote were not experiments at all. Store (10 mutants, all killed): candidate predicate <= flipped to >=; the event emission lifted out of the fire transaction; the fire UPDATE's `fired_at IS NULL` arbiter removed; the RowsAffected check ignored; re-arm clearing fired_at but not acked_at; ack losing `fired_at IS NOT NULL`; the poll surface losing `acked_at IS NULL`; normalizeRemindAt no longer refusing; it dropping .UTC(); GetReminder losing its workspace scope. Surfaces (12, all killed): the priority gate no longer bypassing on overdue; the sort no longer ranking overdue first; attention leaving the shared helper; the reason losing its OVERDUE prefix; the comparison flipped to >; terminal items no longer skipped; terminal reminders no longer filtered; the filter ACKING instead of hiding; reminders appended instead of prepended; the tick running on a far-future clock; ack answering 200 for an unfired reminder; parseRemindAt accepting a bare date. THREE MUTANTS DID NOT COUNT ON THE FIRST PASS and were rewritten. Two failed to compile (`if false` orphaned a variable; deleting a parse orphaned an import) and one had an anchor matching two call sites. A non-compiling mutant emits zero FAIL lines and reads exactly like a surviving one — it invents a hole that is not there — so the harness reports BUILD-FAIL and ANCHOR-BAD as outcomes distinct from SURVIVED. It also restores files from an in-memory copy rather than `git checkout`, which would delete uncommitted work in the tree. ONE MUTANT GENUINELY SURVIVED and the test was at fault, not the mutant: appending rather than prepending reminder suggestions was undetectable because the fixture had a single item, so the reminder sat at index 0 either way. The fixture now fills the three-item cap with in-progress work, where an appended reminder lands fourth and vanishes. Faithful mutant, weak test — checked in that order. The same lesson shapes the four-surface fixture: it is a LOW-priority open orphan, because that is the case the old code handled worst. A high-priority task would have made the ready/next leg pass against the unfixed tree, which is a green that measures nothing. Negative controls throughout: a future deadline is not overdue and does not reach the gate bypass; a tick with nothing due fires nothing; a completed item is neither overdue nor suggested. Without them a helper that reported every date, or a tick that fired everything, would satisfy every positive leg. The lead's pin is asserted in both directions: a fired reminder on a done item is ABSENT from the surface and PRESENT and still unacknowledged in the table. Asserting only the absence would pass against an implementation that consumed the row, which is the behaviour the pin exists to forbid. * feat(mcp): pad_item.remind + ack-reminder, ToolSurfaceVersion 0.28 An agent that can RECEIVE a reminder but not set one has half the primitive. The poll surface is pad_project.next / ready, both long exposed, so reminders already reached agents — what was missing is the other half: deferring a piece of work is exactly the moment an agent knows when it wants to be asked again, and it had no way to say so. Two additive actions, two optional params. Nothing existing moved, so a v0.27 consumer enumerating neither is unaffected — the v0.13 / v0.11 / v0.8 disposition, which likewise wired existing CLI verbs onto the catalog. remind_at REFUSES a bare date rather than reading it as midnight. Worth stating because the `date` schema type accepts YYYY-MM-DD and a caller will reasonably try it here: a bare date names a 24-hour span, and choosing an hour inside it would fire at a time nobody picked. Re-arm and disarm stay CLI-only. Both address a reminder by an id the agent would have to list first, and no listing action exists on this surface — a door with no handle. Adding them later is additive. Five guards had to be taught, and each was answered on its merits rather than excluded: the HTTP parity test (route mappers added, so the actions work on the remote transport rather than being advertised and unrouted), the read-only catalog's cmdhelp fixture and expected cmdPath map, the field- conflict classifier (remind_at / reminder_id are NOT field writers — a reminder is a row in its own table addressed by its own id, so listing them as classified sources would have pointed detectFieldConflicts at something that is not a field source), and the instructions.md / README action tables. That machinery is why the version bump is safe to make now, and it earned its keep on this change: every one of the five failed on the first build after the catalog entry landed. CONVE-23 sweep for prose this falsifies: - SPEC-3 (DOC-2653) amended to v1.7 in the room, recording item.reminder_due with its new subject kind and payload family — the first canonical event with no user mutation behind it, since a scheduler tick produces it. - CLAUDE.md gains the reminder routes, the CLI verbs, and the v0.28 entry. It was also stale at 0.26 with NO v0.27 entry at all: the 0.27 unit swept instructions.md and README.md and missed this file. Both added. - skills/pad/SKILL.md gains the verbs and a routing entry, including the two things an agent will get wrong — the time is an instant, so ask for a time of day rather than picking one, and finishing the item does not acknowledge the reminder. * fix(reminders): codex round 1 — four findings, all real, all with a pin Round 1 found four defects and refuted none of them. Each fix carries a test that fails against the code as it was, and each of those was mutation-checked. **P1 — pending reminders bypassed item-level visibility.** Every other dashboard section reads `allItems`, which the store already scoped to the caller's collections AND their granted item ids. The pending-reminder list is a direct workspace-wide query and inherited none of that, so a guest holding a grant on ONE item could read the refs and titles of every other item in the collection through its reminders — an item-level leak wearing a notification's clothes. Now filtered with the same `isItemVisibleToGuest` call the sibling sections use. The test's two items share a COLLECTION on purpose: a collection-level filter was already applied, so separate collections would have made it pass against the unfixed code. **P1 — soft-deleted items could starve the queue permanently.** Candidate selection ignored `deleted_at`, and `fireOneReminder` rolls back when it finds the item gone — which leaves the reminder ARMED and therefore a candidate again on the next pass. Candidates are ordered oldest-first and bounded by a limit, so enough archived reminders fill every batch and no live reminder ever fires. Silent, too: the tick reports zero fired and looks idle. Excluded in the candidate query rather than skipped downstream, so those rows never occupy a slot; the reminders themselves are kept, so restoring an item restores its reminder with it — asserted, because a fix that reaped them would pass the starvation test alone. **P2 — the pass stopped at the first failing reminder.** The per-reminder transaction exists precisely so one unfireable row cannot hold back the rest, and `return fired, err` made that comment false — with candidates oldest-first, one persistently broken old reminder blocks every newer one forever. Now continues and joins the errors, so a pass that fired seven and failed three reports both halves rather than reading as clean. The loop is split behind an injected seam because a real mid-transaction failure is not reachable from outside: the database refuses the corrupt rows that would cause one (verified — invalid JSON in items.fields is rejected by the schema). **P2 — suggestions dropped the reminder id.** The docs tell an agent to acknowledge what it sees in next/ready, and the payload carried no handle: a stateless poller could read the reminder and had no way to retire it, so it would be shown the same item forever. `DashboardSuggestion` now carries `reminder_id` (omitempty), `pad project next` prints the exact ack command, and the test acks with the id the surface handed out rather than merely checking the field is populated — a wrong-but-present id satisfies equality with itself. Four mutants, four killed; one was rewritten first because its anchor matched two call sites and was therefore not an experiment. * fix(reminders): codex round 2 — four findings, all real **`--rearm` was unusable.** `ExactArgs(1)` forced an item ref that the rearm branch then ignored, so the flag could not be reached without supplying a ref that was silently discarded. Now `MaximumNArgs(1)`, with each mode checked explicitly: a ref is required to arm, and a ref supplied ALONGSIDE `--rearm` is refused rather than ignored — it names an item the reminder may not even belong to, and quietly dropping it is how a user learns nothing about the reminder they just moved. **`unremind --format json` emitted plain text**, breaking the parseable-output contract every sibling command honours. **The MCP `ref` param did not list `remind`.** Agents read that flat description to decide what to send, so an action missing from it is an invalid call waiting to happen. It now also says what `ack-reminder` takes instead, and why: a reminder is addressed by its own id because an item can carry several. **Fractional seconds fired early.** `time.Parse` accepts `09:00:00.900Z` and `Format(RFC3339)` drops the fraction, so it was stored as `09:00:00Z` and fired 900ms BEFORE the moment the caller named — silently, having rewritten their value on the way in. Seconds are genuinely the stored resolution (the column is compared as a string against a whole-second clock, and the tick runs every 30s), so the only question was which way to resolve it, and truncation resolved it the wrong way. `NormalizeInstant` now rounds UP: at most a second of lateness, in exchange for a guarantee that can be stated — a reminder never fires before the instant it was set for. Late is a reminder; early is a wrong answer. Whole seconds round-trip exactly, which is asserted, because an implementation that added a second unconditionally would otherwise pass. Three mutants for this round, three killed (round-up→truncate, round-up→unconditional-add, MaximumNArgs→ExactArgs). Thirty across the unit. Two fixes carry no dedicated test and it is worth being explicit rather than implying coverage: the `--format json` branch on `unremind` is a one-line output change with no server-free way to drive it, and the MCP `ref` description is prose the drift tests do not read — they assert an action is DOCUMENTED, not that a param's sentence lists it. * docs(reminders): the ack id is on the surface an agent polls, not only on the arm response CONVE-23 follow-through on the round-1 fix. Both agent-facing docs told a caller to acknowledge a reminder with the id "returned when you armed it" — true, and useless to the caller that matters: a poller reading next/ready never armed anything. The suggestion now carries reminder_id and `pad project next` prints the exact ack command, so the docs say that instead. The prose was written before the fix existed, which is exactly the case CONVE-23 is about: a change that makes an instruction stale without touching the file the instruction lives in. * test(reminders): bind the tick LOOP to the work, not just the pass (CONVE-19) Every other test in this file calls runReminderTick directly. That vouches for the component and says nothing about whether anything ever calls it — a tick that is never started is indistinguishable, from those tests, from one that is. It is the convention's exact case, and the failure I recorded on my own identity doc three times in one unit: I test the component and not the binding. Driven through the injectable tick channel so the assertion pins a SPECIFIC pass instead of racing a 30-second ticker, and polled to a bounded deadline so a loop that never runs FAILS rather than hanging the suite. Mutant: drop `s.runReminderTick()` from the select and this goes red while every direct-call test stays green. Killed. The idempotence leg exists because a second Start spawning a second loop would leave one running after Stop, making the BUG-842 drain invariant false for this sweeper specifically — the one property a copied lifecycle is most likely to get right by accident and least likely to be checked. The cmd/pad call site (cmd_server.go, alongside StartTokenReaper) stays verified by inspection: a source-scanning guard for it would be an instrument asserting facts about source, which is code with an adversary and not worth it for one line that sits in the middle of five identical neighbours. * fix(reminders): codex round 3 — a deferred reminder fired anyway, and the poll surface was unbounded **A re-arm mid-pass did not stop the fire.** The candidate scan selects an id; before the UPDATE runs, a `--rearm` can move that reminder into the future. Re-arm clears `fired_at`, so a predicate checking only `fired_at IS NULL` still matched — the pass fired a reminder the user had just deferred and emitted its event. The re-arm cannot undo that: it can clear the mark, but the event is already on the outbox and at-least-once means a consumer has seen it. The fire UPDATE now revalidates `remind_at <= nowTS` against the SAME nowTS the candidate scan used. Same-value deliberately: the arbiter and the scan must agree about when this pass is, or a reminder could pass one and fail the other for no reason but clock drift inside a single pass. **The poll surface was unbounded.** Every fired-and-unacknowledged reminder was loaded and turned into a suggestion prepended to a list that is otherwise capped at three, so a workspace with five hundred unacknowledged reminders returned five hundred suggestions — in the dashboard response, the hottest read in the product, growing until somebody acknowledged them. Two bounds, because they are two different guarantees: the query takes a window (default 50, oldest-fired first, so it holds what has waited longest), and the prepended suggestions are capped at 5 so `suggested_next` stays a recommendation rather than a second inbox. The full set stays addressable in `pending_reminders`. Truncation is REPORTED as a boolean, not a count. A count would have to be post-visibility-filter to be true for the caller reading it, and the store cannot compute that — the filter runs per item, above. "There are more than you can see here" is the strongest claim the data supports, so it is the one made. Four mutants; two killed outright, two survived and were run down under CONVE-28: - **Uncapped suggestions survived because the fixture had ONE reminder** — capped and uncapped are the same list at n=1. That is the SECOND time a single-item fixture hid a count-or-order property in this file. Fixture now arms eight; it also asserts all eight remain in `pending_reminders`, so the cap is pinned to the recommendation and not to the data. - **Removing the SQL LIMIT survived, correctly, and the test comment now says so.** The Go slice cap bounds the PAYLOAD; the SQL LIMIT bounds the DATABASE'S work. Only the first is observable at this level — with the LIMIT gone the response is still bounded, while the query silently goes back to materialising every pending row before discarding most of them. That is a memory and I/O property with no assertion available here, so it is stated as a coverage boundary rather than papered over with a green that would not have measured it. * docs(reminders): the fire predicate arbitrates against two actors, not one CONVE-23 inside the file the round-3 fix touched. The comment described the UPDATE as an arbiter for concurrent TICKS, which is what it was written for and is why I did not re-read it when asked whether a user edit could race the pass. It now says what it actually defends against, and names the general shape: an arbiter is only an arbiter with respect to the writers it can see. * fix(reminders): codex round 4 — the round-3 bound recreated the round-1 starvation Round 3 bounded the poll surface. Round 4 caught what that bound did: the query took the first N rows and the dashboard then discarded the ones it could not show — hidden items, unauthorised items, completed items — so N such rows hide a visible reminder behind them indefinitely, with no continuation to reach it. That is the SAME defect I had removed from the fire path one round earlier, reintroduced in the read path within the hour. The general form is worth stating because I clearly did not hold it: **a bounded window is only safe when the discarding happens BEFORE the bound.** Filtering above a limit is a starvation every time, and it does not matter what the filter is for. Two halves, because the two filters are not the same kind of thing: **Visibility is now scoped IN SQL**, using the same collection-id / item-id sets every other dashboard section gets through `allItems` — the same three-way shape as ItemListParams, where holding both collection grants and item grants is an OR. Invisible rows no longer occupy the window at all, which is strictly better than filtering them out afterwards and is what the sibling sections have always done. **Terminality is paged**, because SQL cannot evaluate it — a collection's schema defines which statuses are terminal. The collector refills from the next page when a page comes back short, bounded by a max scan so a workspace full of completed items cannot turn a dashboard read into a table scan. The bound is 10x the window: the common shape fills on the first page, and the pathological shape terminates in a fixed number of indexed reads. Stopping at the scan bound reports truncation, which is honest — there may be more, and we did not look. The empty-scope case is a THIRD state that reads like the second: nil CollectionIDs means unrestricted, a non-nil EMPTY slice means this caller sees no collections. Without an explicit guard they collapse, because the switch matches none of its cases at length zero and adds no clause at all — so "nothing visible" would return the whole workspace. Three mutants, one survived: the empty-scope guard, because no dashboard-level test produces that state (callers that would are refused earlier by workspace access). Faithful mutant, missing test — it now has a direct one, with a sanity leg so a build returning nothing cannot pass it by accident. A guard for a state nothing exercises is exactly the one that rots. * fix(reminders): codex round 5 — the MCP action I shipped did not work over stdio **P1: local stdio MCP `remind` was unusable.** cmdhelp derives positionals by regex from a command's `Use` string, and `<instant>` inside `remind <ref> --remind-at <instant>` matched — it became a second REQUIRED positional, so dispatch failed with `missing required argument "instant"`. The action was advertised on a transport where it could not run. **The MCP catalog's own tests did not catch it, and the reason is the finding.** That suite builds its cmdhelp document BY HAND: I wrote `Args: mkArgs("ref")` in it, so the fixture agreed with what I meant rather than with what the CLI says. Five parity and drift tests passed against a document I authored to match my own intention — the "a test that agrees with whatever the table says is not a test of the table" shape, which the canonical-events test warns about in its own comment two packages away. The new test reads the REAL command tree via cmdhelp.Build, which is the only thing in this repo that can disagree with me about what the CLI declares. **P2: `pad project ready` withheld the ack handle** that `next` prints. Showing a fired reminder on the surface an agent polls while withholding the id it needs to retire it means the same entry comes back on every poll, forever. **P2: suggestions asserted a collection they did not have.** The orphan branch admits ANY collection — its own comment claimed it gated on tasks "mirroring the active-plan branch", and that comment was simply false — while the output hardcoded `Collection: "tasks"` and the reason said "Open task". Pre-existing for high-priority items since BUG-1082; my overdue bypass widened it to any overdue item, which is how it surfaced. Fixed by carrying the item's REAL collection rather than by narrowing the branch: narrowing would silently drop the non-task items this has surfaced for a year, and the defect is the mislabelling, not the inclusion. The false comment is replaced with what the code actually does. The first version of that test used an overdue IDEA and SKIPPED — ideas use `new`, and the branch requires `open` or an active status, so it never became a candidate. A test that cannot fire is a failed reconstruction, not a pass; the fixture is now a bug-like collection whose vocabulary contains `open`, which is the population the defect can actually reach. Three mutants, three killed. Forty-one across the unit. * fix(reminders): codex round 6 — reminders fired from soft-deleted workspaces **P1, and the only defect in this unit whose consequence leaves the process.** Workspace soft-delete deliberately keeps items for the 30-day restore window, so the candidate query's filter on the ITEM's deleted_at found nothing wrong — and the tick kept firing, emitting outbound webhook events for a workspace whose owner had deleted it, possibly while deleting their account. Both queries now join workspaces and require `w.deleted_at IS NULL`. Nothing is destroyed: a restored workspace resumes firing, which the test asserts, because "stops firing" and "is destroyed" are very different answers to someone who restores a workspace and only one of them is right. That test first failed for the WRONG REASON and the fixture was at fault: it counted every outbox row in the workspace, and item creation writes its own, so the assertion was satisfiable by the fixture itself and discriminated nothing. Scoped to the reminder event type. **`Use: "remind <ref>"` declared a requirement the command contradicts.** cmdhelp derives the machine-readable arg spec from that string, and `--rearm` takes no ref — so the published contract said "required" for something optional. The requirement is CONDITIONAL, which cmdhelp cannot express, so the honest declaration is `[ref]` plus the explicit check that names both call shapes. The round-5 test grew a `required` column, which is what makes this observable at all: asserting only the arg NAMES would have passed. **The pad_item tool description omitted both new actions.** The params were declared and the actions dispatched, but the prose an agent reads to decide what a tool can do did not mention them — discoverable only by someone who already knew to look. It now describes both, including the two things an agent gets wrong: remind_at is an instant, and nothing but an explicit ack retires a fired reminder. Three mutants, three killed. Forty-four across the unit. * fix(reminders): codex round 7 — one predicate for the scan and the arbiter Third instance of one class, so this fixes the SHAPE rather than the instance. The class: the candidate scan filters on something the fire transaction does not revalidate, so a change committed between them fires a reminder that no longer qualifies. Round 3 was a re-armed instant. Round 1's soft-deleted item was the same thing caught from the other side. Round 7 is a workspace deleted between the scan and the fire — the round-6 fix added the condition to the SCAN only, and the arbiter went on not knowing about it. Fixing those one at a time is what let the third happen. `reminderFireable` is now a single string that both sites reference: the scan asks it and the fire UPDATE re-asks it, so they cannot disagree, and a fourth condition is one edit in one place rather than two edits someone has to remember are paired. Written as a correlated EXISTS on item_reminders.item_id rather than a JOIN precisely so the identical text is valid in both a SELECT and an UPDATE, and the scan drops its table alias so the two uses are the same characters. What deliberately stays outside it: `fired_at IS NULL` and `remind_at <= ?` live on the reminder row itself, are already spelled identically at both sites, and folding them in would need a parameter order the shared form cannot express. Said in the comment so the omission reads as a decision. Both directions are now tested at the arbiter — a workspace deleted mid-pass and an item deleted mid-pass — because the item case previously relied on the item load coming back nil, and someone simplifying the EXISTS down to the workspace check alone would otherwise still see green. Three mutants, three killed: the arbiter dropping the shared predicate, and the predicate dropping each of its two halves. Forty-seven across the unit. * fix(reminders): codex round 8 — workspace export silently dropped every reminder WorkspaceExport is a hand-maintained field list, so a new table joins it only if someone remembers. Reminders did not: a backup/restore, or a SQLite→Postgres migration via `pad db migrate-to-pg`, dropped every pending reminder with nothing in the destination to show anything had gone. The line that list has always drawn is item-scoped workspace CONTENT (comments, links, versions — exported) versus per-user state (stars, watches — not). A reminder has no user column and hangs off an item, which puts it on the exported side. Stating the rule rather than just adding the field, because the next person adding a table needs to know which side they are on. LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged reminder is still owed to whoever armed it, so it arrives pending; an armed one whose instant has passed fires once on the destination's first tick, which is what would have happened had the workspace never moved. Re-arming everything on import would invent a schedule the user did not set. NULL rather than empty string for the unset marks — the lifecycle is defined by NULL-ness, and "" would make a never-fired reminder read as fired at "". TestMigratedTablesCoversTheExport caught the second half, which I would have missed: `pad db migrate-to-pg`'s NUL preflight decides what to REFUSE on from MigratedTables, so a table the migration copies and the preflight does not know about is a gap in exactly the guard that exists to prevent one. Added there too, with the reason it can never actually fire — every column is machine-produced, so it is listed for coverage rather than expectation — and the "six tables" prose it falsified is now seven. Two mutants, two killed: export dropping the block, and import discarding the marks. Forty-nine across the unit. * test(reminders): state the fire-path invariant and pin it from the invariant The lead's read on why rounds 4 and 7 were the same class: the fire path had no stated invariant, so each fix defended an instance. This states it, and derives the pin from the paragraph rather than from the bug history. THE INVARIANT: the candidate scan is a hint and may be assumed to prove nothing. Every condition that made a row a candidate is re-asserted inside the transaction that marks it fired, in the same statement that does the marking, so checking and writing are one atomic act. Worded as "the scan proves nothing" rather than as a list on purpose — a list invites the next person to add a condition to the scan and stop, which is exactly what happened four times here. TestFirePathInvariant is the pin: one table, one row per scan-side condition, each invalidating that condition in the window between the scan and the fire and asserting the same three things — nothing fires, no event leaves, the reminder is not consumed. The earlier per-defect tests are folded in as rows; they said the same thing one instance at a time, which is how four of these shipped. Adding a fifth condition to the scan without a row here should feel like an omission. It carries a positive control, because four cases that all assert nothing happens would pass against a build that never fires at all. The matrix immediately falsified a claim in the paragraph I had just written. I wrote that the item load inside the transaction is "for the payload, not for the check"; removing the item half of reminderFireable alone changes no observable behaviour, because the load then returns nil and the deferred rollback undoes the write. Item liveness is defended TWICE and a single-mutant experiment cannot say which guard is carrying it — removing both is what kills the test. Both are kept, the predicate is named as primary (the row never matches, so no write happens at all), and the asymmetry is stated: workspace liveness has no second line, which is why dropping ITS half does fail the pin. Six mutants: five singles plus the pair. Five killed alone; the item single survives by design and is documented as such rather than left as an unexplained green. Fifty-five across the unit. * fix(reminders): codex round 9 — one legacy row could hide every reminder **P1: items.item_number is NULLABLE and I scanned it into an int.** Migration 006 added the column to existing rows, so a pre-numbering item still carries NULL — and scanning NULL into an int fails the Scan, which fails the QUERY, which degrades the whole pending-reminder section. One old row, and the feature is dark for everyone in that workspace. ListWatchesForUser, which this query was modelled on, uses sql.NullInt64 for exactly this column. I copied its shape and dropped the part that handles the column's actual nullability — the same way of being wrong as the round-5 cmdhelp fixture: borrowing a form without borrowing what it knows. The legacy row now carries no ref rather than a fabricated "PREFIX-0", which would name a different item. **P1: export shipped reminders that import could only discard.** The items section filters on deleted_at IS NULL, so a soft-deleted item is not in the bundle and its reminder can never be reunited with it. My comment claimed the item_links rationale — round-trip the raw graph so a restore reunites them — which is true for links and false here, because links keep soft-deleted endpoints in the bundle and items do not. A link is a row ABOUT two items; a reminder whose item is absent is a dangling schedule. **P2: import wrote remind_at raw.** Import is a writer, and a bundle is not necessarily one this server produced — hand-edited, or from another instance. A local offset or a bare date would land in the one column every comparison downstream treats as a UTC instant, firing early, late, or never. It now normalizes like every other door. An unparseable value is SKIPPED with a warning rather than failing the restore, matching the lenient import-side precedent already in this file, and the raw value's LENGTH is logged rather than its content. Three mutants, three killed; two needed rewriting because the single-line form did not compile — reverting the nullable scan also requires reverting the render, and dropping the normalization orphans a variable. PROCESS FAULT, recorded because it makes this round's findings weaker than they look: I edited the tree while this review was reading it — committed the invariant work and ran five mutation experiments, which write and restore source, over the same files. A review binds to the tree it read and I moved it underneath. Every finding above was re-verified against the current tree before being acted on, and the next round runs with no concurrent edits. * fix(reminders): codex round 10 — one orphaned item aborted a whole restore An ORPHANED item — one whose collection is missing from the bundle — still gets an itemMap entry. It has to: the entry is written before the skip because parent resolution inside the same loop reads the map for items it has not reached yet. So `itemMap[x] != ""` is satisfied by an id that names no row, and inserting a foreign key to it fails (SQLite enforces FKs here via the DSN's `_pragma=foreign_keys(on)`; Postgres always does). The pre-existing mapping is the sharp edge. The aggravating half was mine: this loop treated a failed reminder insert as FATAL, where item_links and item_versions both skip, so one orphaned item carrying a reminder rolled back an entire 900-item workspace restore. A reminder is the least critical thing in a bundle and it had the strictest failure handling in the file. Both halves fixed: the loop gates on items that actually landed, and a failed insert warns and skips like its siblings. TWO GUARDS THAT ONLY DIE TOGETHER, and this is measured rather than assumed. Reverting either alone leaves the test green — with the map gate restored the skip survives the FK failure, and with the fatal return restored the gate means the insert never fails. Removing both is what fails it. They are kept as a pair because they defend the same failure at different depths (prevent the bad write / survive a bad write arriving some other way), and the pair is recorded in the code so a future reader does not delete one as dead after watching its mutant survive. Second time this shape appeared today; the first was item liveness on the fire path. The bundle in the test is hand-built, because ExportWorkspace cannot produce an orphan — which is the reason it needed a test. That shape only arrives from a hand-edited or foreign bundle, and surviving those is what import is for. Three mutants: two singles that survive by design, plus the pair that kills. Sixty-one across the unit. * fix(reminders): codex round 11 — four contract slips, one of them another unit's **suggested_next returned up to eight entries against a cap of three.** Round 3 prepended reminders PAST the list's own cap, reasoning they should not compete for slots. Every consumer — the web dashboard, `pad project next`, `pad project ready` — is written for three. Worse, it silently falsified a decision recorded elsewhere: BootstrapDashboard deliberately has no suggested_next_overflow_count BECAUSE this list is capped at three upstream, and its comment names raising that cap as the moment to add one. My change made another unit's reasoning wrong in a file I never opened. The combined list is now trimmed back to three, reminders still leading — a reminder can push a task suggestion out, which is the right way round, and the full set stays addressable in pending_reminders. My first version of that trim used `limit`, which is REASSIGNED above to len(candidates) — so on a workspace whose only entries are reminders it would have truncated to zero, killing precisely the case the surface exists for. Caught by reading the surrounding lines before running anything; it has its own test now. **pending_reminders was uncapped in the bootstrap projection.** BootstrapDashboard embeds *DashboardResponse, so every new field joins the boot payload automatically — here, a window of up to 50, which is the budget PLAN-1410 spent a unit trimming. Capped at 5 with an overflow count, under its own constant rather than borrowing bootstrapAttentionCap: they answer different questions and a future change to one must not silently move the other. **Truncation was reported from the wrong question.** The collector used the store's `more` flag, which answers "is there another PAGE", not "did I read all of THIS one" — so a window filling part way through the final page reported that the caller had seen everything while unread rows sat behind the fill point. The paging bounds are now injectable so the case is testable at all: building it with a window of 50 needs ~75 rows in a specific pattern, with a window of 3 it is four. **Import accepted acked-without-fired**, which is not one of the lifecycle's three states. Such a row fires, is excluded from the pending surface because it is already acked, and can never be acknowledged because AckReminder requires acked_at IS NULL — an event emitted into permanent invisibility. The acknowledgement is dropped and the schedule kept, since an ack of something that never fired means nothing. Five mutants, five killed (one rewritten — removing the flag orphans a variable). Sixty-six across the unit. * fix(reminders): codex round 12 — a read is not a hold; scope the arm; ack from the ack Four P2s from round 12 (two independent runs, both landing on the same line of the fire path), each closed at the layer where it lives: - fireOneReminder pins the item and workspace rows FOR NO KEY UPDATE on Postgres before the arbiter UPDATE. reminderFireable re-asserted liveness at the predicate's instant and nothing held it to the commit instant; under READ COMMITTED an archival could commit in between and the event left the process about a deleted resource. Same idiom and same lock strength as CreateAttachmentForLiveItem; SQLite is excluded by its BEGIN IMMEDIATE, not skipped for convenience. Two PG-only pins verify "blocked" in pg_stat_activity, not by elapsed time; the pin-removed mutant fails both. - CreateReminder asserts "live item of THIS workspace" in the INSERT's own SELECT and returns ErrReminderItemGone otherwise. The table had an FK and no same-workspace constraint; a mismatched pair fed another workspace's title to this one's dashboard and webhooks. Handler maps it to 404. - AckReminder matches every fired row (COALESCE keeps the first ack, updated_at moves only when acked_at does), so a no-match means exactly "not fired at the instant of the ack". The handler no longer decides 409-vs-200 from the row it read before the UPDATE. - The invariant paragraph gains its missing sentence: "at that instant" means the commit instant, and the pin is what makes the predicate's instant and the commit instant the same one. Round-12 caveat carried: both runs were static reads (sandbox blocked Go's build cache), so "four" is a floor, not a measurement. Refs IDEA-2641 * fix(reminders): codex round 13 — a reminder's workspace must agree with its item's, at every read Every reader scoped by r.workspace_id and then joined the item without asserting the two agree. No door writes a disagreeing row today (CreateReminder derives the pair from the item; import maps within the workspace), and the table has nothing that forbids one — so a hand-edited bundle, a future move door, or a direct write would carry one workspace's item into another's dashboard, export, and webhooks. The identity goes into reminderFireable (scan + arbiter), the Postgres row pin, ListPendingReminders and the export query. One test writes the row raw — the only way one can exist — and asserts it is inert at each site; the predicate-removed mutant scans and fires it. Refs IDEA-2641 * fix(reminders): codex round 14 — the by-id and by-item reads assert the same identity as every other read GetReminder scoped by the row's own workspace_id and ListRemindersForItem by item_id alone, so a row whose two columns disagree — the class rounds 12 and 13 closed at the scan, the arbiter, the pin, the pending surface and the export — was still readable through the two reads that reach a single row. reminderOwned is that identity on its own, without the liveness half those two reads must not have (a fired reminder on an archived item is history worth showing). The write paths reach a row only through GetReminder, so scoping it scopes them; a row no door can write needs no door to delete it. ListRemindersForItem now takes the workspace its caller already resolved the item in. The raw-row test asserts both reads refuse the row from both sides; the reminderOwned-removed mutant surfaces it through GetReminder. Refs IDEA-2641 * fix(reminders): codex round 16 — an archived item's reminders are readable, and its verbs say "archived" The doors resolved the item live. Listing an archived item's reminders answered 409 from a GET, and ack/re-arm/delete answered a bare 404 for a reminder that exists on an item that exists — while the store, since round 14, deliberately keeps that history readable. The API already has a posture for archived items: GET reads them, mutations answer 409 "archived … restore it before editing" (writeItemResolveError). The list now follows handleGetItem; the lifecycle verbs load the item include-deleted, run the visibility check first, and then answer the same 409 every other item mutation does. One test walks archive → list 200 / ack 409 / arm 409 → restore → ack 200 on the same rows. Refs IDEA-2641 * fix(reminders): codex round 17 — one suggestion per item, the archived 409 by slug, and the door courtesy named Three findings on the server pass. (1) An item that was both a fired reminder and an ordinary candidate appeared in suggested_next twice; the ordinary entry is dropped, the reminder entry (which carries the ack id) stays, and two reminders on one item remain two entries. (2) Round 16's 409 for an archived item's reminder was written by re-resolving item.Ref, which is derived and empty for a legacy item with no item_number — so the class most likely to be legacy fell through to a bare 404. The slug is handed over instead. (3) The archived check in resolveReminderForWrite is check-then-write, and an archive landing in between lets the verb through: accepted and documented — it is the posture of every item mutation here (UpdateItem's UPDATE has no liveness clause), the outcome is benign, and putting liveness in AckReminder's WHERE would re-create the no-match ambiguity round 12 removed. Refs IDEA-2641 |
||
|
|
a1716d8170 |
ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) (#1247)
* ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) `npm audit` exits non-zero identically for "a HIGH/CRITICAL advisory exists" and "the advisory service was unreachable". The Web job ran it before Build / Type check / vitest under `bash -e`, so a registry timeout (main, 03:50Z) and a 503 (#1246, 04:33Z) on 2026-09-04 each produced a red row with every frontend verification step SKIPPED — a lane that read like a failure and had asked nothing. scripts/ci-audit.mjs runs the audit in --json mode and decides from the report: metadata.vulnerabilities present → fail iff high+critical > 0, naming the advisories; an error envelope or unparseable output → a GitHub warning annotation saying the gate did not run, exit 0. The step moves to the end of the job so the frontend's own verdict always exists whatever the audit does. Verified locally against five report shapes (transport timeout envelope, E503 envelope, one high advisory, clean, garbage) and two live runs (the real registry: clean; a dead registry: warning, exit 0). `--input <file>` is the seam those checks use. Fixes BUG-2881 * ci(web): the audit gate fails closed — retry an unreachable advisory service, then fail under its own title Codex round 1 on #1247: the first draft warned and exited 0 when the advisory service could not be asked, which made the only supply-chain gate pass exactly when it had not run. A gate that passes when it cannot run is not a gate. Now: up to three attempts with backoff (registry blips are usually seconds long), then `::error title=npm audit did not run` and exit 1. The title is distinct from `::error title=npm audit` (a real advisory) so the checks tab tells the two apart without opening the log; re-running is the remedy for the first and never for the second. Because the step runs last, Build / Type check / vitest have already produced their result either way — the original blindness is gone regardless of which way this step fails. Verified against the same five saved shapes (transport and E503 envelopes and garbage now exit 1 under the did-not-run title; a high advisory exits 1 under the advisory title; clean exits 0) and two live runs (real registry: clean; dead registry: three attempts logged, exit 1). Refs BUG-2881 * ci(web): the audit gate refuses counts it cannot read, and refuses bad tuning without crashing Codex round 2 on #1247. (1) metadata.vulnerabilities was checked for presence, not for shape: Number("x") + Number(null) > 0 is false, so a malformed count read as a clean audit — a second fail-open, one layer deeper than round 1's. high/critical must now be non-negative integers or the report is unreadable, which is the fail-closed path. (2) The two env knobs are operator-set, but CI_AUDIT_ATTEMPTS=NaN left the retry loop unexecuted and threw a TypeError, and CI_AUDIT_BACKOFF_MS=Infinity parked Atomics.wait forever; both now fall back to the default with a line saying so. Refs BUG-2881 * build: the local preflight runs the same audit gate CI does, and runs it last Codex round 3 on #1247 (blast radius): `make web-check` still chained bare `npm audit && npm run check`, so a registry blip stopped svelte-check locally exactly as it had in CI, and CONTRIBUTING documented the bare command as the way to reproduce the gate. New `web-audit` target runs `npm run audit:ci`; `check` runs it after web-check and web-test, mirroring the Web job's order. CONTRIBUTING and docs/architecture.md say so. Refs BUG-2881 * build: web-audit stands alone — no `web` prerequisite, so `check` runs npm ci once and no new target reaches it Codex round 4 on #1247: `web-audit: web` made `check` run `npm ci` twice (`web` is .PHONY) and added a target CLAUDE.md's worktree rule did not list as reaching `npm ci`. `npm audit` reads the lockfile and needs neither node_modules nor a build — verified by running it with node_modules removed — so the prerequisite goes; CLAUDE.md's safe list gains `web-audit`. Refs BUG-2881 |
||
|
|
dc3fc2d50e |
feat(server,cli): name undeclared field keys on the write response (BUG-2850)
Undeclared keys are ACCEPTED — the census found 168 live values under 14 such
keys, and refusing them would break read-modify-write on items nobody edited
wrongly. But once stored, a typo and a deliberate extra field are
indistinguishable, so the write now says which keys it did not recognize.
- models.Item gains `Warnings *ItemWriteWarnings` with `undeclared_fields`,
omitempty and additive. NEW API SURFACE: item write responses carried no
warnings element before. Wrapping the response as {item, warnings} was the
alternative and would have broken every existing parser; a clean write is
byte-identical to before.
- items.UndeclaredFieldKeys consults models.IsReservedItemField rather than
re-listing the reserved set — that set exists so callers ask, and its doc
comment records what re-listing cost last time. So a write carrying
implementation_notes or github_pr reports nothing.
- fields_patch reports only the PATCHED keys. A stray key already on the item
is not something this write introduced, and naming it on every touch would
train the reader to ignore the field.
- The CLI prints one line to STDERR. Never stdout: `--format json` output is
piped into scripts, and a warning there would corrupt the JSON they parse.
- CLAUDE.md documents the element as new surface.
Controls: never attaching the warnings fails the pin; reverting the HTTP
mapper's native overlay fails the remote-door type test; dropping the
reserved-key exclusion fails its own test.
Two coverage gaps the controls FOUND rather than confirmed, both now closed:
the remote door's native overlay was covered by no MCP test at all (a revert
left the package green), and the reserved-key exclusion had no test either.
Both were written after the control survived, which is the only reason they
exist.
Gates: gofmt clean, go vet clean, go test ./... 29 packages ok.
Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
|
||
|
|
91d92f184f |
feat(server): refuse workspace creation without may_create_workspaces consent (IDEA-2756) (#1212)
* feat(server): refuse workspace creation without may_create_workspaces consent (IDEA-2756)
The OAuth consent screen's "Let this app create new workspaces" checkbox
gated only the post-creation auto-add. A connection whose user left it
unticked could still create workspaces; it simply could not then see
them. A permission that does not prevent the action it names is a
consent mismatch.
Dave ruled it: the checkbox is a permission on whether the connected
token may CREATE, and it has to be true to what a user would honestly
expect from the option. The behaviour-change-for-existing-connections
argument loses to honest consent semantics.
Adds Server.requireWorkspaceCreationConsent, a shared gate at the top of
both endpoints that mint a workspace under the caller's account:
POST /api/v1/workspaces handleCreateWorkspace
POST /api/v1/workspaces/import handleImportWorkspace
Import reaches CreateWorkspace via store.ImportWorkspace, so it is the
same permission at a second door — lead-ruled as an application of the
same rationale, not a new decision. The gate sits above the Content-Type
dispatch, so it covers the tar.gz bundle path (whose only route is that
handler) and refuses before the 64 MiB body read.
Refusal is a 403, mirroring handleAuditLog's consent refusal (BUG-2102):
a hard decline rather than a narrowed response, because there is no
narrower version of creating a workspace.
Three non-refusal cases and one refusal, all but the last with a test:
- not an OAuth grant (PAT, CLI session, local stdio) — creation rides
on ordinary account authority
- ErrOAuthConnectionNotFound (pre-Phase-C grant) — ALLOW, matching the
backfill's may_create_workspaces=ON default. Deliberately asymmetric
with maybeAutoAddCreatorConnection's not-found branch, which declines
a convenience where this one would invent a refusal
- flag set — proceeds; the auto-add is unchanged
- a store I/O error — REFUSED, failing closed with a 500, because
allowing the create when the deciding state could not be read grants
a declined permission on the strength of a database blip. This is
the one branch with no test: injecting a store read failure needs a
fault-injecting store the package does not have, so it is reasoned
rather than measured
Population enumerated before the fix (CONVE-18): five CreateWorkspace
call sites, two of them HTTP endpoints reachable by an OAuth token (both
gated). Excluded with reasons: autoCreateWorkspace (signup-time, no
connection in context), workspace restore (un-deletes an existing
workspace), /oauth/claim (grants access, does not mint), cmd_db.go
(local store copy, no HTTP). Search boundary: the sweep traced
Store.CreateWorkspace callers and did not look for a path that inserts a
workspace by raw SQL.
Ten tests, all driving the real router rather than calling handlers
directly (CONVE-19). Every refusal leg asserts that no workspace of that
name exists afterwards, not merely the status code (CONVE-12) — a guard
that 403s after the write passes a status-only assertion. Seven mutants,
seven detected, including both guard-placement mutations.
MCP tool surface 0.25 -> 0.26. Behaviour bump on the v0.9/v0.16/v0.25
grounds: no tool name, action enum or param shape changed, but
pad_workspace.create now refuses a call it used to permit. Closest
precedent is v0.10; unlike v0.10 there is deliberately no escape-hatch
param, because the gate encodes a decision the USER made at consent time
and a bypass flag would be the app overriding its own grant.
CONVE-23 sweep for prose the change falsified: instructions.md told
agents the create still succeeds and to use the claim flow (it would
have sent them to claim something that was never created); the
TASK-2753 allow-list guard entry asserted the same and posed IDEA-2756
as open; the MCP catalog and CLI help described only the flag=true path;
maybeAutoAddCreatorConnection's flag-off branch is now unreachable from
its sole caller and is documented as dead code kept for contract, to be
deleted only with the guard. CLAUDE.md was already stale at v0.24 (v0.25
bumped the constant without it) — brought to v0.26 with a backfilled
v0.25 line.
The consent screen and console copy are unchanged: they were the
misleading half of this bug, and the fix makes them true.
* docs(server): state the import gate's reachability precisely (IDEA-2756)
The import-side gate is correct but currently unexercised in production,
and the first framing of this change did not say so.
WithMCPTokenIdentity is stashed by exactly one middleware, MCPBearerAuth,
mounted on /mcp alone. An OAuth connection reaches an /api/v1 handler
only through the in-process MCP dispatcher, and that dispatcher's route
table has a workspace create action but no workspace import. So no
OAuth-bound caller can reach handleImportWorkspace today.
The gate stays, and the comment now says why: adding that action later
must not silently reopen the door, which is the state a create-only fix
would have left armed.
Found on a verify pass reading the middleware mount points, not by the
tests — they synthesize the OAuth identity into the request context, so
they prove the handler's behaviour GIVEN an identity and have no opinion
about which routes supply one (CONVE-19). Codex round 2 reached the same
conclusion independently.
* fix(server): correct five overstated claims from Codex round 3 (IDEA-2756)
All five were mine, all P2, none changing the gate's behaviour — four are
claims that were broader than the code, one is a test that proved less
than its name.
1. "Only re-authorization lifts it" was wrong in five places (version.go,
README, CLAUDE.md, the MCP catalog description, CLI help). A user can
also enable the flag on the EXISTING connection via
PATCH /connected-apps/{id}/flags, which the console page drives —
instructions.md said so and contradicted the others. All five now name
both remedies, and both are still the user's, which is the part that
matters: neither is reachable by the app.
2. "This branch is UNREACHABLE ... it is dead code" on
maybeAutoAddCreatorConnection's flag-off branch was false. The gate
reads the connection and that function reads it AGAIN after creation;
a user revoking creation power from the console between those two
reads lands exactly there. It is a real second check across a real
TOCTOU window, failing in the safe direction. The claim was written
from the call graph, which cannot see a concurrent write between two
reads.
3. handlers_import_bundle.go's "Auth: any authenticated user" was made
false by this change and the concept sweep never had a chance at it —
it greps may_create / auto-add / creation power, and that sentence
contains none of them. Corrected in place.
4. The two NonOAuthCallerUnaffected tests claimed PAT, CLI session and
local stdio; each drives one PAT. The comments now state the fixture's
real scope and why one caller stands for the class (the guard branches
on an identity only MCPBearerAuth sets, so callers that skipped it are
indistinguishable) rather than implying three fixtures.
5. The JSON import refusal leg would have passed with the gate below
decodeJSONWithLimit — only the bundle leg pinned placement, and only
for gzip. Adds TestImportWorkspace_ConsentRefusalPrecedesBodyDecode
(malformed body: 400 if the gate is late, 403 if it is early),
mirroring the create-side ordering legs.
Mutation matrix now 9 mutants, 9 detected. M8 (guard below the JSON
decode) is killed by the bundle leg too, so it shows the new test is
covered rather than necessary; M9 gates the bundle path and moves only
the JSON path's guard, and dies to the new leg ALONE. That is the mutant
that justifies the test.
* docs(server): the second consent check narrows the race, it does not close it (BUG-2792)
Round 3 caught me calling maybeAutoAddCreatorConnection's flag-off
branch dead code. The replacement comment then claimed the branch means
a revoked grant cannot silently gain a workspace — which is more safety
than the code delivers, and round 4 caught that.
The read and the AddConnectionWorkspace insert below it are separate
unconditional statements, so a revocation landing BETWEEN them still
adds the workspace. The check narrows the window; it does not close it.
Filed as BUG-2792 rather than folded in: the race is pre-existing and
unchanged by IDEA-2756, and closing it needs an atomic check-and-insert
at the store layer, written and gated for both dialects — materially
more diff and risk than this handler-level guard.
Both mistakes were the same shape in opposite directions: a claim about
concurrency derived from reading the call graph, which cannot see a
concurrent write between two reads.
* style(server): gofmt the doc comment (IDEA-2756)
gofmt wants blank lines between list items once one item spans multiple
paragraphs, which the BUG-2792 note made true.
My error, and worth naming exactly: I ran build, vet and the targeted
tests on this commit but not lint, because lint had passed on the
PREVIOUS commit and the change was 'only a comment'. The gate has to run
on the tree being pushed, not on an earlier one that resembles it. CI's
golangci-lint is pinned to the same v2.11.4 the Makefile installs, so
there was no version skew to blame — the local gate would have caught
this in 51 seconds.
* docs(server): correct ten overstated prose claims from Codex round 8 (IDEA-2756)
Round 8 reviewed only the prose this change adds. Ten claims were
broader than the code. All ten are mine; none changes behaviour. Rounds
3, 4 and 7 each caught one of these, which is why round 8 was pointed at
the class rather than at a new dimension.
The substantive ones:
- "gates every endpoint that MINTS a workspace" — autoCreateWorkspace
mints from registration, bootstrap and oauth-login and is deliberately
outside this gate. The helper doc and the test header now name the two
callers and the exclusion instead of claiming universality.
- "the agent was handed a workspace it could not then see" (version.go,
README, CLAUDE.md) — only true for a connection with an EXPLICIT
allow-list. An all_current_workspaces=true connection is not gated per
slug and could see what it made. The consent mismatch is the constant;
the invisibility was its most visible symptom, not its definition.
- "ErrOAuthConnectionNotFound — a pre-Phase-C grant" asserted a cause the
code cannot know: ANY missing row takes that branch. Now stated as the
expected cause, with the limit of what the code can tell.
- "above the 64 MiB body read" conflated the two import paths. 64 MiB is
the JSON decode's bound; the bundle path has its own, much larger. The
gate precedes both, which is the property that actually matters.
- "the request context is decorated AFTER TokenAuth runs" was false, and
inherited verbatim from the sibling helper this was modelled on
(handlers_oauth_claim_test.go's doClaim), where it is also false. The
wrapper sets the identity BEFORE ServeHTTP; it survives because
nothing on the /api/v1 chain writes that key.
- "lets CreateWorkspace normalize it" — CreateWorkspace slugifies only
when the supplied slug is EMPTY, and import supplies a non-empty one,
so an imported workspace keeps the ?name= value verbatim.
- "The PAT needs a workspace to bind to" — CreateAPIToken takes
WorkspaceID as optional.
And one where the first fix was worse than the finding:
- "Every refusal leg asserts no workspace exists afterwards" was false —
the two ordering legs assert status only. My first correction ADDED
those assertions, which is the trap the finding was pointing at: a
malformed body and an empty name are rejected before creation under
every guard placement, so "no such workspace exists" is true of broken
and working code alike. Reverted; the header now states which legs
carry the counterfactual, and why the ordering legs discriminate on
status instead.
Gates re-run on the tree being pushed, not an earlier one: gofmt clean,
lint 0 issues, internal/server and internal/mcp green, mutation matrix
still 9/9.
* ci: re-trigger CI after a GitHub startup_failure (IDEA-2756)
No code change. The Go job on
|
||
|
|
e747a1610c |
feat(session): registry keyed on the harness session, carrying the agent name; pad session list / prune (TASK-2767) (#1200)
## Summary TASK-2767 (IDEA-2750 part 2, with part 3 riding along — the keying fix and the reaping are one mechanism). The local session registry (`~/.pad/sessions`) was keyed on the pid of the `pad session register` subprocess, which is dead before anyone reads the file. One session left a new file per call and its own pid appeared in none of them; the only live identifier was the harness pid a reader could parse out of the socket path's basename. In practice nothing wrote it (zero callers in `plugin/`, `skills/`, or hooks) and nothing read it. Now: - **One record per session, keyed on the harness session pid** — `$PAD_SESSION_PID` (harness-agnostic override), else `$CLAUDE_PID` (verified present in both the tool shell and a live plugin monitor's `/proc/<pid>/environ`), else the calling process. A set-but-invalid value is an error, not a silent fall-through. - **The record carries the agent name** the session's writes are attributed to (`ResolveAgentName`: `.pad.toml agent_name` → `$PAD_AGENT` → detected runtime; `--agent` overrides, `--agent ""` is anonymous), the harness session id, and the messaging socket's identity (inode/device/mtime — the same binding the arm-state file uses). - **One owner-identity type, one verdict.** `internal/cli/session_owner.go`: `SessionOwner` + tri-state `OwnerLiveness` (`alive` / `dead` / `unknown`). `armStateOwnerAlive` is now `OwnerLiveness(...) == alive` with its file contract preserved (socket identity else mtime; headless pid + start token; fail closed). The registry pruner takes the opposite posture on `unknown`: on Windows `pidAlive` reports dead for every pid, and a reaper built on that would delete every live session's record. - **Verbs:** `pad session register [--agent]` (writes/refreshes; prunes dead records), `pad session list [--agent] [--cwd] [--all] [--format json]` (liveness per row, newest first; dead hidden unless `--all`), `pad session prune [--older-than DUR]` (dead always; unknown only under an explicit bound; alive never). Nothing on MCP — host-local filesystem state. - **Who registers:** `plugin/scripts/pad-monitor.sh` runs `pad session register` on start, BEFORE the consent gate — presence is a fact, consent is a grant, and the record is local/0600/never on the wire. - **Legacy v1 files** list as `legacy` rows: owner = socket-basename pid (else registrar pid), liveness by pid only (v1 recorded no socket identity, and the socket-without-identity rule would have judged every legacy record dead while its session ran). A legacy row can say a session exists, never who it is. Lead rulings on the four open decisions, all as built: `agent`/`--agent` vocabulary; no server-presence merge in `list`; register from the monitor script before the gate; wire follow-on (agent name on the stream) filed separately as IDEA-2750 part 2b. One ordering change from the plan's section A: pid precedence is `PAD_SESSION_PID` > `CLAUDE_PID` > self (explicit override beats detection, mirroring `PAD_AGENT` over runtime detection); the plan listed `CLAUDE_PID` first. ## Behaviour changes for existing users of `~/.pad/sessions` / `pad session register` - Registry files are keyed on the **harness session pid** (`PAD_SESSION_PID` → `CLAUDE_PID` → self), not the `pad` command's pid; repeated registrations overwrite one record instead of accumulating. - `pad session register` records the agent name, harness session id and socket identity; stores the **real path** of the cwd; prints a different text line and a different JSON shape (the full `SessionRecord`); and **rejects** an invalid `PAD_SESSION_PID` / `CLAUDE_PID` instead of silently keying on itself. - Existing v1 files are read as `legacy` rows (owner = socket-basename pid, no agent name) and dead ones are pruned by the next register. - The plugin monitor now registers (and prunes) on every start, before the consent gate. - `armStateOwnerAlive` now delegates to the shared `OwnerLiveness`; the consent gate's observable behaviour is unchanged on every platform and key type (codex round 4 traced every caller; matrix M29 pins the socket-keyed mapping). https://claude.ai/code/session_016zc6oxBvpax6Z3iQMsAJno |
||
|
|
bb003dd6bb |
fix: five claims the final comment-truth round found (BUG-2724, BUG-2726)
The bounded process the lead set: N rounds, an author prune pass, one final comment-truth round. This is that round's output, and the loop stops here. Two were mechanisms I had wrong, and both are the kind a reader would reuse without re-deriving: - "Different Redis DB numbers do not help" was half true. Ordinary keys ARE DB-scoped, so two installations on different DBs keep separate presence registries; it is pub/sub that ignores DBs entirely, which is why the buses cross-feed regardless. Stating it as "does not help" made the namespace look like the only fix for a problem it only half is. - A namespace cutover's client resync was attributed to the epoch check. That check needs an OLD epoch to compare against and a freshly namespaced bus has none — the resync comes from the cold replay-buffer coverage check instead (knownFrom is zero, so every resume falls below it). Same honest outcome, different mechanism, and the mechanism is what someone reasoning about a cutover would use. Three were stale or over-general after earlier changes: the admission comment still said the global limit is passed to the bus as 0 (that parameter is gone), `pad watch --help` and the plugin monitor description lumped a missing .pad.toml's hourly retry in with the 5s-to-5min backoff, and CLAUDE.md said clients must back off without the browser exception docs/deployment.md spells out. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X |
||
|
|
3e3170e915 |
fix(server,cli,docs): the consumer contract, per codex round 12 (BUG-2726)
An angle no earlier round took: what does a CLIENT see. Two of the five
findings were about consumers I had never opened.
- `pad project watch` returned "event stream returned 429: {json}" and
exited, which sends the reader looking for a bug rather than at a
limit. It now says what happened and which knobs govern it, and names
the fact that those knobs cover this stream and the agent watch stream
together. It still exits rather than backing off — it is interactive,
and a human can decide — unlike the unattended monitor, which already
folds 429 into its ladder.
- Both endpoints now answer a refusal through one helper: same status,
same code, same message, plus `Retry-After`. `/api/v1/events` was
setting `Content-Type: text/event-stream` BEFORE the admission check,
so its 429 carried the JSON error envelope under an SSE content type —
a different contract from its sibling's for the same refusal. Admission
moved above the headers, which is where it belonged anyway.
- The anonymous-caller rule was documented as if it applied to both
endpoints. It applies to `/api/v1/events` only; the watch stream
requires a resolved user and answers 401 without one.
- docs/architecture.md described one SSE endpoint and one bus. It now has
the table: two streams, two buses, different scopes and consumers, one
shared connection budget, one Redis namespace.
FILED, not fixed: the web UI's `EventSource` cannot see a 429 or a
`Retry-After` — the spec exposes neither to the page — so a refused
browser tab reconnects at a constant rate while the CLI backs off. That
asymmetry means reaching the limit sheds load from the population that
respects it and not from the one that grows fastest under it. No
server-side change closes it; the fix is a client-side reconnect wrapper.
BUG-2733, and docs/deployment.md warns operators to size the limit with
it in mind.
Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
|
||
|
|
a790810bd6 |
docs: close the cross-artifact gaps codex round 6 found (BUG-2724, BUG-2726, BUG-2727)
The angle no earlier round probed: which artifacts a human or agent CONSUMES should have changed and did not. Five, and the pattern is the one my own record keeps naming — the caveat existed in the artifacts I was editing and not in the ones that get read. - .env.example had neither new variable and still described PAD_SSE_MAX_CONNECTIONS with its old single-endpoint meaning. It is the file an operator copies; docs/deployment.md being right does not help someone who never opens it. - docs/deployment.md called the readiness endpoint /health/ready. The route is /api/v1/health/ready, so every instruction to go read the new redis block pointed at a 404. Corrected there and in four code comments, and the Health Check section now actually shows the three endpoints, the healthy payload, and the degraded one — it previously demonstrated only /api/v1/health, which is the build-info endpoint and says nothing about readiness. - CLAUDE.md listed /api/v1/events and not /api/v1/events/stream at all, so the endpoint this unit bounds was undocumented in the file agents read first. Added, with the limits and the 429 contract. - `pad watch --stream --help` said silence means "no workspace linked or padd unreachable". A capacity refusal now produces the same silence through the same backoff, so the help was enumerating a set that had quietly grown. - The plugin skill told agents "silence means nothing changed" — now false in the same way, and worse, because an agent repeats it to a user as though the quiet were evidence. Rewritten to say what silence does and does not prove. The plugin monitor description had the same enumeration and got the same fix. Checked rather than assumed: there are two SKILL.md files, and only the plugin copy carries a notifications section — the embedded one has no monitor guidance to correct. NOT changed, and raised with the lead instead: deploy/k8s/deployment.yaml points both probes at /api/v1/health, so the readiness endpoint is never consumed. Fixing it is right but it changes rollout behaviour for anyone using the shipped manifest (a database blip would start pulling pods from the load balancer), which is a deployment-posture call rather than part of this unit. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X |
||
|
|
6e7d34c6d1 |
fix(mcp): accept a fields object on pad_item create/update; reject undeclared input keys (#1066) (#1159)
* fix(mcp): accept a fields object on pad_item create/update; reject undeclared input keys (#1066) Reads return fields as a native object (BUG-991 normalization), so writing that shape back is the obvious call — and it was a silent no-op: not a declared param, no additionalProperties, accepted, never mapped by BuildCLIArgs, dropped while the PATCH still bumped updated_at. Two halves, one contract change (ToolSurfaceVersion 0.21 -> 0.22): - pad_item create/update fold a fields OBJECT into the same path as field: ["key=value"] / the dedicated params, at the catalog layer so both transports get it. The same key in two places with conflicting values is refused with a structured error; equal duplicates collapse. Non-writer actions refuse a fields param loudly rather than letting the now-declared key be dropped at dispatch. - The fan-out handler rejects undeclared top-level keys across all catalog tools with a structured validation_failed naming them — closing the silent-drop mechanism for every future variant. Compat carve-out: pad_item's documented v0.16 assigned_user_id / agent_role_id remote clear form stays accepted. Docs updated in lockstep per the TASK-2005 drift guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): review round 1 — export output passes the strict gate; empty fields keys refused; limitations documented Per the PR #1159 round-1 review: - Bug 1: add output to pad_item's compat allowlist with a paper-trail comment — actionItemExport exists to override that key to '-', so the strict gate was killing agent export calls before the override ran. New test drives the REAL fan-out dispatch path so the gate and handler are tested together. The analogous import/file key stays rejected deliberately: the schema steers agents to artifact, and the rejection hint names it. - Bug 2: refuse empty keys in a fields object — {"": "v"} previously passed the '='-in-key check and emitted a malformed field entry. - Scope note: non-scalar round-trip limitation named in the merge contract and the fields param description; array/JSON encoding stays a follow-up. - Intent question: the promoted-key shadowing trade-off is accepted and now written into the merge-contract comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: xarmian <xarmian@gmail.com> |
||
|
|
de96cce900 |
fix(items,server,web): reserved metadata survives a move; referential metadata travels only within its context (BUG-2674) (#1165)
* fix(items,server): reserved metadata survives a move, and dropped fields are reported (BUG-2674)
Moving an item destroyed its implementation notes, decision log and linked-PR
metadata. Well-formed data, on a routine documented operation, silently, with a
success message.
Reproduced before the fix: a note written through `pad item note` — correct
shape, visible on every surface — was gone after `pad item move`, leaving
fields as `{"status":"new"}`.
## Why it happened
items.MigrateFields drops every key absent from the TARGET schema. The reserved
keys — implementation_notes, decision_log, github_pr, convention — are system
metadata that NO collection schema declares; each renders from its own dedicated
surface rather than as a generic field. So they are absent from every targetDefs
and were dropped on every move.
That blindness is structural, not incidental: any code path reasoning about
fields BY CONSULTING A SCHEMA cannot see these keys. It is the shared root of
this bug and of BUG-2627, where the CLI types a --field value by schema lookup
and these keys fall through to a raw string.
## The enumeration comes first, deliberately
Before this there were four constants and exactly ONE non-test consumer treating
them as a set — an inline || chain in a CLI display path. Naming the set inline
again here would have created the SECOND hand-maintained list, which is the
generator pattern behind both bugs reproduced inside its own fix: the next
reserved field lands in the constants, gets wired into whichever surface
prompted it, and silently misses the other.
So models.IsReservedItemField is now the single place that knows, MigrateFields
consults it, and the CLI's || chain is converted to it — the only way it is
provably THE list rather than A list. (formatChangeValue keeps its per-key
switch: it needs to know WHICH reserved key it has, to say "notes" vs "entries",
not whether the key is reserved.)
`convention` is IN the set, settled with evidence rather than by the principle
alone: 35 of 36 conventions in a live workspace do not store the key at all, and
the one that does holds a blob that is a redundant mirror of the alias keys
beside it. No user types a `convention` object — ApplyItemConventionMetadata
writes it, via library activation and the web form. System-stamped.
## Contract
System-minted non-referential data carries; anything dropped is reported.
PLAN-2357 DR-17 settled the analogous case — tags carry because "there is no
workspace-scoped foreign key to break, so dropping them would lose information
for no safety reason". These are the same shape: inert JSON with nothing that
could dangle in a destination. The plan's carry list simply never considered
them, so there was no deliberate semantics to defer to. DR-17's own heading is
"None of this may be silent."
## The reporting half
MigrateResult.Dropped has always existed and the single-move handler has always
thrown it away, so the only record of a field disappearing was the field being
gone. It now rides the move's audit metadata — not the response body, which is
the bare item and would break every consumer, and the activity timeline is where
someone asking "what happened to my item" looks. Joined into one string because
that map is map[string]string and a raw array renders as a Go map literal in the
timeline (BUG-2628).
## Verified
Unit: reserved keys carry with their payload INTACT (asserted on the value, not
merely the key — a carry that re-encoded or zeroed it would pass a presence
check), and bypass schema matching entirely, so a target declaring
`implementation_notes` as `text` cannot coerce them. Mutants run: guard removed
-> both new tests fail; carried-but-also-reported-dropped -> the not-dropped
assertion fails; carry-everything -> the control leg fails alongside three
pre-existing tests.
Live, against a server built from this branch: the note survives the move
byte-identical, and the move's activity metadata carries
`dropped_fields: "priority, status"` for the values the target schema genuinely
has no home for.
## Known scope limit
The BULK move path still discards its Dropped list — a reporting gap only, since
the carry-through lives in MigrateFields and bulk inherits it. Threading the list
out crosses two function boundaries whose signatures serve every bulk operation,
so it is a refactor of the bulk dispatch's return contract rather than a line.
Filed as BUG-2683 rather than smuggled in here.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* fix(server,web): close the four gaps Codex round 1 found on the carry-through (BUG-2674)
Round 1 raised no P1 and four P2s. Three were real defects introduced or exposed
by the carry-through; one was a genuine overclaim in the previous commit. All
four closed here, each mutation-verified rather than asserted.
## A schema may no longer declare a reserved key
MigrateFields carries these keys by identity, but every caller then validates
against the target schema — and ValidateFieldsDetailed iterates schema.Fields,
so it DOES see a declared key. A target declaring implementation_notes as `text`
would receive the carried array and reject it, turning a move that previously
destroyed the notes into one that fails outright. That is a worse failure than
the one being fixed: loud, but it blocks an operation that used to work.
The gate already existed — validateNoReservedFieldKeys, with its
grandfathering — and listed only parent/plan. The four metadata keys join it,
sourced from models.ReservedItemFieldKeys() so the two lists cannot drift.
Forbidding the declaration is the honest fix; coercing the value, or skipping
validation for a key the schema genuinely declares, would be guessing at which
meaning the author wanted.
The web's RESERVED_FIELD_KEYS gains the same four, preserving the existing
deliberate asymmetry (the client lowercases and is therefore stricter than the
server's exact match) so the UI steers authors away before the 400.
## The copy preflight no longer under-reports
`carried` is built by walking the DESTINATION SCHEMA, and these keys are declared
by no schema anywhere — so after the carry-through they appeared in NEITHER
bucket. A copy of an item whose content is its notes would report "nothing
carries over" while in fact retaining them. Before the carry-through they at
least showed under `dropped`, accurately. Reporting in neither is a regression
in the preflight's honesty, which is the same defect class as the move that
reported nothing.
They are now appended to `carried` after the schema-ordered entries, marked
`type: "system"` with a rendered label since they have no author-supplied one.
The bucket's doc comment says so: a client must no longer assume every `carried`
entry resolves to a destination FieldDef.
## The audit report now reaches a human
The previous commit claimed the activity timeline is where someone asks "what
happened to my item" — true, and the timeline renderer ignored the key, so the
report existed only for API and CLI consumers. Stored-but-invisible is not
reported. TimelineActivityCard renders the dropped keys on a move.
## Test aliasing
The "untouched" assertions compared the result against the SAME objects passed
in, so an in-place mutation would change both sides and DeepEqual would stay
true. The expectations are now independent deep copies — the only thing that
makes "untouched" mean untouched.
## Mutants, each run
Preflight pass removed -> the carried assertion fails. Timeline block disabled
-> the render assertion fails. Timeline action guard dropped -> the non-move
negative leg fails (a presence-only test would have passed it). Reserved-set
helper returning everything -> the IsReservedItemField control leg fails.
## Not fixed here
Codex's remaining observation — that a cross-workspace copy now carries
github_pr into a workspace whose repository it does not describe, and leaves a
convention blob detectable on an item outside the conventions collection — is a
product question about what a copy MEANS, not a defect in this mechanism. Raised
for a ruling rather than decided inside a bug fix.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* fix(items,server): referential system metadata travels only within its context (BUG-2674)
Lead ruling on the copy-semantics fork Codex round 1 raised. It does not add an
exception to the carry rule — it applies the qualifier the rule already had.
The contract was "system-minted NON-REFERENTIAL data carries". github_pr is
referential: it names a repository that is a property of the SOURCE workspace's
project, and it hydrates into code_context and renders as a live PR link. Carried
into another workspace that link is a false statement about the destination's
project, not preserved information. implementation_notes and decision_log
describe the item's own history and are true wherever the item is.
So the rule stays one sentence: non-referential system data carries everywhere;
referential system data carries only where its referent's context still holds.
## Scope is a required argument
MigrateFields takes items.MigrateScope. Required rather than defaulted because
BOTH wrong answers lose something: SameWorkspace on a cross-workspace copy
carries a PR link into a workspace it does not describe, and CrossWorkspace on
an ordinary move DROPS metadata from an item whose repo context never changed. A
caller that must name its scope cannot pick one by omission.
The two move handlers pass SameWorkspace as a property of the endpoint, not a
guess — a move changes an item's COLLECTION and cannot change its workspace.
The copy and its preflight COMPUTE it by comparing workspace ids rather than
assuming cross-workspace, because that endpoint accepts a target_workspace equal
to the source; hardcoding would drop a github_pr from a same-workspace duplicate.
Both sides use the same helper, or the preview promises a carry the copy drops —
the DR-6 divergence the shared endpoint exists to prevent.
## The drop is reported, with a reason that explains itself
PLAN-2357 DR-17: "None of this may be silent." It would be perverse to
reintroduce a silent drop inside this fix's own new branch.
The preflight reports it as `referent_not_portable` rather than the generic
`no_target_field`. That generic reason would be actively misleading here: no
schema declares these keys ANYWHERE, so "the destination has no such field" is
equally true of the source and explains nothing about why the value is being
left behind.
## Verified
Mutants run: scope ignored (always carry) -> the cross-workspace leg fails;
generic reason on the preflight drop -> the reason assertion fails. The
same-workspace leg and the non-referential-sibling leg are what stop an
implementation that ignores scope in EITHER direction from passing — each half
alone is satisfiable by a constant.
Gates re-run for THIS commit: lint 0 · go test ./... 0 · make test-pg 0 (3282).
Web gates NOT re-run and not claimed: this commit touches no web file (the web
half of BUG-2674 shipped in
|
||
|
|
625cab9984 |
fix: bound item history and stop resolving bodies nobody reads (BUG-2608) (#1147)
* fix: bound item history and stop resolving bodies nobody reads (BUG-2608)
Item history was unbounded on every surface, and summary mode paid for what it
discarded: the endpoint resolved EVERY version by walking the item's whole
reverse-patch chain, and both the CLI and the MCP dispatcher then projected
that away to metadata. An item edited under collab records a version every few
seconds while someone types, so this is routinely hundreds of full-content
reconstructions per history call, for output that shows none of them.
Two independent fixes, because they address different costs.
SUMMARY SKIPS THE WALK. `?summary=true` returns metadata from the raw rows and
never resolves a patch. That is the dominant win: the resolution was pure waste
for every caller except --full. Content and is_diff are cleared TOGETHER — an
empty body still claiming to be a reverse patch would tell a consumer to
resolve something that is not there.
LIMIT BOUNDS THE WINDOW, newest-first. That direction is not a preference: with
reverse patches, reconstructing any version means walking back from current
content, so a newest-end window is the cheap prefix of that walk while an older
one still pays for everything above it. That is also why there is deliberately
no offset — it would advertise a pagination whose later pages cost the same as
no bound at all.
Absent limit stays UNBOUNDED on the endpoint, following the item-list
precedent (maxItemListQueryLimit: "a zero/absent limit is left unbounded — this
only clamps an explicit oversized request"). The defaults live on the CLIENTS,
where a token budget is actually known: `pad item history` defaults to 50 with
--limit to change it, and the MCP catalog action injects 50 (max 300, the same
pair list and backlinks already use). A server that truncates a request nobody
bounded is a silent-truncation trap for third-party API consumers.
The MCP default goes in the CATALOG action rather than either dispatcher, so it
reaches BOTH transports — HTTP reads it off the input, and stdio receives it as
the CLI's new --limit through BuildCLIArgs. ToolSurfaceVersion 0.20 -> 0.21
with a changelog entry, plus instructions.md and README, per the 2304-family
contract discipline. Additive param bump: `limit` already existed, nothing
changed shape, and a v0.20 consumer that sends no limit now gets the newest 50
instead of all — which is the fix, not a break in it.
The restore and single-version-expand paths still resolve the FULL chain, and a
test pins that: bounding their walk would strand exactly the old versions those
paths exist to reach.
Eight mutations, each failing only the leg it targets. Three fixture problems
surfaced that way and are worth naming, because each made a test that could not
fail:
- force_version in a PATCH body does nothing (`json:"-"` on ItemUpdate), so
the throttle collapsed six edits into one version; varying the source per
edit is what actually records them.
- an 8-byte body is cheaper stored whole than as a patch, so no version was
ever is_diff=true and the is_diff assertion was inert. The fixture now uses
a body large enough that the store really stores patches.
- the cmdhelp test fixture lacked the new --limit flag, so BuildCLIArgs
silently dropped it. Verified against the REAL cmdhelp tree that the flag
is present and typed int, so the fixture mirrors the CLI rather than
flattering it.
* docs: bring CLAUDE.md to v0.21 and name why the two result caps differ (BUG-2608)
Codex round 1, both findings.
CLAUDE.md still described the MCP surface as v0.20 — stale because of my own
bump, in the document every agent working this repo reads first. README and
instructions.md are held to the version by a test; CLAUDE.md is not, which is
exactly why it drifts.
The cap "mismatch" (MCP max 300, endpoint clamp 500) is deliberate layering,
not an oversight — item lists have the identical split (300 in the catalog,
1000 at the endpoint) because the two answer different questions: an agent
token budget is only knowable in the catalog, while the endpoint's clamp is a
server-resource ceiling on what any caller may ask for. But nothing said so
anywhere, so a reader comparing the two numbers had no way to tell design from
accident — which is precisely the report Codex filed. Now stated at the
constant and in CLAUDE.md, including why the versions ceiling is LOWER than the
list one (resolving a version can cost a patch application per row, not just a
row read) and why an absent limit is left unbounded at the endpoint.
* fix+test: honest truncation notice, armed fixtures, and the residual named (BUG-2608)
Codex round 2, both findings, and the second is the more useful one.
CLI TRUNCATION NOTICE was wrong in both directions: it compared the response
length against the requested limit, so an item with exactly N versions was
reported as truncated, and a --limit above the server's ceiling was clamped
there and reported as complete. It now asks for ONE MORE row than it shows and
reports truncation only when that extra row comes back. The one case this still
cannot detect — an ask above the server's own ceiling, where the probe row is
clamped away with everything else — is stated in the code rather than papered
over by hardcoding the server's constant in the CLI.
UNDER-ARMED FIXTURES. The unbounded test seeded 5 versions, so a server quietly
defaulting to 50 would have passed the assertion that denies exactly that; it
now seeds 60. The clamp test seeded 2 and could not observe a clamp at all;
the clamp is now asserted directly against a parseItemVersionsLimit function
extracted for the purpose, over the inputs a URL can really carry (absent, 0,
negative, unparseable, either side of the ceiling).
That extraction replaced my own first attempt, which was worse than no test: it
re-implemented the clamp arithmetic in the test body and asserted the result
against itself. It could not have failed.
THE RESIDUAL, NAMED RATHER THAN IMPLIED. Codex's sharpest point is that the
summary tests cannot detect "resolve everything, then clear the fields" —
verified by mutation: pointing the summary branch at the resolving reader
leaves every handler test green, because the response is byte-identical either
way. So the performance claim does not rest on them. It rests on the handler's
summary branch calling ListItemVersionsPage (one reviewable line) plus a new
store test proving that reader really returns unresolved rows rather than
quietly resolving them — mutation-verified from the other side by making the
resolver a passthrough. The test file says all of this, including that an
end-to-end assertion would need a patch-application counter in the production
path, and why that is not worth it when the cost of being wrong is performance
rather than correctness.
* fix(cli): don't resolve for table output, guard the probe overflow, finish the CLAUDE.md bump (BUG-2608)
Codex round 3, four findings.
--full was treated as "content needed" regardless of output format, but the
table path prints no bodies at any setting — so `pad item history --full`
without --format json made the server walk the entire patch chain to build
content the CLI then dropped. That is the exact waste this bug is about,
reintroduced through the flag meant to opt into it. Content is now resolved
only when it will actually be shown.
The limit+1 probe overflowed at MaxInt: it wrapped negative, the client omitted
the parameter, and a request the user bounded came back unbounded — the
opposite of the ask. Guarded.
The truncation notice's documented blind spot was understated: it is AT the
server ceiling as well as above it, since the probe row is clamped away with
everything else. Wording corrected rather than resolved — the CLI still does
not duplicate the server's constant, because a copied ceiling goes stale
silently and asking for hundreds of versions is already opting out of a bound.
Two more CLAUDE.md sites still called v0.19 current; I fixed only the first on
the previous pass. That document describes the contract in three places and I
updated one, which is its own small lesson about grepping for every instance of
a claim rather than the first.
Live-verified against a real server: --limit 2 truncates and says so, --limit 4
on a 4-version item says nothing (the false positive Codex found), --limit 0
returns everything, --full --format json carries bodies, and the default JSON
shape carries metadata only.
* fix(server): clamp an out-of-range limit instead of treating it as unbounded (BUG-2608)
Codex round 4. `?limit=9223372036854775808` made strconv.Atoi return ErrRange,
which my parser lumped in with 'unparseable' and mapped to 0 — unbounded. So an
absurdly large number defeated the very ceiling the function exists to impose,
which is worse than no clamp at all: the bound looked enforced and was not.
Atoi hands back the saturated value alongside ErrRange, so a range-positive
input is now clamped to the ceiling — it is an oversized ASK, not a malformed
one. Range-negative still falls through to unbounded, matching a plain
negative.
Regression cases cover both overflow directions, and the mutation that
reinstates the old behaviour fails on the exact input.
This is the input-domain lesson again: I enumerated absent, zero, negative and
non-numeric, and stopped at what a person would plausibly type rather than at
what the TYPE admits.
* docs(cli): say in --help that a large --limit is capped server-side (BUG-2608)
Codex round 6, and the third time this ceiling came up — which is the tell that
the disposition was in the wrong place. I had documented it twice in CODE
comments, where the person affected never looks. `--help` says what the flag
does, and it said 'show only the newest N versions (0 = all)', which is false
for an N above the server's cap.
Now stated in both the flag help and the command's long description, including
that the truncation notice cannot detect that case and that --limit 0 is the
way to ask for a complete history.
Deliberately not naming the number: a constant duplicated into help text goes
stale silently, and 'capped server-side' is the part a user needs in order to
act. Behaviour unchanged — this is the artifact catching up with it, which is
the same correction the BUG-2301 sentinel comment and the instructions.md
overclaim both came down to.
* test: seed versions through the store so the fixture stops tripping the rate limiter (BUG-2608)
CI caught this and it is my defect, not a flake. Arming the unbounded test
above the plausible default meant seeding 60 versions, and the fixture did that
with 60 HTTP PATCHes in a burst — which trips the server's rate limiter. It
passed locally and in the Go job and failed under Nix, which is the signature
of a test that was always going to fail intermittently rather than one that
broke.
Seeding now goes through the store. That is not a weakening: versions are
recorded by the store on either path, and the endpoint under test is the READ
side, so seeding underneath the transport costs the assertions nothing while
removing a burst the server is entitled to refuse.
The three things that make this fixture work are now stated where someone would
otherwise undo them by accident — the large body (a small one is stored whole,
so no version is ever is_diff and every diff assertion goes vacuous), the
rotating source (the throttle collapses same-(actor, source) bursts into one
version), and the store-not-HTTP seeding with the rate-limit reason attached.
Re-verified after the change: the fixture still records more than 50 versions
and still produces reverse-patch rows, and the default-cap mutation now uses
the REALISTIC default of 50 rather than the 3 I first tested with — the old
5-version fixture could only have caught an implausibly small cap.
|
||
|
|
8798de7e99 |
docs: correct the onboard playbook's mode vocabulary in CLAUDE.md (#1142)
The Onboarding section described "four modes: build/audit/revisit/ defaults". The playbook's actual arguments declaration (playbook_library_onboard.go) is a mode enum of auto/build/audit/revisit (auto default, routing any user-created item to revisit) with `defaults` a separate fast-path FLAG, not a mode. This error propagated into BUG-2574's body and from there into a skill rewrite before Codex caught it against the source (PR #1139 round 1); fixing the origin so it can't propagate again. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V |
||
|
|
f0cbcb5df4 |
fix(mcp): route backlinks/history/report over HTTP transport + catalog↔route parity test (BUG-2304) (#1126)
* fix(mcp): route backlinks/history/report over HTTP transport + catalog↔route parity test (BUG-2304) Three catalog actions were advertised on the remote /mcp transport but had no route: pad_item.backlinks, pad_item.history, pad_project.report all answered 'not yet implemented over HTTP transport' on Pad Cloud. All three have working REST endpoints — the gap was mappers, and the absence of any catalog↔routeTable parity check is how they shipped silently. - item backlinks + project report: plain routeSpecs (their CLI JSON is the endpoint response verbatim, so a GET reproduces the stdio shape). - item history: hand-written dispatchItemHistory, because the versions endpoint returns full content bodies while the CLI projects to the token-light itemVersionSummary; full=true opts back in, matching the stdio --full path. - The special-case dispatch switch is now an introspectable specialRoutes() map, and a new parity test drives EVERY catalog action through its real ActionFn, captures the dispatched cmdPath, and fails on any action missing from routeTable ∪ specialRoutes ∪ itemLinkSpecs — or any action the fixture can no longer exercise. No ToolSurfaceVersion bump: no tool names, action enums, or parameter shapes changed — advertised actions now work as documented. Also folds in CLAUDE.md catalog-version drift (still said v0.19; 0.20 shipped in BUG-2302/2305). * fixup: cache specialRoutes map (sync.Once); kind-aware item_not_found on history 404 (codex round 1) * fixup: single request path for history — full=true keeps the kind-aware 404 envelope (codex round 2) * fixup: version.go no-bump changelog note, instructions full:true note, parity-test scope comment (codex round 3) |
||
|
|
d843752091 |
docs: worktree web-tooling rules in CLAUDE.md; fix vitest.config.ts's dangling pointer (TASK-2590) (#1118)
CLAUDE.md gains the "Working in a git worktree" section that web/vitest.config.ts:41 has pointed at since the fs.allow fix — it never existed (grep worktree/npm ci/node_modules: zero hits). Content per the corrected day-38 ruling on TASK-2590, not the task's original body: the symlink stays fine and stays the recommendation; the real prerequisite is `npx svelte-kit sync` (a fresh worktree has no generated web/.svelte-kit, and vitest fails on the missing tsconfig either way — the 2x2 on the trail shows the symlink was never the variable); and npm ci through a symlinked node_modules is the one genuinely destructive move (deletes the shared tree, stalls every session), which the original "npm ci, never symlink" rule would have instructed agents to do. Both documented legs verified as written in this very worktree: fresh + symlink -> vitest fails with the exact quoted TSCONFIG_ERROR; npx svelte-kit sync -> same test passes through the symlink (and through this edited config file). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
ef903f0b22 |
feat(cli,mcp): --clear-parent / clear_parent to detach an item's parent (BUG-2078) (#1113)
* feat(cli,mcp): add --clear-parent / clear_parent to detach an item's parent (BUG-2078)
The server has honoured a present-but-empty "parent" key in fields_patch
as "clear the link" since BUG-2013, but neither the CLI (--parent ""
silently no-ops) nor MCP (parent is a plain string with the usual
"empty means not provided" convention) could reach it. Mirrors the
clear_assigned_user/clear_agent_role shape from IDEA-2584: a boolean
that carries its destructive meaning in its name and survives the trip
to local stdio MCP via BuildCLIArgs' snake_case-to-flag mapping.
Bumps ToolSurfaceVersion 0.18 -> 0.19 and updates the drift-pinned docs
(instructions.md, README.md) accordingly.
* test(cli,mcp): cover --clear-parent / clear_parent on both transports (BUG-2078)
CLI: --clear-parent sends fields_patch{"parent":""}; is absent when not
passed; conflicts with --parent and refuses without issuing a PATCH;
item create pins the deliberate create/update asymmetry.
MCP: clear_parent detaches through the real store+server (not a
recording handler) so the assertion is "item ends up unparented", not
just "payload shaped correctly"; clear_parent=false is inert; a plain
empty `parent` string stays a no-op (control leg); a simultaneous
parent + clear_parent is refused via both the direct param and the
--field-lifted route.
* fix(cli,mcp): close --clear-parent bypass via --field parent/plan aliases (BUG-2078, codex r1 P1)
extractParentLink (internal/server/handlers_items.go) resolves the parent
link from either a "parent" or a "plan" key in fields_patch, with no
early exit, so the later key in its own loop wins. The clear_parent
conflict check only covered one path each on the two client surfaces:
- CLI: the check ran BEFORE the --field overlay and only compared
against --parent's own value, so `--clear-parent --field parent=X`
(or `--field plan=X`) reached the wire unrejected — the --field loop
ran after clearParent's own `patch["parent"] = ""` and silently
overwrote it.
- MCP HTTP dispatcher: the check ran after the --field overlay (correct
ordering) but only inspected `patch["parent"]`, missing the "plan"
alias route.
Both surfaces now run the clear_parent check after every patch-building
step (named flags, --field overlay, column lift) and check both
"parent" and "plan" for a competing non-empty value.
* fix(cli,mcp): refuse --clear-parent/clear_parent when schema shadows "parent"/"plan" (BUG-2078, codex r2 #2)
extractParentLink (internal/server/handlers_items.go ~L606-610) is a
pre-existing, deliberate policy: it skips hierarchy handling entirely
when a collection's schema declares its own field literally named
"parent" or "plan", letting the value fall through as an ordinary
field write instead. Once {"parent":""} reaches the server it can no
longer distinguish clear-hierarchy intent from a legitimate
blank-my-schema-field write, so a client-side clear_parent request
against a shadowed collection used to report success while silently
blanking the data field AND leaving the real hierarchy link untouched
-- reproduced empirically before this guard existed.
The ambiguity is created at the surface that accepted the clear
request, so that surface refuses rather than pushing the decision
server-side (server-side refusal would also break legitimate blanking
of a real schema field).
CLI: the check is free -- collSchema is already fetched for --field
type parsing whenever any field change (including a bare
--clear-parent) happens.
MCP HTTP dispatcher: adds one conditional collection lookup, paid only
when clear_parent=true -- the common update path fetches no schema
today and doesn't start.
* docs: sync repo CLAUDE.md tool-surface contract to v0.19 (BUG-2078, codex r3 P2)
CLAUDE.md's MCP tool-surface prose still said "currently v0.18" and its
changelog omitted clear_parent -- a consumed-artifact gap, same rule as
the SKILL.md case: the doc a diff invalidates ships with the diff.
Synced three spots (intro paragraph, Tools bullet, ToolSurfaceVersion
stability-contract changelog) to v0.19, matching internal/mcp/version.go's
in-code entry's wording, plus the schema-shadow refusal (BUG-2078's
second follow-up commit) at the same level of detail the changelog
already gives the parent/plan alias conflict-refusal.
Grepped the rest of CLAUDE.md for any other 0.18/tool-surface reference
-- none found outside these three lines.
* docs: add schema-shadow refusal to version.go's v0.19 changelog entry (BUG-2078, codex r3 follow-up)
The in-code changelog is the canonical source; it was missing the
codex r2 schema-shadow refusal that a later commit added, which is
why CLAUDE.md and version.go briefly disagreed. Completes version.go
instead of letting CLAUDE.md drift ahead of it.
|
||
|
|
d7da237198 |
feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584) (#1107)
* feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584)
v0.16 and v0.17 made unassigning WORK. Nothing advertised it. The params
that do it — `assigned_user_id` / `agent_role_id` — were never in the
catalog, so an agent reading the tool schema to find out how saw only
`assign` (a name) and reached for `assign: ""`, which is a no-op and
deliberately stays one. The capability existed with no name an agent
could find.
`clear_assigned_user` / `clear_agent_role` booleans on `pad_item`, backed
by new `--clear-assigned-user` / `--clear-agent-role` bareword flags on
`pad item update`.
WHY BOOLEANS rather than declaring the existing string params. Two
reasons, and the second decided it:
1. An empty DECLARED string is inert everywhere else on this tool
(title, content, comment, tags), so a client that pads optional
params with "" instead of omitting them is harmless today. Giving
one a destructive meaning would turn that same client into one that
silently unassigns every item it touches. A boolean carries its
meaning in its name and can't be tripped that way.
2. Only a boolean can REACH local stdio. BuildCLIArgs emits the CLI's
real flags, so a catalog param with no flag behind it is dropped
before dispatch — declaring `assigned_user_id` would have left the
direct form remote-only, i.e. would not have closed the gap this
change exists to close. That fact reframed the design fork and is
what the ruling turned on.
Server-side this is WIRING, not new semantics:
models.ItemUpdate.ClearAssignedUser / ClearAgentRole already existed and
the store has honoured them since BUG-2566, on the same branch as the
empty-string form. The older forms keep working and are NOT deprecated;
they're just not what the schema advertises.
UPDATE ONLY, deliberately asymmetric with create, and recorded in-place
at both the flag registration and the catalog description so a
symmetry-minded reader meets the reasoning before the "fix": clearing at
create is a request to not-set something never set, whose only honest
behaviour is a no-op — it teaches a wrong affordance and pads every
create call's schema. A test fails if someone adds them there.
CLI precedence is the OPPOSITE of the --field lift's, deliberately: an
explicit `--clear-assigned-user` beats `--assign`, because that
combination is a contradiction the user typed and the reading that
cannot silently assign somebody is the safer one. Tested.
The dispatcher forwards the booleans VERBATIM rather than only-when-true.
A `&& b` guard would read as the thing protecting a param-padding client
and would be lying: what makes `false` inert is the store. Same call I
made on #1106's `len(patch) > 0` — a guard that reads as load-bearing
while doing nothing is worse than none.
ToolSurfaceVersion 0.17 -> 0.18, ADDITIVE bump per the v0.5 / v0.6
precedent: no existing tool, action or param changed shape.
Consumed artifacts moved in the same commit, which is the whole point of
this change — the schema IS the deliverable: catalog_item.go (the schema
agents read, plus an `assign` description that now says where to find the
clear), instructions.md (leads with the boolean, mentions the older forms
as still-working), version.go, README, CLAUDE.md.
VERIFIED LIVE, five legs, both transports:
CLI --clear-assigned-user -> assigned=None, role intact
CLI --clear-agent-role -> role=None
stdio clear_assigned_user:false -> assignment SURVIVES and the
update still applied (title
changed) — the control that
makes the boolean safe to
declare at all
stdio clear_assigned_user:true -> assigned=None
stdio clear_agent_role:true -> role=None
Three mutations, each failing only its own tests: dropping the dispatcher
forwarding; hardcoding true in the dispatcher (fails the false-control);
dropping the CLI flag wiring.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Closes IDEA-2584.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(mcp,cli): refuse a simultaneous set-and-clear (codex round 1)
Codex found a real bug, and the more useful half of the finding is that
MY OWN TEST FOR IT WAS VACUOUS.
The store's branch order is `if AssignedUserID != "" { set } else if
ClearAssignedUser { clear }`. So `--assign wren --clear-assigned-user`
assigned Wren and the clear evaporated. My in-place comment claimed the
opposite ("an explicit clear wins"), and the test I wrote to prove it
asserted `body["clear_assigned_user"] == true` — that the FLAG was set,
not that the item ended up unassigned. The flag was set. The behaviour
was backwards. A test that asserts a field is present says nothing about
which field wins.
Both surfaces now REFUSE the contradiction rather than silently resolving
it. Rejecting beats picking a winner here: the store already picks one
silently, which is the bug; and a caller who typed both wants to be told,
not guessed at. Precedent in the same command family — `item list`
already makes `--parent` and `--unparented` mutually exclusive.
PLACEMENT IS THE LOAD-BEARING PART, and I got it wrong first. There are
two routes to a competing value: `--assign` / `assigned_user_id`, which
resolve early, and `field: ["assigned_user_id=<uuid>"]`, which reaches
the payload via liftFieldsToColumns LATER. My first version checked
between them and its comment asserted the lift "has already" run — it
hadn't. That version rejects the direct case and lets the lifted case
through: a half-fix that reads as complete. The check now runs after
both, in the CLI after --assign/--role resolution and the lift, in the
dispatcher immediately before the body marshal.
That mutation is now a test: moving the dispatcher check back to the
pre-lift view fails ONLY the two `lifted …` subtests and passes the
direct one — the exact shape of the bug I nearly shipped.
Tests assert the OUTCOME, not the message: a refused conflict must leave
the item's assignment AND role untouched, and the CLI must issue no PATCH
at all. An error string alone wouldn't prove the write didn't happen.
Agent-facing text moved with it (the consumed-artifact step): both
catalog descriptions, instructions.md, and the v0.18 version entry now
say the combination is refused. An agent that pairs them gets a
structured refusal, so the schema has to say so.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
847ee73327 |
fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583) (#1106)
* fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583)
`pad item update TASK-9 --field assigned_user_id=<uuid>` wrote the pair
into the item's FIELDS JSON BLOB while the column stayed stale, and then
printed "Updated TASK-9". Two defects in one line: a success message for
a write that did nothing the caller asked for, and a blob key shadowing a
real column's name, so the CLI surface diverged from store/HTTP/MCP
truth. The empty-string case was the same defect wearing a worse hat —
it was the only route an agent had to unassign an item.
Blast radius beyond the CLI: local stdio MCP (`pad mcp serve` — Claude
Desktop, Cursor, Windsurf) dispatches through ExecDispatcher, which
shells out to this CLI. So TASK-2571's fix reached the remote /mcp
transport only, and the transport most agents actually use still could
not unassign. This closes that half.
`cmd/pad/cmd_item.go` now lifts `columnFieldKeys` out of the --field map
onto the column pointers, on CREATE and UPDATE both, mirroring
internal/mcp/dispatch_http.go's liftFieldsToColumns — including its
INVARIANT, which is the part that matters: only keys with defined
clear-to-NULL semantics for "" belong in the list, and `tags` never does
(an empty write corrupts a JSONB column rather than clearing it). A test
fails if anyone adds it.
Two compat changes, ruled separately by the lead:
Q1 non-empty values move to the COLUMN and stop writing the blob key.
Accepted: relying on the old behaviour is relying on a shadowing
defect.
Q2 empty values clear the column. Falls out of the lift, inheriting
BUG-2566's store semantics.
`agent_role_id` gets identical treatment. Existing stray blob keys are
left alone per the ruling — this stops minting new ones; a sweep would
be its own change.
Precedence is explicit and tested: `--assign` / `--role` win over a
lifted --field value, matching liftFieldsToColumns' "caller-supplied
top-level values win". It is delivered by the ORDER of two blocks in the
command, which is exactly the kind of thing that gets reordered by
accident, so there is a test whose only job is to fail when it does.
A non-string --field value is deliberately NOT lifted: a collection that
genuinely declares a field with one of these names makes parseFieldFlag
return a typed value, which cannot address a column. It stays in the
blob — today's behaviour and the only lossless option.
ToolSurfaceVersion 0.16 -> 0.17, and v0.16's transport-scope paragraph
now points forward rather than claiming a limitation that no longer
holds. Behaviour-only bump again, same grounds as v0.16 and v0.9. The
CLI's own marker, CmdhelpVersion, deliberately does NOT move: its
contract is flag/arg SCHEMAS, and no flag or argument changed shape.
instructions.md — the text agents receive at handshake — drops the
"remote only" caveat it carried since TASK-2571. That file is the reason
this PR exists in the shape it does: it is the artifact the actor reads,
and it was the one place the previous PR overclaimed.
VERIFIED LIVE against a running server, with a negative control, because
the claim is about a transport rather than a function:
legs, fixed binary
--field assigned_user_id= -> column CLEARED, blob clean
--field assigned_user_id=<uuid> -> column SET, blob clean
--field agent_role_id= / <uuid> -> same, sibling column untouched
stdio MCP tools/call pad_item
action=update field=["assigned_user_id="]
-> column CLEARED, blob clean
control, PRE-FIX binary, same server + same item + same JSON-RPC bytes
-> column UNCHANGED, blob polluted
with {"assigned_user_id":""}
Six unit tests in cmd/pad/item_column_fields_test.go, four mutations each
failing only its own test (no lift; drop non-strings; flip the
lift/assign precedence; add `tags` to the list). One assertion was
rewritten after mutation testing showed it was VACUOUS: `len(fields_patch)
!= 0` passes whether the key is absent or present-and-empty, so it now
asserts key PRESENCE — confirmed by mutating `omitempty` off the model
field and watching the old form stay green. The redundant `len(patch) > 0`
guard that assertion was meant to cover is gone too; `omitempty` already
does that job, and a guard that reads as load-bearing while doing nothing
is worse than no guard.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* test(cli): cover the create half of the column lift (BUG-2583)
Codex came back CLEAN, but the review reminded me I'd changed `item
create` and only tested it through `liftColumnFields` directly — no test
asserted what create actually puts on the wire. That's the weaker half to
leave uncovered, not the stronger one: on update a wrong write contradicts
a visible prior value, while on create the column-named key is simply
baked into the blob at birth with nothing to contradict it.
The assertion has to parse rather than index, because ItemCreate.Fields is
a JSON-encoded STRING and not a nested object — a body["fields"]["…"]
lookup would have been vacuous in a way that looks fine.
Mutation-tested like the rest: neutralizing the create-side lift fails
this test and only this test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): say WHICH form of the unassign works on which transport (codex round 2)
Codex round 2, and it is the same class of defect as the previous PR's
round 2 — an overclaim in the artifact agents actually read. My
instructions.md said "works on BOTH transports" of two forms that do not
behave the same:
field: ["assigned_user_id="] clears on BOTH transports
assigned_user_id: "" clears on REMOTE ONLY
The direct params are not declared in pad_item's schema. They reach the
remote mapper only by riding the verbatim input map; on stdio,
BuildCLIArgs drops unknown keys, so the call does nothing.
VERIFIED, not accepted on the reviewer's word, and the verification
corrected my own first reading. My initial probe appeared to show the
stdio call CORRUPTING the fields blob — but that blob key was leftover
state from the earlier pre-fix control leg, not something the probe
wrote. Re-run against a freshly created item, the two forms separate
cleanly:
before assigned=b6786b13... fields={priority,status}
after assigned_user_id:"" assigned=b6786b13... fields={priority,status} (clean no-op)
after field:["assigned_user_id="] assigned=None fields={priority,status} (cleared)
So the stdio behaviour of the direct param is a DROP, not a corruption —
worth stating precisely, because "it corrupts the blob" would have sent
the next reader hunting a bug that isn't there. (Identity-doc rule: a
guessed mechanism stated as the reason is a claim, not a hedge.)
instructions.md now leads with the form that works everywhere and names
the remote-only limitation of the other; version.go and CLAUDE.md say the
same. IDEA-2584 — declare the params properly — is the fix that would
collapse this distinction, and is now cited from all three.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(cli): don't lift a field the collection actually DECLARES (codex round 3)
Nothing reserves `assigned_user_id` or `agent_role_id` as field names, so a
collection may legally declare a field with one of those keys. For that
collection `--field assigned_user_id=foo` means the DECLARED field — and
the lift I just added would redirect it to the assignment column while
dropping the value the user set. Two wrongs from one line: the intended
write vanishes and an unintended one happens.
liftColumnFields is now schema-aware and never lifts a declared key. Cheap
to do here because both call sites already fetch the collection schema for
parseFieldFlag. The check is PER-KEY — an undeclared sibling still lifts,
so one collision doesn't disable the feature — and a schema-fetch failure
degrades toward lifting, matching how the rest of --field handling degrades.
This makes the CLI deliberately STRICTER than the MCP dispatcher it
otherwise mirrors. liftFieldsToColumns has the identical collision and
can't make the same check as written: it builds its fields map straight
from the tool input without fetching a schema. Filed as IDEA-2587 rather
than fixed here, because closing it costs a round-trip on a hot path while
the CLI fix was free — and recorded so the divergence is KNOWN, in the safe
direction, rather than something a later reader "fixes" by loosening the
CLI to match.
The old non-string branch stays as belt-and-braces: parseFieldFlag only
returns a non-string for a declared field, which the new check already
catches, but if that stops being true a non-string still can't address a
column.
Mutation-tested: ignoring the schema declaration fails the new test and
only that test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
ee05c58446 |
fix(mcp): let an agent clear an item assignment (TASK-2571) (#1104)
* fix(mcp): let an agent clear an item assignment (TASK-2571)
Two filters in the MCP dispatch path dropped an empty-string assignment
value before the request body was built, so an MCP agent had no way to
UNASSIGN an item — `assigned_user_id=""` was a silent no-op rather than a
clear or an error:
- mapItemUpdate's top-level pass-through (dispatch_http_advanced.go)
- liftFieldsToColumns (dispatch_http.go), which lifts `--field` entries
onto their columns. This is the path an agent actually reaches: the
catalog exposes `assign` (a name) and `field`, but no
`assigned_user_id` param, so `field: ["assigned_user_id="]` is the
only schema-visible way to ask.
Both were right when written — `""` had no defined meaning at the store
and bound an empty string into a FK column. BUG-2566 gave `""`
clear-to-NULL semantics for exactly these two columns and the HTTP
surface inherited it, which left MCP the odd surface out. Uniformity
restoration, not a new feature.
Compat posture ACCEPTED per the lead's ruling: a caller sending `""`
today gets a no-op, and after this gets a clear. That is the correct
reading of the input — nobody sends an empty assignment ID meaning
"leave it alone" — and the no-op is the surprising half. Option (b)'s
clear_assigned_user / clear_agent_role schema flags are deliberately
skipped as additive sugar.
The empty-string filter on `tags` three lines above STAYS (codex #547 r3
P2): `tags: ""` is not a clear, it is a corrupt write into a JSONB
column on Postgres and TEXT on SQLite. Same-looking guard, opposite
justification — the new test's control leg fails if someone "unifies"
them.
ToolSurfaceVersion 0.15 -> 0.16. No tool, action, or parameter shape
changed, so this is a BEHAVIOUR bump on the v0.9 precedent (which moved
for a return shape with an unchanged signature). Flagging it for the
lead as my call, not theirs — it is a one-line revert if they read the
contract differently.
TRANSPORT SCOPE, established live rather than assumed: this fixes the
REMOTE /mcp transport, where both filters lived. LOCAL STDIO MCP still
cannot clear, because ExecDispatcher shells out to the CLI and the CLI
has no unassign at all — `--assign`/`--role` skip on empty, and
`pad item update TASK-9 --field assigned_user_id=` writes
{"assigned_user_id":""} into the item's FIELDS BLOB while the column
stays set (observed against a running server). Separate defect, CLI-wide
blast radius, filed separately rather than riding along on a ruled-scope
PR. The version-history entry says so explicitly so the note can't be
read as covering it.
Tests: internal/mcp/dispatch_http_clear_assignment_test.go drives the
REAL server + store, not a recording handler — asserting the dispatcher
merely puts `""` in the payload would restate the fix rather than test
it. Three mutations, each failing only its own test: restoring the
top-level filter fails the two direct-param tests; restoring the lift
filter fails the --field test; removing the tags filter fails the
control leg.
go test ./internal/mcp ./internal/store ./internal/server — all pass.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): record why an empty `assign` alias still doesn't clear (codex round 1)
Codex's finding is REAL: the catalog exposes `assign` / `role`, not
`assigned_user_id` / `agent_role_id`, so an agent reading the schema will
reach for `assign: ""` to unassign and get a no-op. The fix as shipped
only covers the params an agent has to already know exist.
Its suggested remedy — map the empty aliases to a clear — is the riskier
of the two it lists, and I've deliberately not taken it.
`assign` is SCHEMA-DECLARED. Every other schema-declared string on this
mapper (title, content, comment, tags) follows one convention: empty
means NOT PROVIDED. An MCP client that fills declared optional params
with "" instead of omitting them is harmless today; making `assign: ""`
mean "clear" would turn that same client into one that silently
unassigns every item it touches — destructive, silent, and inconsistent
with the four params beside it. That is exactly why the same change IS
safe for `assigned_user_id`: an agent can only send it deliberately.
The remedy that closes the gap without that hazard is the other one
codex names — explicit clear_assigned_user / clear_agent_role params,
i.e. option (b) on TASK-2571, which the lead deferred as additive sugar.
This finding is new evidence for revisiting that, so it goes to the lead
as a decision rather than being taken unilaterally in a ruled-scope PR.
Adds the reasoning at both call sites and a test that pins the limit, so
a future "finish the job" edit fails a test and has to be a decision
rather than a drive-by. The MCP instructions already name the working
form meanwhile.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): scope the unassign instructions to the transport where it works (codex round 2)
Codex round 2, and it caught a defect in my own round-1 documentation
fix. instructions.md is the text sent to agents at handshake, and BOTH
transports serve the same string — so telling agents "pass
assigned_user_id: '' to unassign" was true on remote /mcp and a lie on
local stdio, where ExecDispatcher shells out to a CLI that has no
unassign path. I had scoped the claim carefully in version.go and the
commit message and then overclaimed in the one place agents actually
read.
The instructions now name the transport, say plainly that stdio ignores
the value, and tell the agent to verify rather than assume. An agent can
act on a conditional; it cannot act on a claim that is false half the
time.
Both gaps are now filed rather than merely described:
BUG-2583 — the CLI has no unassign at all, and `--field
assigned_user_id=` writes into the item's FIELDS BLOB
while the column stays set (verified live: fields became
{"assigned_user_id":"", ...} and the CLI printed
"Updated TASK-9"). This is what makes stdio MCP fail.
IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
so an agent reading the schema still cannot discover the
clear. Reopens option (b) with codex's evidence.
version.go and CLAUDE.md now cite both refs, so the version-history
entry can't be read as covering more than it does.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): cite BUG-2583 / IDEA-2584 in the version history and CLAUDE.md
Follow-up to the previous commit: its scripted edits to version.go and
CLAUDE.md silently no-op'd (a gofmt rewrap moved the anchor text), so
only instructions.md actually changed. Caught by grepping for the refs
rather than trusting the commit.
Both files now name the two filed gaps, so the v0.16 entry cannot be
read as covering more than it does:
BUG-2583 — the CLI has no unassign, which is why local stdio MCP
still can't clear.
IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
so the clear stays undiscoverable from the schema.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
5044e223eb | docs(cli): document copy content semantics (TASK-2355) | ||
|
|
1e48a7a1dd |
feat(cli): add pad item copy for cross-workspace copy and move (TASK-2366)
Wraps PLAN-2357's two endpoints behind one command:
pad item copy <ref> --to-workspace <slug> --collection <slug>
[--dry-run] [--archive-source] [--field key=value ...]
--dry-run renders the preflight's three contract buckets (carried /
dropped / needs_value) and DR-15's full warning set. Every bucket header
and every warning line prints unconditionally, zeros and empties
included: omitting a zero would make "no attachments" indistinguishable
from "this CLI does not report attachments", and DR-17's whole point is
that none of it is silent. Schema-supplied strings are escaped and list
members quoted, so a comma or newline in an option value cannot forge an
entry or a row.
--format json emits the endpoint's own response. json.Indent is a lexical
transform, so key order, unmodelled fields and int64 precision all
survive; the bytes are never round-tripped through a Go value.
DR-13, the no-retry obligation. There is no idempotency key, so a blind
re-run duplicates the item. Four mechanisms, each with a test:
1. the mutating copy runs on its own *http.Client AND its own
transport. The transport half is the one that matters: retry in Go
is almost always a RoundTripper wrapper, which a merely-dedicated
http.Client would inherit. A plain *http.Transport is cloned so
proxy/TLS config carries; a wrapper is not used at all;
2. its body is hidden behind an opaque reader, leaving Request.GetBody
nil so net/http's own nothing-written replay cannot fire;
3. redirects are refused rather than followed with the POST body;
4. failures are classified into three exclusive outcomes, because each
licenses a different thing to say. UNKNOWN (transport failure, 500
copy_failed) sends the user to check the destination and never
suggests a retry. COMMITTED-BUT-UNREPORTED (a 2xx whose body could
not be read or decoded) exits ZERO -- a non-zero exit would tell a
script the copy did not happen, which is the DR-13 duplicate
arrived at through the reporting layer. A 4xx is a refusal made
before any write and passes through plainly.
The same asymmetry governs stdout: a write failure on the dry run is an
error (nothing happened), while a write failure after the copy committed
goes to stderr and leaves the exit code at 0.
Refuse to guess. The preflight always runs first (it is read-only), and a
non-empty needs_value refuses before any mutating request, naming each
field and the exact --field flags to add. Mirrors the web dialog's
disabled confirm rather than round-tripping the user into an error they
could have been shown.
--field values are typed against the DESTINATION collection's schema, so
a number lands as a number. A malformed --field is a hard error here
rather than the silent skip `pad item create` does: this command's
contract is "you were told what to supply", and dropping a supplied value
would make the refusal a lie.
The response types in internal/cli mirror internal/server's. That is a
layering choice, not a cycle -- nothing in server imports cli, and the
mirror test imports server freely. It follows the posture already
recorded in internal/cli/bootstrap.go: this package is the HTTP client
and does not depend on the server package. An external cli_test package
walks both response shapes and fails on any JSON contract drift.
MCP is deliberately untouched: no pad_item.action: copy, and
ToolSurfaceVersion stays 0.15.
|
||
|
|
f8ff5742e5 |
feat(server): add cross-workspace copy endpoint with post-commit fanout (TASK-2365)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
8cdfb8e287 |
feat(mcp): remote /mcp resource parity — wire read-only resources onto the cloud transport (TASK-2101) (#934)
* feat(mcp): wire read-only resources onto remote /mcp transport (TASK-2101)
The cloud /mcp Streamable HTTP transport registered zero resources
("resources_wired: false") — the stdio ExecResourceFetcher shells out to
the pad binary with one user's ~/.pad credentials, unusable in the shared
multi-OAuth-user process, so resources (incl. PR #930's attachment image
resource) were deferred.
Add HTTPResourceFetcher: the in-process equivalent that dispatches each
resource read through the same pad-cloud handler chain, reusing
HTTPHandlerDispatcher's user resolution + buildAuthedRequest (token-scope
check, verified-email gate, consent Apply). It reproduces each CLI
--format json shape (item list -> cli.ToItemSummaries; workspace list ->
{slug,name,updated_at}; attachment show -> HEAD-header synth; dashboard/
collections/bootstrap/item show -> endpoint body). Because it satisfies
ResourceFetcher + BinaryResourceFetcher, RegisterResources wires the full
read-only set onto the remote transport with the SAME handlers stdio uses
(formatItemAsMarkdown, attachment bounds/sniff/base64) — zero duplication.
Attachment bytes flow through cappedResponseWriter (wrapping the existing
cappedWriter) preserving PR #933's 1 MiB download bound in the shared
process. mcp-go propagates the HTTP request context (WithCurrentUser) into
resource handlers, so auth/scope/consent parity with tool calls holds.
- item list resource matches CLI `--all` (lifts non_terminal only; does
NOT set include_archived — soft-deleted items stay hidden).
- Shared synthesizeAttachmentMetadata between the pad_attachment tool and
the resource fetcher so the HEAD-derived shape can't drift.
No ToolSurfaceVersion bump — resources aren't part of the tool catalog
contract (PR #930 precedent).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(mcp): scope workspaces resource by OAuth consent allow-list per Codex review (round 1)
The pad://workspaces resource shelled GET /api/v1/workspaces, whose handler
returns every membership without consulting the OAuth token's allowed_workspaces
consent list (unlike per-workspace routes). On the remote transport a token
consented only for workspace alpha could enumerate names/slugs of unconsented
workspaces. Filter with the same rule the error-hint lister uses (buildAllowSet):
nil/wildcard allow-list -> no filter (PAT + local stdio unaffected); a specific
allow-list -> intersect with memberships.
Note: the pad_workspace list TOOL hits the same endpoint and has the same
unfiltered behavior — a pre-existing, broader concern to address at the
handler/tool level separately.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
475a70b57a |
fix(mcp): harden attachment image resource label + download bound (#933)
Follow-up to #930: label the blob from downloaded bytes (TOCTOU fix), bound FetchBytes buffering at the 1 MiB limit, and fix stale 'deferred to TASK-2076' docs. Adversarial-review + Codex findings; Codex CLEAN. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
c07f6d4b7e |
Add bounded MCP image attachment resource (#930)
Read-only MCP resource pad://workspace/{ws}/attachments/{id} returning a bounded base64 image via the existing thumb-md variant pipeline (image-only, 1 MiB pre-base64 cap, local-stdio surface). Closes #906. Implements TASK-2076/TASK-2077.
Author: @jstar0 (first-time contributor).
|
||
|
|
c72fe5a663 |
feat(items): add unparented filtering contract (#926)
* feat(items): add unparented filtering contract * fix(items): preserve unparented projection state * fix(views): preserve reserved filter on reset * fix(items): resync projection scope changes * fix(items): address PR 926 review findings - localIndex: fetch snapshot before clearing store/cache in resyncProjectionScope (no data-loss window on fetch failure) - items: degrade to committed item when post-parent-link readback fails instead of 500 - items: treat unparented=<non-true> as a field filter so a schema field named unparented still filters - persistence: delete dead persistCursor - mark validateUnparentedListRequest canonical; cross-reference the 3 early-feedback copies * fix(items): resync race + purge safety per Codex review (round 1) - resyncProjectionScope: merge-reconcile instead of blunt clear so a higher-seq upsert/delta racing the snapshot fetch is preserved (not erased) and the cursor never regresses below it - recheck generation after persistWipe so a sign-out/403 purge during the wipe can't resurrect purged rows via persistDelta - snapshot rows authoritatively replace local copies (drop is_unparented on downgrade); mergeRow's projection-preservation is bypassed for resync * fix(items): sanitize projection bit on preserved racing rows per Codex review (round 2) When a projection resync lands a restricted snapshot, strip is_unparented from any racing higher-seq row kept by the seq guards — the old scope no longer grants it. Keep the row itself (dropping it would reintroduce the racing-mutation data loss; server 403 enforces real visibility). * fix(items): transactional cache replace in resync per Codex review (round 3) Replace wipe()+persistDelta() in resyncProjectionScope with a single persistReplace() transaction (clear + write in one tx). Avoids the deleteDatabase() onblocked cross-tab hang where a pending delete stalls the following reopen+write indefinitely, wedging the resync promise. wipe() stays for the sign-out / schema-mismatch full-teardown paths. * fix(items): drop-and-replay resync reconciliation per Codex review (round 4) Rework resyncProjectionScope: drop every row absent from the authoritative snapshot (not just older-than-cursor ones) and pin the cursor to the snapshot cursor. A post-snapshot mutation the client can still see is re-fetched by the next /items-changes?since=cursor under the NEW scope, so visible rows return and old-scope-hidden rows stay gone — no old-scope row survives the resync, and nothing is permanently lost. Present-in-snapshot racing edits are still kept (is_unparented stripped under a restricted scope). * fix(items): continue delta poll after resync so replay actually fires (round 5) The drop-and-replay resync (round 4) pins the cursor to the snapshot cursor so post-snapshot mutations re-fetch under the new scope — but both poll loops broke out / returned immediately after the resync, so the replay never ran until an unrelated sync/reload. Both callers now continue the loop from the pinned cursor; resync already aligned the scope so the branch can't re-fire, and the existing 50-iteration cap bounds it. * fix(items): keep pendingResync set until replay catches up (round 6) resyncProjectionScope cleared pendingResync after installing the snapshot but before the pinned-cursor replay drained. If that replay later failed or hit the 50-page cap, pendingResync stayed false and the next bootstrap() no-opped with racing mutations still missing. Let the reconcile loop's caughtUp logic own the flag instead. * fix(items): set pendingResync when any resync begins (round 7) Round 6 removed the premature clear but only the bootstrap path pre-sets pendingResync; a page deltaSync resync ran with it false, so a failed/capped replay there wouldn't trigger a bootstrap resume. Set pendingResync=true at the start of resyncProjectionScope so any caller marks catch-up pending; the reconcile loop clears it on caughtUp. * fix(items): fence stale optimistic writes + epoch-guard resync catch-up (round 8) Adds a resync-epoch + fenced-id mechanism to close the last two race classes: - fencedIds: a resync records the ids it dropped (hidden under the new scope). upsert() refuses a fenced id, so a stale old-scope create/update response resolving after the resync can't resurrect a now-hidden row that no new-scope delta would evict (P1). An authoritative applyDelta re-add un-fences; the next resync recomputes the set (re-upgrade clears it). Self-contained in the store — no epoch threading through the optimistic callers. - scopeEpoch: bumped when a resync installs a new snapshot. Both reconcile loops capture it before each /items-changes and skip treating a response that raced a concurrent resync as caught-up, so a stale in-flight delta can't clear pendingResync without validating the pinned cursor (P2). Regression test covers fence → reject stale upsert → authoritative re-add un-fences → later edits accepted. * fix(items): bump scope epoch before resync fetch (round 9 P2) scopeEpoch advanced only after listIndex() returned, so a reconcile response racing the fetch saw the old epoch and could clear the pendingResync the resync set at start. Bump the epoch before the network await instead. |
||
|
|
b710b8b031 |
docs: list OpenCode in CLAUDE.md supported-agent set (#924)
Follow-up to #923 (OpenCode agent-install support). The user-facing README and CLI help were reconciled there; this brings the dev-guide's supported-agent enumeration in line too. Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF |
||
|
|
bed933d7fd |
feat(items): field-level PATCH + conflict envelope + read-only version history (TASK-2022) (#876)
* feat(items): field-level PATCH + conflict envelope + version history Adds three related item-update primitives (TASK-2022 / IDEA-1480): - Field-level merge: PATCH `fields_patch` shallow-merges onto the item's current fields INSIDE the write transaction (null deletes a key), so concurrent single-field updates no longer clobber each other via the full-blob read-modify-write. `pad item update` and the MCP `pad_item.update` action now send only the changed keys. - Optimistic concurrency: optional `expected_updated_at` on update; on mismatch the store returns *UpdateConflictError and the handler emits the pad-structured-error/v1 conflict envelope (HTTP 409, code=update_conflict). Surfaced on CLI (`--expected-updated-at`) and MCP (`expected_updated_at`). - Read-only version history: `pad item history <ref>` (alias `versions`) and MCP `pad_item.history`, reusing the existing item_versions store + versions endpoint (no new store, no schema change). MCP ToolSurfaceVersion bumped 0.9 -> 1.0 (new action + param; update behavior change). No migration required. Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS * fix(items): address Codex review — dispatcher fields_patch, OCC ordering, date/required guards Round 1+2 review fixes for TASK-2022: - HTTP MCP dispatcher (dispatch_http_advanced.go) now sends fields_patch (only changed keys) instead of a client-side merged full fields blob, and forwards expected_updated_at — remote MCP callers get the same race-free merge + optimistic concurrency the CLI/HTTP paths do. - ValidatePartialFields rejects null-deleting a schema-declared REQUIRED field (would otherwise persist a blob the full-update validator rejects). - Open-children guard on the fields_patch path merges the patch onto the IN-TX locked row inside the precheck (not a stale pre-lock preview), so a priority-only patch can't false-fire the guard. - Optimistic-concurrency check now runs BEFORE the open-children precheck in the store, so a stale expected_updated_at yields update_conflict (not open_children) — single in-tx re-read shared by both. - Date auto-population on the patch path only fills an EMPTY current date; an existing end_date the caller isn't touching is preserved. Tests added for each fix. Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS |
||
|
|
4bacea530f |
feat(mcp): expose pad_project ready + stale actions (#878)
Add read-only `ready` and `stale` actions to the pad_project MCP tool, mirroring the existing CLI `pad project ready` / `pad project stale`. `ready` returns the actionable backlog (query-oriented counterpart to `next`); `stale` lists items needing attention. Both HTTP dispatchers already existed; this wires them onto the catalog surface. `pad project reconcile` stays CLI-only (shells out to `gh` for live PR state — a local-git dependency MCP agents lack). Bumps ToolSurfaceVersion 0.12 -> 0.13 across version.go, instructions.md, README, CLAUDE.md; adds readOnlyActions entries, drift-guard test entries, and a SKILL.md routing line. TASK-2019 Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS |
||
|
|
c846cff4fd |
feat(project): agent-accessible activity feed (pad project activity + MCP action) (#877)
* feat(project): agent-accessible activity feed (pad project activity + MCP action)
Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.
- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
(handler parse + store SQL clause) so limit/actor/since behave
identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
instructions.md) and add a SKILL.md querying-guidance line.
Tests: store since-filter test, HTTP dispatch test, catalog action test.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(mcp): mark pad_project.activity read-only in tool surface
Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
|
||
|
|
2e6538ac34 |
feat(mcp): add read-only attachments surface (pad_attachment) (#875)
Wire the existing attachment HTTP dispatchers onto the MCP catalog as a new read-only pad_attachment tool with list/show actions, mirroring the CLI `pad attachment list` / `pad attachment show`. Both dispatch paths already existed (ExecDispatcher via passThrough, HTTPHandlerDispatcher via dispatch_http_attachments.go) — this exposes them on the tool surface. - New tool rather than pad_item actions: an attachment is its own workspace-scoped resource, not an item property; a dedicated tool keeps pad_item's action enum focused. - Read-only only: upload/download/view stay CLI-only (filesystem-bound), matching the catalog's exclusion rules. - Bumps ToolSurfaceVersion 0.10 -> 0.11; updates instructions.md, README, CLAUDE.md, readOnlyActions, and the drift-guard fixtures. - The base64 image RESOURCE for multimodal agents is deferred to TASK-2076 (ResourceFetcher returns strings; no CLI base64-to-stdout path exists — non-trivial, out of scope here). TASK-2017 Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS |
||
|
|
c127a5f965 |
fix(playbooks): enforce draft gate server-side + expose status (BUG-2020) (#874)
* fix(playbooks): enforce draft gate server-side + expose status (BUG-2020)
pad_playbook run / POST /playbooks/{ref}/run now refuse a playbook whose
status isn't "active" with a structured playbook_not_active error. Adds
an allow_draft escape hatch across all surfaces: JSON body field, CLI
--allow-draft flag, MCP boolean param, and an "allow-draft" bareword in
raw_args (stripped before strict parsing). status is now echoed on both
the run and get responses.
Bumps ToolSurfaceVersion 0.9 -> 0.10 and updates the drift-guarded docs
(instructions.md, README.md) plus CLAUDE.md.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): forward allow_draft on WebMCP + refresh mcp serve help
Codex review follow-up for BUG-2020:
- WebMCP dispatcher + api client now forward allow_draft so the browser
surface can use the draft-gate escape hatch the catalog advertises.
- `pad mcp serve --help` refreshed from the stale "v0.4 / eight tools"
text to the current v0.10 / nine-tool surface (incl. pad_library).
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): type playbooks.get() with top-level status (BUG-2020)
Codex P3 follow-up: the get response now returns Item & { status }.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
|
||
|
|
ac6e05d1e3 |
refactor(cli): split cmd/pad/main.go by resource (TASK-2015) (#866)
Mechanically split the 9,841-line cmd/pad/main.go god file into cohesive per-resource files (all package main): cmd_item.go, cmd_collection.go, cmd_workspace.go, cmd_auth.go, cmd_project.go, cmd_playbook.go, cmd_role.go, cmd_tag.go, cmd_github.go, cmd_webhook.go, cmd_agent.go, cmd_server.go, cmd_attachment.go, cmd_db.go, cmd_library.go, cmd_bootstrap.go. main.go now holds only main(), newRootCmd(), and shared config/client wiring (265 lines). Zero behavior change — a pure move of command constructors + helpers. All 169 top-level declarations preserved verbatim; the recursive --help command/flag tree is byte-identical to main. cmdhelp and the MCP catalog read command schemas at runtime, so they are unaffected. Updates the CLAUDE.md "Add a new CLI command" recipe to point contributors at the appropriate cmd_<resource>.go file and groups.go, so the file stops being a merge-conflict magnet for parallel agents. Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS |
||
|
|
1159fa7df4 | docs: sync CLAUDE.md MCP narrative to v0.9 / nine tools (#849) | ||
|
|
88c5771aed |
ci(web): run vitest unit tests in CI + Makefile web-test target (#835)
The 128-test vitest suite (7 files, incl. the WebMCP dispatch/descriptor tests backing PLAN-1888) ran nowhere in CI. Add a "Run web unit tests" step to the Web job after the build/check steps, a `web-test` Makefile target wired into the `check` chain, and a CLAUDE.md Testing note. Fixes TASK-1999. Claude-Session: https://claude.ai/code/session_01BoPkYhKqMiWPYmxQigeWsA |
||
|
|
76875218b8 |
feat(mcp): add pad_workspace restore + deleted actions (TASK-1973) (#834)
Expose the TASK-1972 CLI commands `pad workspace restore` /
`pad workspace deleted` on the MCP surface as two new pad_workspace
actions:
- `deleted` (read-only) — lists the caller's soft-deleted workspaces
still inside the 30-day restore window.
- `restore` (mutating, non-destructive, owner-only) — un-soft-deletes
a workspace by slug while it's still restorable.
Both passThrough to the CLI subcommands and reuse the existing `slug`
param (no new params). Non-interactive and non-destructive, so
MCP-appropriate.
- Mark `deleted` read-only in tool_surface.go readOnlyActions (restore
stays a write, the safe default).
- Wire both into the HTTP MCP route table (dispatch_http_routes.go) so
cloud/remote MCP dispatches to the existing /workspaces/deleted +
/workspaces/{slug}/restore endpoints instead of "not yet implemented
over HTTP transport" — mirroring TASK-1521's create/claim wiring.
- Bump ToolSurfaceVersion 0.7 -> 0.8 and document the addition in the
CLAUDE.md MCP stability contract.
- Extend catalog bijection/dispatch tests + cmdhelp fixture and add
route-table + HTTP-mapping tests for the new actions.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
3c0aed55db |
feat(server): add webmcp_enabled platform setting + session flag (#763)
* chore(docs): correct cloud MCP from "future /mcp endpoint" to live mcp.getpad.dev vhost The HTTPHandlerDispatcher description called the remote MCP server a "future /mcp endpoint." It's live: a cloud-mode-gated Streamable HTTP server mounted on the dedicated mcp.getpad.dev vhost via SetMCPTransport / registerMCPRoutes. Point at handlers_mcp.go. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * feat(server): add webmcp_enabled platform setting + session flag Introduce the opt-in gate for the browser-side WebMCP surface (PLAN-1888 Phase 1, DR-6). New webmcp_enabled platform setting, default off, admin-writable, surfaced to the web client via the /api/v1/auth/session payload so client tool registration can gate on it. - internal/server/handlers_admin.go: add settingWebMCPEnabled to the admin-PATCH whitelist (else silently dropped) + serialize a "false" default in the GET settings response. - internal/server/handlers_auth.go: emit webmcp_enabled in the session payload via a fail-closed webMCPEnabled() helper (false on unset or read error). - web/src/lib/api/client.ts: add webmcp_enabled?: boolean to AuthSession. - web/.../console/admin/settings/+page.svelte: Integrations section with a WebMCP toggle + security warning copy (Phase 4 admin-warning intent). - Go tests: admin PATCH persists + non-admin 403; session payload reflects stored value with default false. No migration (platform_settings is an existing kv table). Refs TASK-1889 / PLAN-1888 Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
616a6d2a0a |
feat(auth): localhost password recovery for locked-out self-host admins (#760)
Add a loopback-only account-recovery path so a self-hosted operator who
forgot their password (with no email provider configured) can recover
without editing the database by hand.
- POST /api/v1/auth/local-reset: loopback-gated, non-cloud, no auth
required (same trust model as bootstrap). Returns a single-use reset
link, or a temporary password with {"temp_password": true}.
- pad auth reset-password <email> [--temp-password]: talks to the local
server over loopback directly (not the configured public URL), so the
command works on the server host regardless of CLI config. Prints the
server's shareable reset_url when a public base URL is known.
- Web /forgot-password reads email_configured from the session and shows
host-recovery instructions instead of a dead "we emailed you a link"
when no provider is configured.
- forgot-password server log emits the reset path on non-cloud instances
so operators can also recover straight from the logs.
- Docs: CLAUDE.md + docs/deployment.md recovery sections.
Tests cover the loopback/cloud gates, the shareable reset_url, and both
output modes (reset link + temp password).
|
||
|
|
bf40cc9946 |
feat(artifact): MCP pad_item export/import actions (#758)
* feat(artifact): MCP pad_item export/import actions Phase 5 of PLAN-1867. Adds export/import to the pad_item MCP tool: - action=export — forces stdout (--output -), returns the artifact text. - action=import — accepts the artifact body via a new `artifact` param, writes a temp file, dispatches `item import`, returns ref+warnings. Bumps ToolSurfaceVersion 0.6 → 0.7 (additive, backwards-compatible) and updates the CLAUDE.md MCP version reference. Implements TASK-1881, TASK-1882. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(artifact): wire MCP export/import over the HTTP transport Addresses Codex Phase-5 review: the new pad_item export/import actions worked over local stdio (ExecDispatcher) but not the HTTP MCP transport. - item export → routeSpec GET /items/{ref}/export (models item show). - item import → custom dispatchItemImport sending the raw artifact as a text/markdown body to /import-artifact (RouteMapper only sends JSON bodies), reusing buildAuthedRequest (scope check) + packageHTTPResponse. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
a2827ecb33 |
feat(playbooks): NL-canonical invocation in seeded bodies + CLAUDE.md (TASK-1861) (#751)
De-hardcode `/pad <slug>` as the primary invocation form across user/agent- facing copy: - Seeded playbook bodies (plan / decompose / onboard): the cross-references and recap lines now lead with intent / the playbook name, with `/pad` labeled as the Claude-Code shortcut where shown. - CLAUDE.md: the invocation_slug description, the library section (now also reflecting the `▶ <slug>` chip from TASK-1860), the onboarding sections, and the needs_onboarding nudge quote (updated to the shipped NL-canonical active offer from PLAN-1847). Left Go `//` dev comments as shorthand — not user-facing, and out of scope for the drift-guard (TASK-1862 scopes to body strings + markdown). Parent: PLAN-1858. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
2a1a00e385 |
test,docs: blank+onboard+needs_onboarding integration test + CLAUDE.md update (TASK-1507,1508) (#580)
PLAN-1496's final consolidation pair, shipping together because both are small cleanup passes that close the plan out. TASK-1507 (tests): Most of the test coverage required by this task was already added incrementally in the prior PRs that built each surface: - Blank template (4 focused tests, PR #575): TestSeedFromBlankTemplate, TestBlankTemplateShape, TestBlankTemplateExcludesSoftwareCollections, TestBlankTemplateUsesMinimalVocabularies, TestBlankTemplateAppearsInPicker - Onboard auto-seed (PR #576): TestSeedFromTemplateAlwaysIncludesOnboardPlaybook (walks all six templates), TestSeedWithEmptyTemplateNameSkipsOnboard (locks the empty-templateName escape-hatch invariant), TestOnboardPlaybook_Contract (invocation_slug, trigger, mode-enum, ADAPT-DON'T-CURATE rule in the body) - needs_onboarding (PR #578): TestBootstrapNeedsOnboardingFlag (lifecycle: fresh → user item → flag flips), TestBootstrapNeedsOnboardingIgnoresTemplateSeeds (template seeds don't count) - Retired-pattern updates (PR #577): TestSoftwareTemplatesShipNoSeedItems inverse invariant; TestDashboardOnboardingSeed_NilForAllTemplates collapsed from three IDEA-1/BACK-1/FEAT-1 tests. This commit adds ONE integration smoke test that ties the three subsystems together at the bootstrap layer: - TestBootstrapBlankWorkspaceOnboardReady creates a blank-template workspace, fetches bootstrap, and asserts: needs_onboarding=true (nudge fires) AND the onboard playbook is in bootstrap.playbooks AND its status is "active" AND its trigger is "manual" (in the blank template's seeded vocabulary). If any one of the three pieces regresses silently, the integration breaks and this test catches it before /pad onboard stops dispatching on day one. TASK-1508 (docs): - CLAUDE.md "Data Model / Templates" section: added Blank under a new "Custom" category bullet pointing at the new Onboarding section; called out the PLAN-1496 retirement of the IDEA-1 / BACK-1 / FEAT-1 first-person seed pattern; updated design history reference to include PLAN-1496. - CLAUDE.md "API" section: added the /api/v1/workspaces/{ws}/agent/bootstrap endpoint with a note about the needs_onboarding flag (was previously documented only inline in the Playbooks section). - CLAUDE.md: new top-level "Onboarding" section between Playbooks and Testing. Covers: auto-seeded everywhere, surface-agnostic body, adaptation posture (library entries are starting points), the three TASK-1510/1511/1512 mutation primitives, the needs_onboarding bootstrap flag + skill nudge, the four retired surfaces (pad onboard cobra, OnboardingPrimaryRef, *OnboardingItems generators, standalone skill workflow section), and a code map. Verification: - go test ./...: clean (full suite passes including the new integration test) - make lint: 0 issues Parent: PLAN-1496. |
||
|
|
0930743304 |
feat: retire IDEA-1 seed pattern + 'pad onboard' cobra + surface blank in init (TASK-1501/1502/1503) (#577)
* feat: retire IDEA-1 seed pattern + 'pad onboard' cobra + surface blank in init (TASK-1501,1502,1503)
PLAN-1496's legacy-onboarding teardown:
TASK-1501 (remove seed items + update banner):
- internal/collections/templates_onboarding.go (and the _product/_scrum
siblings) deleted — these generated the IDEA-1/PLAN-2/TASK-3/DOC-4 +
BACK-1/SPRINT-2/BUG-3/DOC-4 + FEAT-1/FB-2/ROAD-3/DOC-4 first-person
seeds. The /pad onboard playbook (TASK-1499 / TASK-1500) is the
replacement.
- startup/scrum/product templates: SeedItems lines removed.
- post-init banner in printOnboardingHints: now points at "/pad onboard"
in one line, then web UI link, then dashboard hint. The "use pad to
get IDEA-1 / BACK-1 / FEAT-1" branch is gone.
TASK-1502 (retire cobra + OnboardingPrimaryRef plumbing):
- OnboardingPrimaryRef struct field on WorkspaceTemplate removed. The
dashboard's banner auto-discovers seeds via item_number=1 +
source="template" + created_by="system", so the field was redundant
even before retirement.
- onboardingPrimaryRef() helper in cmd/pad/main.go removed.
- 'pad onboard' Cobra subcommand removed (~160 lines). It scanned the
project directory for build/test/CI markers and seeded library
conventions — useful behavior but CLI-only, unreachable from
MCP-only agents. The /pad onboard PLAYBOOK now covers it.
- internal/cli/detect.go and workspace_context_detect.go stay; still
used by the web-side workspace-context save path.
TASK-1503 (Blank in interactive picker):
- The picker already surfaces Blank because templates_picker.go iterates
GroupTemplatesByCategory, and the IDEA-1479 Blank template entry lives
in CategoryCustom. Verified the output renders correctly with the
TASK-1498 description + icon update.
- 'pad workspace init --help' Long now mentions Blank explicitly +
points users at /pad onboard. Helps discoverability without restructuring
the picker.
Test changes (delete or rewrite tests that exercised the retired pattern):
- internal/collections/templates_test.go: six tests deleted (StartupOnboardingItemsOrderAndShape,
ScrumOnboardingItemsOrderAndShape, ProductOnboardingItemsOrderAndShape,
Startup/ScrumProduct/TemplatesDeclareOnboardingPrimaryRef). New
TestSoftwareTemplatesShipNoSeedItems replaces them with the inverse
invariant: software templates ship zero seed items.
- internal/server/handlers_dashboard_test.go: three IDEA-1/BACK-1/FEAT-1
expectation tests collapsed into TestDashboardOnboardingSeed_NilForAllTemplates,
which asserts the auto-discovery finds no seed because seeds no longer
ship. (Hiring + EmptyWorkspace tests untouched — they already expect
nil for unrelated reasons.)
- internal/store/items_test.go: TestSeedCollectionsFromTemplate{Startup,Scrum,Product}RefSequence
and TestOnboardingFlow_FullWalkthrough_{Startup,Scrum,Product} deleted;
these locked the IDEA-1 ref-sequence + walkthrough behavior. Unused
helpers (findItemByTitle, extractStatus, safeFields, setItemStatus,
countItemsInCollection) deleted alongside them.
- internal/mcp/resources_test.go: TestReadItem_PreservesIDEAOneOnboardingBodyVerbatim
→ TestReadItem_PreservesBodyVerbatim. Property is the same (resource
pipeline doesn't mangle markdown), but the fixture is now synthetic
markdown instead of the IDEA-1 seed.
Note: handlers_dashboard.go still has the auto-discovery code path
(onboardingPrimaryCollectionSlugs map + the loop that probes for
item_number=1 + source="template"). It's now dead code — no item
will ever match the criteria after this PR. Left in place for a
follow-up cleanup pass to keep this PR focused.
Parent: PLAN-1496.
* docs: replace 'pad workspace onboard' references with /pad onboard (Codex round 1)
P2 finding on PR #577: README + CLAUDE.md still advertise the
'pad workspace onboard' subcommand in four places (README §Onboard
agents to a new codebase, README §3 Teach your agents the rules,
README CLI Reference, CLAUDE.md CLI). After this branch lands, those
instructions return "unknown command."
Replaced each with guidance pointing at /pad onboard (the playbook,
auto-seeded into every workspace). The library-list commands still
work and stay where they are.
Parent: PLAN-1496.
* docs: replace 'use pad to get IDEA-1' guidance with /pad onboard (Codex round 2)
P1 finding on PR #577: README.md:33-39 and CLAUDE.md:111-117 still
told users to 'use pad to get IDEA-1' after the post-init banner.
Since this branch deletes templates_onboarding.go and stops seeding
IDEA-1/PLAN-2/TASK-3/DOC-4, the quickstart instructions in both
top-level docs pointed at items that no longer exist.
Replaced each with /pad onboard guidance (the playbook is auto-seeded
into every new workspace by TASK-1500). CLAUDE.md's CLI reference
gets a one-line historical note explaining the pre-PLAN-1496 IDEA-1
pattern so readers reviewing older code/blame have context.
Parent: PLAN-1496.
* docs(skill): retire 'use pad to get IDEA-1' guidance in agent skill (Codex round 3)
P1 finding on PR #577: skills/pad/SKILL.md:175 still taught agents
that '"use pad to get IDEA-1"' should dispatch to 'pad item show IDEA-1'.
This branch deletes the seed items, so any agent following the
shipped skill in a fresh workspace would try to fetch a missing ref
instead of running /pad onboard.
Updated the routing entry to dispatch the legacy phrasing (kept as a
recognized intent so older docs/conversations still work) to the
/pad onboard playbook. Explicit "do NOT try to fetch IDEA-1
directly" to short-circuit the previously-trained behavior.
A broader skill cleanup — removing the standalone Onboarding
workflow section and adding the bootstrap nudge rendering — is
TASK-1505's scope. This PR's update is the minimal change needed to
unbreak the agent-facing routing.
Parent: PLAN-1496.
* docs(skill): add library-activation caveat to onboard routing entry (round 4)
P2 finding on PR #577: the routing entry said /pad onboard is
'always invokable because every workspace auto-seeds it.' True for
newly-created workspaces, but pre-existing workspaces (created before
PLAN-1496 lands) won't have it. Auto-upgrade is intentionally not
wired into SeedCollectionsFromTemplate for empty-template-name paths.
Mirrored the same activation-fallback caveat /pad plan and
/pad decompose carry: 'activate via library if the bootstrap's
playbooks array lacks invocation_slug=onboard, status=active.'
Parent: PLAN-1496.
|
||
|
|
de8679f535 |
chore(mcp): bump ToolSurfaceVersion 0.3 → 0.4; document v0.4 envelope (TASK-1418) (#544)
* chore(mcp): bump ToolSurfaceVersion 0.3 → 0.4; document v0.4 envelope (TASK-1418)
Final PR of PLAN-1410. The contractual announcement that the v0.4
bootstrap shape is stable.
## What
1. internal/mcp/version.go — ToolSurfaceVersion: "0.3" → "0.4".
The godoc on the constant gains a full v0.4 changelog entry
enumerating each shape change shipped by PLAN-1410's six
bootstrap PRs:
- BootstrapCollection projection (TASK-1412): drops id,
workspace_id, created_at, updated_at, settings; schema as
a nested JSON object.
- BootstrapRole projection (TASK-1423): drops id,
workspace_id, tools, created_at, updated_at.
- Convention slug dropped (TASK-1413).
- Top-level recent_activity duplicate removed (TASK-1413).
- BootstrapDashboard wrapper caps five sub-arrays (TASK-1413
+ TASK-1422): attention, recent_activity, active_items,
active_plans, by_role at 5 entries each, parallel
*_overflow_count fields. suggested_next deliberately
excluded — already capped to 3 upstream.
- Schema label omitted when label == TitleCase(key) (TASK-1424).
Plus an explicit compatibility note: all v0.4 changes are
additive or subtractive (no field renames); clients that read
the preserved field names keep working unchanged.
2. CLAUDE.md updates:
- "## MCP server" header: v0.3 catalog → v0.4 catalog, with a
one-paragraph summary of what v0.4 shipped.
- "Surface:" Tools bullet: v0.3 → v0.4, with a note that the
tool/action surface is unchanged — only the bootstrap JSON
these tools return has been trimmed.
- "Stability contract": ToolSurfaceVersion (currently "0.4"),
comprehensive single-paragraph description of the v0.4
envelope, cumulative size reduction (40% live / 54% fixture),
and explicit additive/subtractive note.
## Why the strategy worked
PLAN-1410's "version bump last" strategy paid off:
- Each individual shape PR (TASK-1412/1413/1422/1423/1424) was
reviewable in isolation against a stable v0.3 contract.
- The six skill-side PRs (TASK-1414/1415/1416) had no MCP-shape
impact and didn't need any version bump consideration.
- v0.4 is now announced as a single comprehensive contract change,
not five separate version bumps — easier for downstream MCP
consumers (Claude Desktop, Cursor, future Pad Cloud remote MCP)
to reason about.
## Verification
- `make check` — golangci-lint 0 issues, all Go tests pass
(including the version-tracking tests in catalog_meta_test.go
that auto-pin to whatever ToolSurfaceVersion is set to),
govulncheck clean, web build clean.
- MCP handshake (verified via `pad mcp serve` + an initialize
JSON-RPC request) advertises
capabilities.experimental.padToolSurface.version = "0.4".
padCmdhelp.version stays at "0.1" as expected.
## Post-merge follow-ups
After this lands:
- Update PLAN-1410's Result section with a "v0.4 announced" line
and the final post-everything measurement (taken against
docapp after `make install`).
- Flip PLAN-1410 status from `active` → `completed`.
These are pad-item operations, not git changes.
Parent: PLAN-1410. Closes the plan.
* fix(mcp): update stale v0.3 references after ToolSurfaceVersion bump (TASK-1418 follow-up)
Address Codex P2 + P3 findings on PR #544: bumping
ToolSurfaceVersion in version.go left four runtime/user-facing
docs still claiming v0.3:
P2 — runtime MCP docs:
- internal/mcp/instructions.md "## Tool surface (v0.3)" → v0.4
- internal/mcp/catalog_meta.go "v0.3 server-introspection tool" → "(v0.4 catalog)"
- internal/mcp/catalog_meta.go padMetaToolDescription twice:
* "the v0.3 tool catalog" → "the v0.4 tool catalog"
* "v0.3 catalog dump" → "v0.4 catalog dump"
- internal/mcp/catalog_meta.go actionMetaToolSurface godoc:
"v0.3 catalog" → "catalog" (de-versioned; the comment is
about scope, not version)
P3 — public README:
- README.md "Tool catalog (v0.3)" → "Tool catalog (v0.4)"
- README.md "tool_surface_version: '0.3'" → "'0.4'" with a
pointer to PLAN-1410's bootstrap-trim summary and
version.go's full v0.4 changelog.
Without these, agents reading the initialize-instructions blob or
pad_meta's tool description (both of which are part of the
runtime MCP surface, not just internal docs) would see v0.3 while
the handshake / pad_meta.action: version returned v0.4 — the
exact "contradictory metadata depending on what you read" failure
mode Codex flagged.
Same skill-↔-code sync pattern that has been a running theme
through PLAN-1410's review loops. The cluster of stale references
is a classic side effect of a version bump landing late in a
plan — the version constant is one string, but downstream prose
that names it lives in multiple places.
Verified no remaining "v0.3" claims that imply currency — `grep -rn
"v0\.3\|tool_surface_version" --include="*.{go,md}"` returns only
historical-context mentions in changelog godocs (correct) and the
runtime constant readback (correctly returns "0.4" now).
Parent: PLAN-1410 / TASK-1418.
* fix(mcp): correct schema-type-change disclosure + stale cmdhelp-walker description (TASK-1418 follow-up)
Address Codex round 2 P3 findings on PR #544:
## P3 — `cmd/pad/mcp.go` still described the retired leaf walker
The `pad mcp serve` command's Long description said "every leaf
command becomes an MCP tool, except the curated allow-list
exclusions" — that was true under v0.1 but the cmdhelp leaf
walker was retired in TASK-981 (PLAN-969's v0.2 rollout). The
v0.2/v0.3/v0.4 surface has always been the hand-curated catalog
of eight resource × action tools + pad_set_workspace.
Updated the Long description to:
- Name the v0.4 catalog explicitly.
- List the eight resource × action tools.
- Note that cmdhelp v0.1 still drives per-command arg schemas
at dispatch time (so it's not gone, just no longer drives
tool naming/count).
- Reference TASK-981 for the cutover.
## P3 — "additive/subtractive only" was misleading
The compatibility note in `version.go` and `CLAUDE.md` claimed
all v0.4 changes were additive or subtractive. That glossed over
one breaking change in TASK-1412: `collections[].schema` went
from a JSON-encoded string ("schema":"{\"fields\":...}") to a
nested JSON object ("schema":{"fields":...}). For any v0.3
consumer that read schema as a string and JSON.parse()'d it
themselves, that's a TYPE change, not a no-op.
Updated both godoc and CLAUDE.md to explicitly call this out
as the one breaking change, separately from the additive/
subtractive bucket. Better for downstream MCP consumers to see
the truth than to discover it via runtime failure.
The remaining v0.4 changes ARE additive (overflow counts on
BootstrapDashboard) or subtractive (dropped fields with named
canonical alternatives) — those parts of the original note
are accurate and kept.
Honesty about compatibility is more valuable than a tidy
narrative. Surfaced explicitly in the godoc + the public
contract doc; PLAN-1410's Result section was already honest
about the field-level deltas.
Parent: PLAN-1410 / TASK-1418.
|
||
|
|
1db0e6a505 |
docs+test: closes the PLAN-1397 library overhaul loop (TASK-1404) (#533)
* docs+test: closes the PLAN-1397 library overhaul loop (TASK-1404)
## Tests
New `internal/collections/playbook_library_test.go` with 4 regression
guards for the invokable-first library:
- TestPlaybookLibrary_InvokableEntriesPresent — asserts ship + plan +
decompose are all present by invocation_slug, that each has at
least one Argument declared, and that at least 3 invokable entries
exist. Catches T1 widening getting half-reverted or T6's library
rebuild dropping an entry.
- TestPlaybookLibrary_AllTriggersKnown — asserts every library
entry's Trigger is one of the canonical values (manual,
on-implement, on-review, on-plan, on-triage, on-release,
on-deploy, on-pr-create, on-task-complete, always). Catches typos
that would seed an invalid trigger.
- TestPlaybookLibrary_ShipBodyShared — confirms the library `ship`
entry and ShipPlaybook() seed share the same body constant. The
whole point of T3 was to avoid duplication; this prevents drift.
- TestPlaybookLibraryArchive_BodiesCompiled — keeps archivedPlaybooks()
greppable and verifies the canonical retired title ("Implementation
Workflow") is still in the archive.
## CLAUDE.md
- Added a "Library — discovery surface" subsection to the Playbooks
section explaining the invokable-first lineup, the archive, and
how `softwareStarterPlaybookTitles` seeds plan + decompose into
software workspaces.
- Expanded "Code map" with the four new/refactored library files
(playbook_library.go, playbook_library_plan.go,
playbook_library_decompose.go, playbook_library_archive.go).
- Cross-referenced PLAN-1397 alongside PLAN-1377 for design history.
## skills/pad/SKILL.md
- "Planning: 'Let's create a plan'" and "Decomposition: 'Break plan
X into tasks'" now name `/pad plan` and `/pad decompose` as the
canonical entry points, with the inline workflow as the fallback
when those playbooks aren't activated in the workspace.
- The high-level "Planning:" intent list at the top of the routing
section updated to match.
- Note: SKILL.md is embedded into the Go binary at build time. The
next `make install` distributes the skill update; the .agents/
copy regenerates automatically.
Parent: PLAN-1397. Closes the loop — all 7 tasks of the playbook
library overhaul shipped.
* fix(test): source knownPlaybookTriggers from SoftwarePlaybookTriggers per Codex review (round 1)
Round 1: the hand-maintained `knownPlaybookTriggers` map admitted
`on-pr-create`, `on-task-complete`, and `always` — all valid in
the schema for *conventions*, but NOT in `SoftwarePlaybookTriggers`
(templates.go:266). A library entry could ship one of those and
the regression test would pass, but the same trigger would be
rejected when seeded into a software workspace at activation.
Fix: derive the test's known-set from `SoftwarePlaybookTriggers`
directly. If the schema widens or narrows, the test follows
automatically — no drift between the assertion baseline and the
actual schema. Also added a note clarifying that templates with
domain-specific trigger vocabularies (e.g. hiring's
on-candidate-advance) would need their own library scoped to
that template.
The set is now exactly: manual, on-implement, on-triage,
on-release, on-plan, on-review, on-deploy.
|
||
|
|
9764b2fe92 |
docs: document playbook invocation surface (TASK-1387) (#525)
* docs: document playbook invocation surface (TASK-1387)
Closes out PLAN-1377 — Make Playbooks first-class invokable procedures —
by bringing the four user-facing docs surfaces up to date with the
shipped invocation model. The pad-web docs ship in a separate commit
(../pad-web@main: docs: document playbook invocation surface).
- CLAUDE.md — new Playbooks section after Data Model covering the
three invocation surfaces, the invocation_slug/arguments schema
fields, bootstrap-returns-metadata, the seeded ship playbook, the
web UI editor, and a code map. MCP section grows pad_playbook,
pad://workspace/{ws}/bootstrap, and the pad_set_workspace embedded
response note.
- skills/pad/SKILL.md — adds a "Creating a playbook" subsection under
natural-language routing with CLI examples for trigger-only and
slug-invocable playbooks, plus a Playbooks block in the CLI
reference (pad playbook list/show/run with parsing rules).
- README.md — one-line bump in the feature list mentioning the new
/pad <slug> invocation form and the seeded ship playbook.
Parent: PLAN-1377.
* fix(docs): correct bootstrap route + CLI arguments authoring per Codex review (round 1)
Codex round 1 findings:
[P2] CLAUDE.md cited GET /api/v1/workspaces/{ws}/bootstrap but the
implemented route is /api/v1/workspaces/{ws}/agent/bootstrap (server.go
line 1182). Documented endpoint would 404 for HTTP integrators.
[P2] SKILL.md '--field arguments=[...]' example would fail validation
— pad item create stores all --field values as strings, while
arguments is a json field type. Rewrote the slug-invocable-playbook
authoring guidance to direct agents at the web UI editor for
structured argument authoring (the canonical path the editor was
built for) with the CLI handling everything else. Same fix applied
to the pad-web /docs/agent-integration page in a separate
../pad-web@main commit.
Parent: TASK-1387 / PLAN-1377.
* fix(docs): use full /{username}/{workspace}/playbooks route in SKILL.md per Codex review (round 2)
Codex round 2 finding:
[P3] SKILL.md's recommended web editor path was '/{workspace}/playbooks',
but the SvelteKit route is '/{username}/{workspace}/playbooks'. The
prior path would 404 or land on the wrong workspace. Fixed.
The pad-web docs ship the matching fix in a separate commit at
../pad-web@main: docs(playbooks): use full /{username}/{workspace}
route path per review.
Parent: TASK-1387 / PLAN-1377.
* fix(docs): bump CLAUDE.md MCP tool surface to v0.3 + close SKILL.md backtick per Codex review (round 3)
Codex round 3 findings:
[P2] CLAUDE.md still labelled the tool surface as v0.2; internal/mcp/
version.go advertises ToolSurfaceVersion = '0.3' (since PLAN-1377 /
TASK-1380). Updated to v0.3 and added a note about what v0.3 introduced
(pad_meta.action: bootstrap, pad_set_workspace embedded-bootstrap
response, pad://workspace/{ws}/bootstrap resource).
[P3] SKILL.md's web-editor route had the parenthetical inside the
code span: '`/{username}/{workspace}/playbooks (click "+ New
Playbook")`' — closed the backtick after '/playbooks' so the
rendered code span is the literal path.
The pad-web docs ship the matching v0.3 bump in a separate commit at
../pad-web@main: docs(mcp/tools): bump tool surface to v0.3.
Parent: TASK-1387 / PLAN-1377.
* fix(docs): bump stale MCP catalog references to v0.3 per Codex review (round 4)
Codex round 4 finding [P2]:
Three places still described the MCP catalog as v0.2, contradicting the
v0.3 surface block that landed in this PR:
- CLAUDE.md 'MCP server' lede paragraph — bumped to v0.3, added
pad_playbook to the listed tools, and noted what v0.3 introduced.
- skills/pad/SKILL.md MCP note for MCP-using agents — bumped to v0.3,
added pad_playbook to the listed tools and called out the playbook
invocation surface + bootstrap action.
- README.md 'Tool catalog (v0.2)' block — bumped to v0.3, added the
pad_playbook row, the pad_meta.action: bootstrap row, the bootstrap
resource, and bumped tool_surface_version.
Parent: TASK-1387 / PLAN-1377.
* fix(docs): bump MCP server-side self-description to v0.3 per Codex review (round 5)
Codex round 5 finding [P2]:
Two MCP-server-internal documentation surfaces still advertised v0.2:
- internal/mcp/instructions.md — the markdown blob the server returns
to MCP clients as initialization instructions. Updated 'Tool surface
(v0.2) / Eight tools' to 'Tool surface (v0.3) / Nine tools', added
pad_playbook with list/get/run, added bootstrap to pad_meta's actions,
noted pad_set_workspace's embedded-bootstrap response, and added
pad://workspace/{ws}/bootstrap to the resource list.
- internal/mcp/catalog_meta.go — the pad_meta tool's Description string
said 'v0.2 tool catalog' twice. Bumped both to v0.3.
These ship inside the binary; MCP clients read them directly so v0.2
mentions there contradict the v0.3 catalog the handshake actually
advertises (ToolSurfaceVersion in version.go).
Parent: TASK-1387 / PLAN-1377.
* fix(docs): finish MCP self-description v0.3 cleanup per Codex review (round 6)
Codex round 6 findings [P3]:
[1] catalog_meta.go's padMetaTool block-comment said 'Three actions,
all handled inline' even though bootstrap (the v0.3 fourth action)
dispatches through env.Dispatch. Fixed both the count and the
dispatch description, added the bootstrap row to the action list.
Also corrected the v0.2 mentions in actionMetaToolSurface's comment
and removed the rollout-era language now that the cmdhelp walker is
retired.
[2] instructions.md said 'Nine tools, each with an action enum' but
pad_set_workspace doesn't take an action. Clarified the count as
'eight resource × action tools, plus pad_set_workspace (which takes
a workspace slug only)' and scoped the 'Always pass action' rule to
the eight resource × action tools.
Parent: TASK-1387 / PLAN-1377.
* fix(docs): finish MCP self-description nine-tool wording per Codex review (round 7)
Codex round 7 findings:
[1] catalog_meta.go's padMetaToolDescription still mentioned the
PLAN-969-rollout cmdhelp walker contributing to tools/list. The walker
was retired in TASK-981. Rewrote the tool-surface action description
to match current behavior and explicitly note pad_set_workspace is
registered separately (not enumerated by tool-surface).
[2] actionMetaToolSurface's comment claimed scope includes
pad_set_workspace; the impl only loops env.Catalog. Updated the
comment to be accurate — tool-surface enumerates the eight catalog
tools only, callers should account for pad_set_workspace as a known
extra.
[3] CLAUDE.md and README.md described the MCP surface as if every
listed tool was resource × action. pad_set_workspace takes
'workspace' only. Reworded both to match instructions.md's
'eight resource × action tools plus pad_set_workspace' framing.
Parent: TASK-1387 / PLAN-1377.
|
||
|
|
9fecb82e82 |
docs: surface --schema flag in SKILL.md + CLAUDE.md (TASK-1336) (#484)
PR #482 / TASK-1334 added the --schema flag to `pad collection create`. PR #483 / TASK-1335 wired it through MCP. Agents reading the live SKILL.md (embedded in the binary) need to see the new path or they'll keep reaching for --fields DSL and hit the original BUG-1284 symptom. Updates: - skills/pad/SKILL.md "Collections" section: now shows both --fields DSL and --schema JSON forms side-by-side. Calls out exactly when --schema is required (terminal_options et al.), and notes the label-from-key auto-fill so agents know empty labels are safe. - CLAUDE.md command list: adds the --schema variant alongside the existing --fields example so the project-level cheat sheet stays accurate. The CLI Long help (`pad collection create --help`) and the MCP tool description were already updated in TASK-1334 / TASK-1335. Parent: PLAN-1333. |
||
|
|
f44e592554 |
docs: confirm collab adds no container deps; close PLAN-1248 cleanup (TASK-1272) (#469)
Documents that the dumb-relay design preserves the single-Go-binary self-hosted shape — no Yjs Go port to vendor, no separate sync server, no Redis (multi-instance fanout is a separate deferred IDEA). Op-log lives in the existing SQLite/Postgres; relay is part of the main HTTP listener. The original task scope also called for removing `web/src/routes/dev/yjs-sandbox/`, but that route only existed on the `feat/yjs-tiptap-spike` branch and was deliberately not cherry-picked into PLAN-1248's productionization work — so there's nothing to delete on `main`. The spike branch can now be deleted. Audited web/package.json for spike-only deps: every yjs / tiptap / y-protocols dep is consumed by production code paths. Nothing to clean up. |
||
|
|
680cfbd879 |
docs: collab endpoint + Tiptap coordinated-bump rule (TASK-1269) (#467)
CLAUDE.md changes (no behaviour change):
- API Reference: GET /api/v1/collab/{itemID}?schema_version=N
- New "Real-time collaboration (Yjs / Tiptap)" section under Common
Tasks, documenting:
- Where the collab code lives
- The three-package coordinated-bump rule (@tiptap/core +
@tiptap/extension-collaboration + @tiptap/y-tiptap must move
together with exact pins)
- When the schema version (web/src/lib/collab/schemaVersion.ts +
internal/collab/manager.go::DefaultSchemaVersion) must bump
|
||
|
|
d1fb61097e |
docs(onboarding): document IDEA-1 trigger phrase across README, CLAUDE.md, and /pad skill (TASK-1138) (#406)
* docs(onboarding): document the IDEA-1 trigger phrase across README, CLAUDE.md, and the /pad skill (TASK-1138) Make the seeded onboarding entry point (PLAN-1131) discoverable in every doc surface a fresh user might land on. README.md Quick Start gains a follow-up paragraph after `pad init`. Names the trigger phrase verbatim so a copy-paste lands deterministically. Tone matches in-product hint copy from PR #403; no "tutorial" / "lesson" language. CLAUDE.md Authentication section gets a paragraph after `pad auth setup` pointing developers + agents at the same trigger phrase. Also enumerates the four seeded refs (IDEA-1 / PLAN-2 / TASK-3 / DOC-4) for context, with pointers to the source-of-truth code (internal/collections/templates_onboarding.go) and design history (PLAN-1131). skills/pad/SKILL.md Adds a bullet under the Onboarding routing section: an explicit "use pad to get IDEA-1" trigger and the schema-aware terminal-status guidance per collection (Ideas → implemented, Plans → completed, Tasks → done, Docs → archived). Frames the seed items as ordinary items the agent reads and acts on — no "onboarding mode" — so the no-marker / no-skill-detection design from PLAN-1131 stays clean. pad-web (../pad-web) is intentionally not touched — separate repo per CONVE-159. Spawned TASK-1142 to pick up the pad-web getting-started flow as a follow-up. Parent: PLAN-1131. Origin: IDEA-1128. * fix(docs): scope the IDEA-1 hint to post-workspace-creation, not bootstrap setup, per Codex review (round 1) Codex caught that the original wording suggested users could go straight to `use pad to get IDEA-1` after `pad auth setup`. But `pad auth setup` only creates the first admin account — no workspace. IDEA-1 is only seeded when a `startup`-template workspace is created (`pad init` or `pad workspace init`). Tightened to call out the precondition explicitly: a startup-template workspace must exist before the trigger phrase resolves. Spawned TASK-1143 to fix the matching CLI hint behavior — PR #403's `printIdeaOneTriggerHint` after `pad auth setup` has the same imprecision and should either drop the IDEA-1 mention or point users at `pad init` first. Out of scope for this docs PR. |
||
|
|
19f20c5911 |
feat(mcp): v0.2 catalog migrate pad_item + retire cmdhelp walker (TASK-981) (#354)
* feat(mcp): v0.2 catalog migrate pad_item + retire cmdhelp walker (TASK-981)
Final commit of TASK-970's 3-stage rollout (PLAN-969). pad_item lands
with 17 actions consolidating the v0.1 verb tools (item_create /
item_block / item_star / item_unstar / item_supersedes / item_unsupersede /
...) into one resource × action shape. cmdhelp leaf walker retired —
tools/list now advertises only the v0.2 catalog (~7 catalog tools +
pad_set_workspace).
pad_item actions:
- Lifecycle: create, update, delete, get, list, move
- Relationships: link, unlink, deps
- Stars: star, unstar, starred
- Comments: comment, list-comments
- Bulk + notes + decisions: bulk-update, note, decide
link / unlink dispatch on link_type via itemLinkRoutes table:
- blocks, blocked-by → item block / blocked-by + item unblock
- supersedes → item supersedes / unsupersede
- implements → item implements / unimplements
- split-from → item split-from / unsplit
Per-direction op (cmdPath, firstArg, secondArg, inverted) handles
the asymmetric "blocked-by unlink reuses unblock with operands swapped"
case correctly.
Walker retirement:
- registry.go shrinks dramatically. Register() now registers
pad_set_workspace + delegates to RegisterCatalog. Drop identifyLeaves,
hasExcludedAncestor, buildTool, makeDispatchHandler, propertyForArg,
propertyForFlag, propertyOptionsCommon, stringifyEnum, ToolNameFromPath,
DefaultExcludes, RegistryOptions.ExcludeCommands.
- mergeDispatchInput moves to dispatch.go (still used by env.Dispatch).
- registry_test.go pruned to: validation tests, MCPPropertyName tests,
shared helpers (fakeDispatcher, fixtureDoc, equalSlice). DOC-978
said to "delete and rebuild" — done; the v0.1 walker assertions
weren't worth carrying forward.
- cmd/pad/mcp.go: single Register() call (no separate RegisterCatalog).
ToolSurfaceVersion bumped 0.1 → 0.2. pad_meta.tool-surface's
rollout_status flips from "in-progress" to "complete" automatically
because the bump makes ToolSurfaceVersion != "0.1".
CLAUDE.md updated to reflect the new architecture (catalog over walker;
two version constants — CmdhelpVersion + ToolSurfaceVersion).
Tests:
- TestPadItemLink_DispatchTable iterates itemLinkRoutes and asserts
link/unlink dispatch correctly for every link_type, including the
blocked-by-uses-unblock-with-swapped-operands case.
- TestPadItemLink_Missing/UnknownLinkType for the structured error path.
- catalog_readonly_test.go's expected{} extended with pad_item
passThrough actions; link/unlink intentionally skipped (custom
dispatch).
- TestRegister_PassesPadVersionToCatalog round-trips PadVersion through
RegistryOptions → CatalogOptions → ActionEnv.
Parent: TASK-981 → TASK-970 → PLAN-969.
* fix(mcp): support repeatable refs for pad_item.bulk-update per Codex review (round 1)
Codex P (no priority shown — substantive issue): pad_item exposed
`ref: string` everywhere, but bulk-update's CLI takes a repeatable
positional (one or more refs). The retired cmdhelp walker generated
array schemas for repeatable args; v0.2's scalar `ref` made
bulk-update effectively single-item or schema-invalid for its
primary use case.
Fix: dedicated `refs: array<string>` schema param + custom
actionItemBulkUpdate handler. Translates `refs` array → repeatable
`ref` positional (the form BuildCLIArgs feeds CLI commands with
arg.Repeatable=true).
Why a separate `refs` param vs. overloading `ref`: keeps the schema
consistent across actions — agents see one shape per param name.
JSON Schema oneOf would also work but mcp-go's helpers don't expose
it cleanly.
Lenient fallback: a single ref passed unwrapped as a string still
works (logically equivalent to a 1-element array). Empty arrays and
missing refs both surface structured errors with `refs is required`.
Tests cover: array of strings → multiple positionals, single string
fallback, missing refs error, empty array error. Existing fixture
in TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath extended with
`refs: ["TASK-1", "TASK-2"]` so bulk-update reaches dispatch.
Parent: TASK-981 → TASK-970 → PLAN-969.
|