Commit Graph

80 Commits

Author SHA1 Message Date
xarmian 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 b437cc58
(IDEA-2641, reminders) had ALSO taken 0.28 — a SEMANTIC collision, not a
textual one: two different contracts under one number, and a client pinning
"0.28" would have had no way to know which it got. Renumbered to 0.29, and
main's 0.28 entry kept intact ahead of it.

Also swept while resolving: main's CLAUDE.md carried v0.28 straight after
v0.26, because the v0.27 bump (BUG-2850) never reached that file. Both
places in CLAUDE.md now read v0.26 -> v0.27 -> v0.28 -> v0.29, and the
README changelog line gains the v0.28 entry it never got.

A BEHAVIOR bump on the 0.27 / 0.26 / 0.16 / 0.10 / 0.9 grounds — not on
0.28's, which was purely additive —
no tool name, action enum or parameter shape changed, and `pad_item` now
refuses calls it used to accept. A `relation` value must name a live item in
the collection the field declares; a caller writing a resolvable value sees
no difference, and one writing an unresolvable value was storing something
no surface could render, so the break is the fix. No escape hatch,
deliberately: unlike 0.10's `allow_draft` there is no legitimate call this
refuses, and the case with a real claim to leniency — a CARRIED value —
is already exempt by provenance rather than by a flag.

Full entry in internal/mcp/version.go. instructions.md and README.md follow
the constant because `internal/mcp`'s own drift gates require it; CLAUDE.md
does not, which is exactly why it drifted.

CLAUDE.md's `pad item move` / `pad item copy` reference also gains the
relation semantics, which is the part a reader of that file is most likely
to need and the part that just changed.

THE CONSUMER, which is the interesting half. `dropped[].reason` is a wire
enum with its renderer on the other side of a language boundary, and nothing
made the two meet. BUG-2674 added `referent_not_portable` server-side; the
TypeScript union and CopyItemDialog's `dropReason` switch never learned it,
so it fell through `default: return reason` and the dialog showed a user the
raw string. It went unnoticed because only `github_pr` produced it — and
this change makes it the reason for EVERY carried relation on a
cross-workspace copy, plus three more (`not_found`, `wrong_collection`,
`target_missing`). A latent defect going live because my emission made the
form routine.

So the reasons are ENUMERATED rather than left as literals:
`store.RelationIssueReasons()` owns the four the store decides,
`preflightDropReasons()` composes them with the five this package
originates, and the emission sites now use the constants. The new gate
requires every entry to have a union member and a switch case.

The gate is SCOPED to `dropReason`'s own body rather than searching the
component, because another switch matching `case 'not_found':` for an
unrelated purpose would satisfy it while the dialog still rendered the raw
enum — a guard passing on the wrong evidence. If that function is renamed
the test FAILS rather than silently passing on a body it can no longer find.

Negative controls, all DETECTED: remove the dialog's `referent_not_portable`
case; remove `wrong_collection` from the TS union; rename `dropReason`
(which checks the scoping guard's own premise).

What the gate does not claim: that a case EXISTS, not that its sentence is
good — no test judges that. It is also blind to a renderer handling a reason
the server never sends; that direction is a dead branch, the other is the
defect.

Gates: internal/server ok 162.8s · internal/mcp ok 13.6s · internal/store ok
192.2s · `npm run check` 0 errors (6 pre-existing warnings, unrelated files)
· go vet clean · gofmt clean · make lint 0 issues.
2026-09-04 15:42:46 +00:00
xarmian 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
2026-09-04 11:36:11 -04:00
xarmian 4b6e321924 feat(mcp): bump ToolSurfaceVersion to 0.27 (BUG-2850)
The contract this branch changes is advertised in the handshake under
capabilities.experimental.padToolSurface, and agents branch on it. It
still said 0.26.

Caught by reading the artifact a CONSUMER reads rather than the code —
and the repo already had the instrument: tool_surface_drift_test.go
fails when instructions.md or README.md drift from the constant, so the
bump immediately named both documents. They are updated with what
actually changed, not just re-titled.

Bump grounds are v0.26's own, and v0.25's, v0.16's, v0.10's and v0.9's:
no tool name, action enum or parameter shape changed, and the behaviour
did. This branch REFUSES calls 0.26 accepted:

  - two names for one target in a single call (parent/plan,
    assign/assigned_user_id, role/agent_role_id), refused even when the
    values match, because the names address one thing through
    incomparable vocabularies and the doors resolved them differently;
  - the same key through the `fields` object and another source with
    differing values (equal ones collapse);
  - a non-string `assign`/`role`, which one door dropped silently and
    the other rejected;
  - an empty hierarchy value inside `fields`, which promoted onto a
    param both doors read as "not supplied" and so reported success
    having detached nothing.

Every one of those replaced a call that SUCCEEDED while doing something
other than what it said, so the break is the fix in each case.

The additive halves are in the same entry because they are one contract
change: server-side coercion (a declared number/json field was
unwritable from the remote transport at all), the `fields` object
carrying native types, and `warnings.undeclared_fields` on write
responses.

Deliberately NOT refused, and stated in the entry so it reads as a
decision: a top-level param colliding with a `field:[]` entry under the
same name with no `fields` object. Both doors resolve that identically
and it is visibly a duplicate.

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 17:59:40 +00:00
xarmian d28c28e97a docs(plugin,mcp,readme): the push monitor is consent-gated — say so where agents and operators read (PLAN-2613 S5, TASK-2620) (#1216) 2026-08-27 01:09:10 -04:00
xarmian 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 cb47c763 failed on BUG-2786 (the recurring
internal/events subscribe-confirm guard, which fails by asserting its own
premise: 'the acknowledgement never landed before the mark; this test could
not have discriminated'). CONVE-11 owes that failure a re-run before it can
be called a flake.

rerun-failed-jobs produced attempt 2 = startup_failure with the Go job stuck
in 'queued' — a GitHub infrastructure fault, not a test result — after which
the run refuses further retries ('This workflow run cannot be retried'). The
CI workflow has no workflow_dispatch trigger, so a push is the only way to
get a fresh run.

Evidence the failure is unrelated to this branch, gathered before re-running
rather than after: the branch touches 0 files under internal/events (11 files
total, none in that package), and the parent tip c6818500 had Go: SUCCESS with
the only non-comment Go difference being one added assertion in this branch's
own test file. Go (PostgreSQL) also passed on cb47c763, exercising the same
package.
2026-08-26 13:23:56 -04:00
xarmian 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
2026-08-25 15:31:16 -04:00
xarmian 99ffad1bca feat(server): timeline comment rows carry the agent name (TASK-2760) (#1196)
* feat(server): carry the agent name onto comment rows in the timeline (TASK-2760)

An agent's comment rendered under the human's name: the name is stamped only
on the linked 'commented' activity, which the timeline suppresses because the
comment card stands in for it. The comment list queries now LEFT JOIN that
activity and surface the name as Comment.AgentName (top-level and nested
replies, on the timeline and the comments endpoint alike, through one scan
helper), mirrored onto comment-kind TimelineEntry.agent_name to match the
actor_name idiom. The web comment card renders it verbatim in an isolated
<bdi>, separate from the human author.

Store join rather than a handler-side match: the two lists are paginated
independently, so a handler join misses at page edges and reads as
intermittently-correct attribution. Metadata is parsed in Go, not SQL, to
keep the query free of a SQLite/Postgres dialect fork.

* test(store): make the activity-window premise strict, not a same-second coin flip (TASK-2760)

* fix(server): replies log + link their commented activity so the agent name reaches them (TASK-2760, codex r1)

The dedicated reply route wrote no 'commented' activity, and the activity is
the only row that carries the writing agent's name — so a reply through the
web UI rendered under a generic chip no matter what the client sent. Also
rewrites the README + SKILL.md claim that comments never show the name, moves
the reply test onto the real route, and asserts order/limit under the join.

* fix(store): exclude comment-linked activities in the timeline's activity query (TASK-2760, codex r2)

buildTimeline suppressed a comment's linked activity only when that comment
was on the same page; the two sources are paginated separately, so an
activity could slip through as a standalone 'commented' card. The query now
excludes linked rows via NOT EXISTS on idx_comments_activity (both dialects),
exact regardless of either window, and the page-local guard is removed
rather than kept as a dead one that reads as load-bearing.

* fix(store): item-scope the comment/activity link and freeze comment-linked activities against debounce merges (TASK-2760, codex r3)

The join keyed on activity id alone while nothing in the schema ties a
comment's activity to its item — scope both the LEFT JOIN and the NOT EXISTS
to the item. And CreateActivityDebounced could merge a later update into the
'updated' row a comment links to, overlaying its agent stamp and bumping
created_at, so two agents under one set of credentials would silently
re-attribute an earlier comment; comment-linked rows are no longer merge
targets. Prose corrected: the linked row is a 'commented' row OR the
'updated' row of an update that carried the comment.

* fix(server,web): keep the read-skew guard beside the SQL exclusion; nowrap on every 24ch agent label (TASK-2760, codex r4)

The page-local guard covers a distinct failure from the query exclusion —
a comment fetched then hard-deleted before the activity query runs — so it
returns with that reason written down. Sweep: of the seven 24ch agent-label
rules, three lacked white-space: nowrap (both timeline cards and
EpisodeFeed), so a name with spaces wrapped instead of ellipsizing; the
other four already had it. Prose nits corrected; the pre-link debounce race
on update-with-comment is recorded on BUG-2716 with a pointer in the handler.

* docs(server,cli): state the reverse read-skew at the guard and the CLI non-rendering decision (TASK-2760, codex r5)

* fix(store): debounce merge refuses a comment-linked row inside the UPDATE itself (TASK-2760, codex r6)

The read-then-write left a window in which a comment could link the chosen
row before the merge overwrote its agent stamp. The merge is now one
statement whose predicate re-checks the link under the row write, and a
zero-row merge falls through to a fresh insert. Prose corrected: a later
update looks past a frozen row, to an older unlinked one or a fresh one.

* fix(store,test): one freeze mechanism, and the window-edge leak proven end to end (TASK-2760, matrix survivors)

The debounce SELECT-side exclusion became redundant once the UPDATE's own
predicate refused linked rows, and its 'look past to an older unlinked row'
semantics folded a later change into an earlier entry — a linked row now
simply ends the coalescing run. And the server suite could no longer tell
the SQL exclusion from the restored in-memory guard, because it only
exercised the same-page case; a test now drives the page-edge case codex
found (comment outside its window, activity inside), where only the query
can help.

* fix(web): drop a duplicate nowrap in EpisodeFeed — the rule already had it (TASK-2760, codex r7)

Corrects the round-4 sweep count: of seven 24ch agent-label rules, two
lacked white-space: nowrap (both timeline cards), not three.
2026-08-24 18:09:02 -04:00
xarmian de3c9b818f feat(web): name the agent on the admin per-user activity views too (TASK-2759)
Codex round 12 — and it corrects MY exemption, not codex's reading of it.

I listed these two tabs as exempt because their local row type omitted
`metadata`. True, and the wrong reason: handleAdminGetUserActivity
serializes whole models.Activity rows, so the stamped name was on the wire
the entire time and only the client type dropped it. By this unit's own
discriminator — does the surface hold an Activity? — they were never exempt.
Verified against the handler before changing anything.

The consequence was the exact gap the audit log had, on the same rows: an
admin reading a user's activity saw "Updated an item via cli" with no way
to tell which agent acted. The lead ruled the audit log IN on this
discriminator; these belong in for the same reason.

Rendered with the same rules as every other surface — <bdi>, bounded at
24ch, title for the full value, nothing shown when no name was stamped.
Tests assert the binding at this surface (CONVE-19), including the empty
case, the non-agent case and the bidi one.

Docs updated: the surface list in the README and both SKILL.md copies now
names the admin console's audit AND per-user activity views. The precision
of that list is what round 2 was about, so it moves with the code.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:53:12 +00:00
xarmian 0d08e7004c fix(web): isolate self-declared agent names so they cannot rewrite the audit around them (TASK-2759)
Codex round 8, probing adversarial names — the sharpest finding of the run
and a defect this unit introduced.

The agent name is text chosen by whoever is writing, and the admin audit log
built its cell as `${agent} (via ${human})`. A writer could therefore pick
a name that forges the construction (`admin (via root)` renders as nested
attribution), or one carrying U+202E, which reorders everything appended
after it — the audited party editing how the audit reads. Not an auth bypass:
the stored actor stays correct. It is an audit-integrity defect, on the one
surface whose job is to be trusted when trust is in question.

displayUser now returns the PARTS and the template renders them as separate
elements, each in its own <bdi>. That bounds a hostile name to its own
isolate: it still displays exactly as sent, but it cannot reorder the " (via
" literal or the account name, and the account half is structure rather than
string, so a name spelling "(via root)" is visibly text inside the agent's
element. The via span is styled distinctly for the same reason.

Swept the sibling renders rather than the reported one (CONVE-18): the two
badges, the episode label, the timeline chip and the human name beside it are
all <bdi> now, since every one of them sits inline next to other text.

P3 from the same round — the timeline chip and the audit User cell were the
two name surfaces still unbounded. Both bounded, ellipsis, full value on the
title where the element clips.

This does NOT weaken the verbatim contract, and the distinction is the whole
point: isolation changes no characters and rejects no names, it just renders
each value as its own unit. Storing raw and rendering safely are compatible;
an allow-list would not be.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:24:21 +00:00
xarmian fa22b6680e docs+test: correct two over-claims and pin name escaping (TASK-2759)
Codex round 2, fresh angles.

P1, accepted — my own docs over-claimed. The README and both SKILL.md
copies said the name appears wherever agent actors appear, including "item
timelines". Comments, version snapshots and note/decision entries carry the
actor KIND and no name (that is the exempt set the plan named, and TASK-2760
files the comment half), so on a timeline only ACTIVITY entries show it. Both
now say which entries carry it and which read "Agent".

P2, accepted — the README's fallback was wrong in a way that mattered. When
nothing resolves a name, the CLI omits X-Pad-Agent entirely (client.go:1884),
so actorFromRequest records the write as "user": it is attributed to the
PERSON, not to a generic "agent". Verified both call sites rather than
reasoning from the label. The generic "agent" rows that do exist come from
pre-naming writes and from audit events logged without agentMeta.

P2, accepted — the name is attacker-influenced text and every test used
benign values, so a rewrite to {@html} would have passed. Added a markup
payload at two surfaces that build their labels through different paths,
asserting no element is created and the text survives intact.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:45:35 +00:00
xarmian a3ba6eec6c docs: the "name your agents" story for agent attribution (TASK-2759)
The README's For AI Agents section promised that agent actions are
attributed, and said nothing about naming the agent — which was fair while
nothing rendered the name. Now that five surfaces do, the section carries
the precedence (.pad.toml agent_name -> $PAD_AGENT -> detected runtime),
where the name shows up, and that Pad renders it verbatim rather than
keeping a list of approved names.

The honesty framing is QUOTED from ResolveAgentName's own contract comment
rather than restated: the header is self-declared, an agent that omits it
is indistinguishable from the human whose credentials it uses, and a human
running `! pad ...` in an agent's terminal inherits that attribution. It
is a label an actor chose, not evidence about who acted — which is also why
the admin audit log shows both the agent and the account.

Both SKILL.md copies gain one clause: the name an agent sends is now
DISPLAYED, so a specific name beats a generic client id. Their existing
attribution principle was already accurate and is otherwise untouched.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:32:19 +00:00
xarmian 0c14890b19 docs: add Community section to README (r/getpad + social) (#1174)
* docs: Community section in README — r/getpad, issues, X/Bluesky

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

* docs: r/getpad in the README header link row

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-20 23:49:17 -04:00
xarmian 402f79e016 feat(store,server,web): collection kernel traits — de-hardcode conventions/playbooks slugs (TASK-2657, BUG-2702) (#1171)
Implements SPEC-5 §Collection traits (approved v1.1) — the first unit of
PLAN-2656 phase 0. Three kernel behaviors were keyed on the literal collection
slugs "conventions" and "playbooks": what the agent bootstrap loads, which
items route by invocation slug, and which items export as portable artifacts.
Collections now DECLARE those behaviors and the kernel resolves them from the
declarations.

Fixes the KERNEL half of BUG-2702, which stays open for the rest (see below).
A slug is not a stable identifier —
UpdateCollection re-slugs on any name change, and renaming a collection is a
documented onboarding step (TASK-1510) — so renaming either collection silently
detached all three behaviors from it, with the items still present and no error
anywhere. Measured on origin/main before the fix: conventions and
convention_index dropped 1 -> 0, playbooks 1 -> 0, and GET /playbooks/{slug}
went 200 -> 404, so `/pad ship` stopped resolving with no sign the playbook
still existed. Both halves are locked by regression tests observed failing on
unfixed code.

BUG-2702 is NOT fully closed here, deliberately. Every kernel behavior follows
the trait, and library activation on the MCP dispatcher and CLI was converted
too — but the pack's own dedicated web routes (/conventions, /playbooks list and
detail, /library) still address their collection by literal slug and render
empty after a rename. Filed as BUG-2705 with the route paths and the likely fix
shape; 2702 closes when that lands. Degradation there is bounded: no data loss,
and the collection stays usable at its own /[collection] route and in the
sidebar.

SPEC-5 was amended to v1.1 BEFORE any code, per the spec tree's own discipline:
bootstrap_include becomes a LIST of {mode, filter, key} because v1.0 could not
express convention_index at all; the conventions filter is now normative and
includes status=active, which v1.0's shorthand omitted and which the
implementation does enforce (implementing v1.0 literally would have leaked
draft conventions into every agent's boot payload); v1 filters are field-
equality maps with query/1 named as the widening path, since SPEC-2 is phase 1
and PLAN-2656 forbids growing toward it; and invocation_field is constrained to
the literal `invocation_slug`, because any other field name falls outside the
partial unique indexes in migrations/054 and pgmigrations/033 that are the real
uniqueness guard.

Traits get their own column rather than a key inside the schema JSON. The
schema column is overwritten wholesale on update and every client rebuilds it
fields-only, so a traits key stored there is destroyed by one ordinary
collection edit — measured during this task, not assumed. Trait authority
cannot rest on a value an unrelated UI save deletes. UpdateCollection writes
traits only when explicitly supplied, so pre-existing clients leave them alone;
an explicit "{}" still clears.

Bootstrap keeps its three payload keys as first-party views fed from the
declarations, and gains a generic bootstrap_includes array for any other
declared key — so the boot surface is genuinely generic rather than three
hardcoded payloads, and no consumer breaks.

Existing workspaces are backfilled slug-keyed in both dialects, guarded on
traits='{}' so a re-run cannot clobber a workspace's own declarations. The
backfill inherits today's blind spot (a workspace that renamed the collection
before upgrading is not reached) but cannot do worse than the status quo, which
is itself slug-keyed; from the backfill forward the hazard is structurally gone.

Malformed declarations are refused at create and update rather than stored:
an unparseable blob degrades to "declares nothing", which is silently the wrong
behavior instead of a loud error (SPEC-0 L6).

Web groups agent-facing collections by bootstrap_include presence, replacing a
hardcoded two-slug array repeated at five call sites.

Not done, deliberately: no MCP catalog change (traits are first-party kernel
declarations, no agent needs to set them, and the separate column means
pad_collection.update passes through harmlessly — no ToolSurfaceVersion bump);
bootstrap's collections[] projection does not carry traits (PLAN-1410 trimmed
that payload and nothing consumes them there); prefix.go's NormalizeSlug is
untouched (a pure function with no workspace context, and de-hardcoding two of
its six slugs would make it less coherent, not more).

Eight Codex review rounds found nineteen real defects, all fixed here. The
serious one:
bootstrap_include filter keys FAIL OPEN. The item store's field-filter path
drops any key its sanitizer rejects, removing the predicate rather than matching
nothing, so a declaration filtering on `"stat us"` would narrow nothing and ship
every convention — drafts included — to every agent at boot, defeating the
status=active guarantee this change makes normative. Filter keys are now
validated against the store's own sanitizer shape and pinned by a cross-package
agreement test, since models cannot import store and a future divergence would
silently reopen it. SPEC-5 amended to v1.2 with the rule and its fail-open /
fail-closed asymmetry. Also fixed: an unknown declared artifact_kind reached
artifact.Encode and surfaced as a 500 (now a 400 at the export boundary, since
SPEC-5 permits unknown kinds as legal non-round-tripping declarations); and
workspace import validated traits as JSON only, so an archive could persist a
declaration that degrades to "declares nothing" (now validated, degrading to
"{}" with a warning rather than refusing an import that may be the only copy).

Later rounds found more, and several were defects this change itself created.
A hidden collection could SHADOW a visible one: resolution used to name exactly
one collection, so with several declaring, resolving across all of them and
rejecting afterwards on visibility made a visible playbook unreachable behind a
hidden one — candidates are now filtered by visibility before selection, in both
playbook resolution and artifact import. Importing a pre-traits archive produced
an INERT workspace: the migration backfill cannot reach rows inserted long after
it ran, so conventions/playbooks arrived declaring nothing, and canonical
declarations are now inferred from the slug when a collection declares none
(never overriding declarations that survived the round trip). The generic
include path had no L4 boot budget and is now capped with an overflow count.
Trait parsing claimed to be strict but json.Decoder ignores trailing bytes, so
`{...} garbage` parsed cleanly. First-party payload keys are now mode-pinned,
since their projections have fixed shapes and declaring the other mode would be
silently ignored. Duplicate artifact_kind / invocation_field declarations are
refused at the collection API, and a conflicting archive warns on import.

Agent-facing text was updated with the rest, not after it: SKILL.md,
instructions.md and the MCP catalog said the literal slugs, which is exactly the
artifact an agent acts on. ToolSurfaceVersion 0.24 -> 0.25 for the
pad_library.activate behaviour change.

Trait uniqueness is a documented BEST-EFFORT gate, not an invariant, by lead
ruling. The gate reads then writes without a lock, import bypasses it, and a
rename can mint a duplicate without touching that path. The database-level
enforcement (partial unique indexes on the extracted traits) cannot ship first:
existing deployments can already hold duplicates via rename-then-reseed, so the
index would fail the migration on precisely the databases that most need
repairing. TASK-2710 carries the de-duplication pass and the indexes; SPEC-5
v1.3 records the deferral and the reason. L6's requirement that conflicts fail
loud is met by the refusal plus the warning — the mechanism is deferred, the
principle is not.

Gates: build · make lint 0 issues · go test ./internal/... · make test-pg ·
svelte-check 0 errors · vitest 99 files / 1734 tests. Mutation-verified across
four matrices, 20 mutations, 19 caught; the survivor is a seeding path whose
trait-vs-slug difference is unreachable today (SeedCollectionsFromTemplate
creates any missing template collection before it seeds items), recorded on the
task trail rather than papered over with a test that proves nothing.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 13:26:05 -04:00
David Barkhausen 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>
2026-08-20 07:54:21 -04:00
David Barkhausen 5784d907c0 feat(cli): PAD_TOKEN environment override for stored credentials (#879) (#1160)
* feat(cli): PAD_TOKEN environment override for stored credentials (#879)

Layer 1 of #879: if PAD_TOKEN is set, the CLI uses it as the bearer
token and skips the credential-store lookup — gh's GH_TOKEN convention.
Reads never write credentials.json, so a read-only override sidesteps
the multi-agent identity contention completely; the store is never
touched under the override.

Per the acceptance grounding notes:

- NewClientFromURL resolves PAD_TOKEN before the per-server store
  lookup (the single token-attachment chokepoint).
- whoami no longer lies under the override: it skips the store
  short-circuit and reports the effective identity via a real /me
  fetch, with an 'Auth: PAD_TOKEN environment override' line.
- auth login/logout print a gh-style stderr notice when the override
  is active. logout additionally pins its server-side session
  invalidation to the STORED token — an unpinned Logout() after the
  constructor change would have invalidated the env token's session —
  and skips the server call when there is no stored session.
- pad init's status line and server info's report disclose the
  override (env_token_override field; the auth probe uses the token
  every other command would use).

Zero behaviour change when PAD_TOKEN is unset. Token minting stays
web-only; a minimal 'pad token' CLI is offered as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): review round 1 — init fails on a rejected PAD_TOKEN; login shortcut skipped under the override; logout asymmetry documented

Per the PR #1160 round-1 review:

- Bug 1: pad init's auth step no longer falls back to stored
  credentials when a set PAD_TOKEN is rejected — it fails with the
  distinct rejected-token message (mirroring whoami), which also makes
  the status line's override disclosure truthful. Test drives the real
  padInitCmd flow and asserts the stored identity is never consulted.
- Bug 2: login's 'Already logged in as <stored user>' shortcut is
  skipped when the override is active — it reads the store, and firing
  it right after envTokenNotice contradicted the notice. A second test
  pins the unchanged no-override shortcut behaviour.
- Doc ask: the deliberate logout asymmetry (the env token's own
  session is never invalidated; its lifecycle belongs to the minter,
  GH_TOKEN posture) is now stated in env_token.go's doc comment and
  the README PAD_TOKEN section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 07:54:13 -04:00
xarmian 449ac109e9 fix(server,mcp,cli): refuse system-metadata keys in fields_patch + retry-hostile error code (BUG-2627 part 2, BUG-2675) (#1166)
* fix(server,mcp,cli): refuse system-metadata keys in fields_patch + retry-hostile error code (BUG-2627 part 2, BUG-2675)

Part 2 of BUG-2627 closes the door that mints the defect parts 1 and 3
dealt with: `--field implementation_notes=<json>` stored the entries as a
JSON-ENCODED STRING, which is invisible to every reader and — since part
3's guard — disables `pad item note` on that item until the row is
repaired.

Refused SERVER-SIDE in `fields_patch`, not at the CLI as the item's scope
line proposed. The deviation is deliberate and recorded on the trail: the
CLI is one of three clients, and all three lower a user field-setter into
the same key (`pad item update --field` at cmd_item.go, the MCP `field`
param via dispatch_http_advanced.go on remote, and stdio by shelling out
to that CLI). One gate closes all three; a CLI-only refusal would have
left remote MCP writing the key. Both call sites were read, and the CLI's
lowering is now pinned by a test rather than left as an assumption.

Scope, stated because it is deliberate: this closes UPDATE only. The full
`fields` blob stays open because that door is SHARED — `pad item note` /
`decide` / `github link` send one, and so does convention activation via
BuildConventionItemFields -> ItemCreate. Closing it would break the system
writers the gate exists to protect. Item create therefore remains a mint
site, tracked with the rest of that surface in BUG-2685.

The refusal message is per-key: implementation_notes -> `pad item note`,
decision_log -> `pad item decide`, github_pr -> the GitHub link flow, and
`convention` refuses WITHOUT naming a command, because none writes it.
PATTE-135 wants a remedy that works in the failing state; a single
"use pad item note" line would have been wrong for three of the four keys.

BUG-2675 rides along on one ToolSurfaceVersion bump, as ruled. The append
refusal from part 3 reached MCP agents as `server_error` — not our fault,
and not transient, so agents could reasonably retry a failure that is
deterministic forever. New closed-set code `stored_state_unreadable`,
emitted on BOTH transports: HTTP classifies the sentinel error directly,
stdio via a `pad-structured-error/v1:` marker the CLI now writes for its
own local refusal (the first marker generated without an upstream
APIError). v0.16-then-v0.17 is what a one-transport fix costs.

Also here:
- items.ReservedOverrideKeys -> ReservedFieldKeysIn. The second caller
  passes a patch, not an override map, and the old doc comment said
  fields_patch was an open exposure — true until this commit.
- `Extract* returns nil for THREE reasons` -> FOUR. The comment listed
  four; the count was corrected everywhere except the code.
- Consumer-read artifacts updated where the claim is ACTED on, not only
  where it is documented: instructions.md (incl. a "do not retry this
  code" section), the catalog `field` param description, `pad item update
  --help`, README.

Gates: build · make lint · go test ./... · make test-pg · Codex.
Eleven-mutation matrix run against the new tests; every one killed by an
assertion (two were rewritten after killing by compile error / surviving,
which proves nothing).

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

* fix(server,mcp): honest remedy when the stored value is already unreadable; name the MCP-facing code (Codex round 1)

Three findings from the pre-push review, all real:

P2 — the refusal named `pad item note` unconditionally, but on an item
whose stored value is ALREADY undecodable that command refuses too (part
3's guard). The caller was routed in a circle: field write refused -> run
the note -> refused -> back again. That is exactly the failure PATTE-135
exists to prevent, and my own trail had reasoned the remedy was safe on
the strength of the HEALTHY case only. The message now inspects the
item's stored value and, when the key is unparseable, says so and points
at the one action that works in that state (inspection), noting that the
repair needs a full `fields` write no CLI flag exposes.

P2 — two doc claims were false where an actor reads them. The catalog
said reserved keys are refused "on every action that accepts field",
which includes CREATE, and create is deliberately NOT gated; and both the
catalog and instructions.md named `validation_error` (the HTTP code)
where an MCP client actually receives `validation_failed`. Both corrected,
and the create exception is now stated rather than implied by omission —
an agent that reads only "refused on update" will otherwise assume create
is fine, which is how a hole gets used.

nit — the destructive-downstream sentence claimed every reserved key
becomes unreadable and trips an append guard. True only for the two
append-backed keys; github_pr and convention are simply overwritten. The
clause is now per-key, because a confident wrong explanation is worse
than a vague right one.

Two more mutations run against the new branch: always-readable (the
circular remedy returns) and never-readable (the working remedy
disappears) — both killed by assertions.

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

* fix(models,mcp,cli): one appendability predicate, per-key docs, stdio hint parity (Codex round 2)

Five findings, all real.

P2 — the message's readability check and the guard it describes were two
different decodes. Mine unmarshalled into []json.RawMessage; the guard
uses []ItemImplementationNote. A stored `[1]` passed mine and fails the
guard, so the message would again have prescribed a command that refuses
— the same circularity round 1 caught, through a narrower door. Replaced
with models.StructuredFieldIsAppendable, which ASKS the guard rather than
re-deriving it, plus an agreement test over 12 shapes x 2 keys that
compares the predicate against the real Append* helpers. Verified by
restoring the RawMessage version: the table catches it on `[1]`.

P2 — stdio lost the new code's hint. Remote MCP told the agent retrying
is pointless and how to inspect; stdio got the code with an empty hint,
because the CLI's marker envelope carried none and the classifier parsed
none. Both fixed, with the hint hoisted into paired constants (the same
duplication StructuredErrorMarker already uses) and the test comparing
the two TRANSPORTS' envelopes rather than either against a literal.

P2 — doc text was still false for `convention`: the catalog, the
instructions and `--help` all said reserved keys are maintained by
note/decide/the GitHub flow, which is true of three of the four. Each key
now names its own writer, and `convention` names library activation.
Also dropped the `malformed_override` advertisement — that is the
SERVER's code; an MCP client sees validation_failed for both refusals.

nit — the classification test called structuredAppendErrorResult
directly, so deleting either dispatcher call site left it green.
Added dispatcher-level tests driving the real server + store, asserting
the code, the hint, and that the item's stored fields are byte-identical
afterwards. Mutation-verified by reverting the note call site.

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

* fix(items,models,mcp): github_pr stays writable through fields_patch; no nil-map panic (Codex round 3)

P1 — the gate refused `github_pr`, and that was wrong. My model was
"system writers use the full fields blob, user setters use fields_patch",
which holds for three of the four reserved keys and fails for this one:
`pad github link` needs a local git checkout and the `gh` CLI, so it is
excluded from remote MCP BY NAME, and internal/mcp/dispatch_http.go's
noRemoteEquivalent map tells remote agents in so many words to use
`item update --field github_pr=...` instead. For that audience the patch
door is not a bypass of the writer — it IS the writer.

So the refusal deleted a documented capability from remote agents, and
answered with a message naming a command they cannot run: the same
circular remedy round 1 caught, aimed this time at the people the gate
was meant to help. items.PatchRefusedFieldKeysIn now exempts the key and
records the rule being applied — refuse a raw write where a real writer
exists — rather than the list it produces. Whether remote agents should
get a proper PR-link action, so the key can be closed too, is a product
question and is left as one.

P2 — the hint told agents to read the bad value with `pad_item action=get`.
They cannot: stripDuplicatedFieldsKeys removes implementation_notes and
decision_log from every MCP response's fields blob, and the top-level
arrays come from the extractor, which returns nil for exactly this shape.
The value is invisible on the whole surface. The hint now says so and
routes to a human, who can read it with `pad item show --format json`.

P2 — `fields` holding a literal `null` unmarshals into a NIL map with no
error, and both Append* helpers assign into what they get back, so
`pad item note` PANICKED ("assignment to entry in nil map") instead of
appending. Reproduced, fixed in parseMutableItemFields, and pinned by a
test that fails on a panic rather than taking the process down. An absent
blob and a null blob mean the same thing to every caller. Pre-existing,
but it sits in the function family this bug is about and the message was
about to recommend the command that panics.

nit — README claimed a "closed eight-code taxonomy" (17 codes, and I had
just added one) and read as if create lowers into fields_patch.

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

* fix(models,mcp): predicate matches the append on malformed blobs; stop promising a broken workaround (Codex round 4)

P1 — round 3 exempted `github_pr` from the update gate on the strength
of noRemoteEquivalent's documented workaround. That workaround does not
work: ingestFieldKVP (remote) and parseFieldFlag (CLI, and so stdio) both
store a `field` value as a STRING, so the PR data lands double-encoded
and no link appears — the BUG-2627 shape one key over. Filed as BUG-2696
with the three candidate fixes; NOT folded in, because the narrowest of
them changes how every field value is typed.

The exemption stands regardless: refusing would leave remote agents with
strictly less than a broken door. What changes is what we may PROMISE.
The catalog, instructions.md, version.go and README said "this is how you
link a PR"; they now say the door is open and broken, and to hand PR
linking to a human. Advertising a capability that isn't there is the
failure mode this whole unit keeps circling.

P2 — StructuredFieldIsAppendable returned TRUE when the whole fields blob
was unparseable, on the reasoning that a broken outer blob is a different
problem. True of the cause, irrelevant to the caller: the Append* helpers
bail on that same parse, so the message again named a command that fails.
It now returns false, which is simply the honest answer to the question
asked, and the agreement table grew a malformed-outer-blob leg — the gap
that let the disagreement through.

P2 — the message claimed a raw field write always stores something Pad
cannot read back. That holds for the CLI and MCP (a `--field` value is
typed by schema lookup and these keys are in no schema) but not for a
direct REST caller sending a valid array, who is refused for ownership
reasons alone. Reworded to say both parts.

nit — a misplaced parenthetical in the README read as if item CREATE
lowers into fields_patch. It does not; it sends the full blob.

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

* fix(mcp,models): stop the remote hint advertising the broken PR workaround; classify an unparseable blob as retry-hostile (Codex round 5)

P1 — I corrected four artifacts that pointed agents at the github_pr
field write and missed the fifth: noRemoteEquivalent's own text, which IS
the message a remote agent receives when it calls `github link`, and
which Codex had quoted at me in round 3 to establish the workaround
existed. The nearest artifact to the actor was the one I did not open.
Both entries now say there is no working remote path and name BUG-2696,
with a test pinning the negative so a future edit cannot quietly
reinstate the advice while the write is still broken.

P2 — a fields blob that will not parse at all produced a bare parse
error, so `note` / `decide` reached agents as `server_error`: transient-
looking, and therefore retried, for a failure that is as deterministic as
the per-key one BUG-2675 exists for. Both Append* helpers now wrap that
parse failure in ErrStructuredFieldUnreadable, which both transports
already classify, and the malformed-blob test asserts the sentinel rather
than just an error.

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

* docs(mcp,cli): qualify what an agent can actually see when the state is unreadable (Codex round 6 nit)

Round 5 widened stored_state_unreadable to cover a fields blob that
fails to parse outright, which made half of its own hint false: MCP's
normalization strips a broken structured KEY (so `get` hides it), but
leaves an unparseable BLOB as a raw string (so `get` shows it). The hint
and instructions.md asserted the first case for both.

Now stated per layer, in the two paired constants and the instructions.
The reason it is worth the words rather than being cut: an agent told
'you cannot see this' does not look, and would have missed a value that
was in fact right there in the response it already had.

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

* fix(mcp): classify the move/copy reserved-key refusal as validation on stdio too (Codex round 7)

P2 — carried over from v0.22, surfaced because THIS bump documents the
two reserved-key refusals as agreeing across transports. The move/copy
message ("Field(s) reserved for system metadata and not settable here")
matched none of the stdio validation patterns, so the same deterministic
400 arrived as validation_failed on remote and server_error on stdio —
and server_error reads as transient, so an agent retries a refusal that
can never pass. One pattern added, plus a test that drives both real
classifiers with the real server message text for both refusals, so a
reworded message that stops matching fails here rather than in the field.

nit — the github_pr exemption is UPDATE-only; move and copy still refuse
it, because there the argument is BUG-2674's (an override reintroduces
the key the migration just dropped), not this one's. The catalog and
instructions said "not refused" without that qualifier.

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

* fix(mcp): cover the copy path's own refusal wording in the stdio classifier (Codex round 8)

P2 — round 7 fixed the MOVE wording; the copy path words the same class
of refusal differently ("Destination collection has no field(s): ..."),
so it kept arriving as server_error on stdio and validation_failed on
remote. Third message in one family, and the round-7 test used the move
text for every case, which is why it missed this.

The parity table now carries all three real messages plus a control leg
using one the pattern list already covered — without it the table could
pass by matching everything.

Recorded in the pattern list's comment rather than left implicit:
matching prose is a stopgap, the structural fix is the
pad-structured-error/v1 marker that carries the code instead of inferring
it, and until a refusal emits one, this test is where a new wording has
to be added.

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

* test(mcp): use the real upstream codes in the parity fixtures (Codex round 9 nit)

The copy legs carried `validation_error` where the handlers actually
emit `malformed_override` and `invalid_override`. The 400 branch ignores
the body code today, so the test passed either way — which is exactly why
the fixture mattered: it was quietly recording a wrong contract, and a
future code-aware classifier would regress against a table that agrees
with it.

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

* docs(mcp): the upstream code is not forwarded to MCP clients (Codex round 10 nit)

The catalog said the server's own code (validation_error /
malformed_override) appears in the MCP message. It does not: the 400
branch emits code=validation_failed with a fixed "Validation failed."
message and the server's text in the HINT, discarding the finer-grained
code. Reworded to say what an agent actually receives, and to say that
telling the two refusals apart means reading the message.

Also carried the update-only qualifier on the github_pr exemption into
the README, matching the catalog and instructions.

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

* docs(items): state the exemption predicate, not the exemption list (lead ruling)

The lead's ruling on the github_pr reversal: make the REASON what the code
says, so the next key added to reserved metadata is evaluated against
'does this audience have a real writer?' rather than pattern-matched onto
a list that happened to be wrong for one key.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 23:17:24 -04:00
xarmian 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 82577a74 and is unchanged here).

## Noted, not fixed

handlers_items_copy_preflight.go already documents the same defect class for
RELATION fields — a same-named relation carries a SOURCE-workspace item id
across workspaces and is reported as a clean carry — and says the fix "belongs
in MigrateFields, for both callers at once". MigrateScope is now the mechanism
that comment asks for, but wiring relation fields through it is a separate
change with its own semantics to settle.

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

* fix(items,server): close Codex round 2 — grandfathered schemas, stale drop reports, scope coverage (BUG-2674)

Round 2 raised no P1 and three P2s plus a nit. All four were real; two are
defects in round 1's own fixes.

## Grandfathered schemas that already declare a reserved key

Round 1 added the four metadata keys to validateNoReservedFieldKeys, which stops
the collision being CREATED — and that gate deliberately GRANDFATHERS schemas
that already have one. I did not follow through: such a FieldDef still reached
ValidateFieldsDetailed, met the system-owned array MigrateFields hands through
by identity, and rejected it. A collection whose only sin is a field name
someone was once allowed to pick would fail every move and copy.

ValidateFieldsDetailed now skips reserved keys outright. That is not "ignoring
validation": these values have no user-authored schema to validate against, by
design — the schema entry is the anomaly, not the value. ValidateFields inherits
it through the same call.

This also closes the second half of the same finding: the preflight could report
one key in BOTH needs_value and carried, because the issue came from validating
a key the carried-append also emits. No issue, no collision.

## Dropped reports that were no longer true

MigrateFields computes Dropped BEFORE overrides merge and before defaults are
injected, so a key it lists may have been supplied moments later. Both the move
audit (which I added in this branch) and the preflight's dropped bucket reported
those anyway — claiming "we discarded your due_date" about an item that HAS a
due_date.

That is worse than the silence it replaced: silence at least does not send
someone hunting for data sitting on the item, and a report that cries loss over
visible data teaches the reader to distrust the channel. items.StillDropped
filters against the FINAL map so the report is true at the moment it is written.

## Scope coverage

attachments_copy_plan_test models a copy from workspace A into B and passed
SameWorkspace — the wrong scope stated confidently in a test whose whole subject
is a cross-workspace copy. It came from the bulk edit that threaded the argument
through, which picked a value rather than reading each fixture.

And nothing proved the MUTATING copy honours scope at all, so a call site
passing the wrong one — precisely the mistake a required argument exists to
prevent — would have shipped green. TestCopyEndpoint_ReferentialMetadataTravels-
OnlyWithinItsWorkspace covers both directions end to end. Mutant run: the store
call site pinned to SameWorkspace now fails the cross-workspace leg.

## The nit was an overclaim, so it is fixed in the code

38fa8fec said the copy and its preflight "use the same helper". They did not —
the helper lived in the server package and the store duplicated the comparison
inline, which is how a preview and its copy drift apart. items.ScopeFor now
lives in the package that defines the type and both call it.

Gates: lint 0 (after a gofmt fix lint caught) · go test ./... 0 ·
make test-pg 0 (3283). No web file touched; web gates not re-run.

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

* fix(items,server): move the validation skip to the right altitude, and finish the drop-report fix (BUG-2674)

Codex round 3, no P1, two P2s. Both say round 2's fixes were applied at the
wrong altitude — correct in the case in front of me, wrong for the callers I
did not enumerate.

## The validation skip was global; the problem is local

Round 2 made ValidateFieldsDetailed skip reserved keys. That validator is shared
with create, full update, artifact import and every bulk path — none of which
migrate anything. On a GRANDFATHERED schema (one that already declared a
reserved key before the round-1 gate), those paths genuinely did validate the
key, and the skip stopped them: arbitrary junk could be written into
implementation_notes through create, while fields_patch kept rejecting it via
ValidatePartialFields. Full and partial updates disagreeing about the same key
is a worse bug than the one I was fixing.

Reverted. items.SchemaForMigratedFields strips reserved FieldDefs from the
schema used to validate the OUTPUT of a migration, and only the four migration
and copy sites call it. Create and update keep enforcing the declaration,
because on those paths the user really is authoring that key.

## StillDropped reached two of three surfaces

The move audit and the preflight were filtered; the MUTATING copy was not.
migrateCopyFields returned the raw pre-override list and the 201 response
exposes it as warnings.dropped_fields — so one request could report the key
carried in the preview, PERSIST it, and still call it dropped in the copy's own
response. Three surfaces, two answers.

## And StillDropped's own test was too weak

Presence is not the test — present-and-non-nil is. The move path writes
overrides straight into the map including a nil, where the copy path deletes the
key, so `{"due_date": null}` on a move left the key present carrying nothing.
Treating that as restored suppresses a REAL drop, which is the silent loss this
change exists to end.

## A mutant survived, and the fixture was why

`out.Fields = schema.Fields[:0]` + appends mutates the caller's backing array.
The first version of the input-not-mutated assertion passed it twice: once
because it checked length (Go passes the struct by value, so the caller's slice
HEADER survives), and again after fixing that, because the reserved key was LAST
in the fixture — the one surviving field was written back into the slot it
already occupied. With the reserved key FIRST the corruption lands in slot 0 and
the mutant dies. Recorded in the test, because the next person writing a
"does not mutate its input" assertion in Go will reach for len() too.

## Comment accuracy

The reserved-set doc claimed callers "inherit additions without edits". True for
membership tests, false for the three places that need something a set cannot
supply — referentialItemFieldKeys, reservedFieldLabel, and the web's separate
RESERVED_FIELD_KEYS. Now listed, with the test that fires as the reminder. The
collections-handler comment described only parent/plan and now says it covers
two unrelated groups.

Gates: lint 0 · go test ./... 0 · make test-pg 0 (3285). No web file touched.

## Flagged, not fixed

The preflight labels a destination DEFAULT as from:"migrated" when the source
had the key but migration dropped it — origin is keyed on presence in the source
map, not on where the final value came from. Pre-existing and untouched by this
branch; filed separately rather than folded in.

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

* fix(items,server): close Codex round 4 — grandfathered defaults, override holes, duplicate carried entries (BUG-2674)

Round 4, no P1, three P2s. All three are the same case I kept half-fixing: a
GRANDFATHERED schema that declares a reserved key.

## Reserved declarations were still live in the defaults pass

MigrateFields carried reserved keys by identity but then ran the target schema's
defaults/required loop over them unchanged. A legacy Default was injected into
system metadata as though a user had authored it, and a legacy Required produced
a migration ERROR — which bulk move rejects on BEFORE reaching the
stripped-schema validation. So a legacy target requiring implementation_notes
failed bulk move while single move and copy succeeded: same key, same item, two
answers depending on which button was pressed.

## Overrides were a hole straight through the rule

A field override naming a reserved key was merged and then validated against the
STRIPPED schema — i.e. not validated at all. Two consequences, the second worse
than the first:

  - arbitrary junk could be written into implementation_notes / decision_log,
    bypassing the append guard BUG-2627 exists to enforce;
  - on a cross-workspace copy, an override could reintroduce the github_pr that
    MigrateFields had just dropped for leaving its workspace — defeating the
    scope rule by the simplest available route.

The copy paths now gate overrides against the stripped schema, so a reserved key
is undeclared there by construction and takes the existing malformed_override
refusal. The MOVE path had no declared-key gate at all and gets a dedicated one
(items.ReservedOverrideKeys). Refused rather than silently dropped: a caller who
asked for a value and got an item without it has no way to tell.

## The preflight emitted reserved keys twice

The carried walk iterated the raw target schema, so a grandfathered declaration
was emitted there AND appended again by the reserved pass. The existing
preflight/copy parity helper collapses carried entries into a map, so it could
not see it — a check that de-duplicates before comparing cannot detect
duplication. The walk now uses the stripped schema.

## Two mutants survived, and both were the test's fault

- The defaults fix had no test at all. Written after the fact, it fails on the
  unfixed code on both halves (injected default, spurious required error).
- The override test passed with the stripping REMOVED, because the ordinary
  destination does not declare github_pr — so UndeclaredOverrideKeys refuses it
  either way. Only a schema that DECLARES the key distinguishes the two
  implementations. The grandfathered fixture added for that fails the mutant
  with the PR link visibly written onto the copy.

Also added the falsy-value legs to StillDropped (false / 0 / "" are
restorations, not absences — a truthiness filter would report them lost) and
drove SchemaForMigratedFields off the canonical set so a mutant stripping only
implementation_notes fails.

Gates: lint 0 · go test ./... 0 · make test-pg 0 (3289). No web file touched.

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

* docs(items): correct the scope claim on ReservedOverrideKeys (BUG-2674)

Codex round 5. The previous commit message said reserved keys are refused "on
any path". True only for FIELD-OVERRIDE maps — the same-workspace move, the copy
preflight and the mutating copy. An ordinary `fields` / `fields_patch` map still
reaches them from the CLI, MCP, the web editor, artifact import, and Pad's own
note / decision / convention / GitHub writers, which is by design for the system
writers and a pre-existing exposure for the rest.

The doc comment now says which paths it covers and, more importantly, what it is
NOT — a general write gate. That distinction is the kind a future reader would
otherwise take on trust from the function name.

Round 5 was asked a different question than rounds 1-4: not "what is wrong with
this diff" but "enumerate every path that could meet a declared reserved key,
and is this approach right at all". It found ~10 further latent sites (create,
full and partial update, artifact import, bulk status/priority, terminal
options, unique_scope, computed, the web field editor, search, share
presentation) — all PRE-EXISTING, none regressions from this branch, and all in
the same grandfathered-schema case rounds 3, 4 and 5 kept surfacing.

They are filed as BUG-2685 with the full map rather than patched here. Four
rounds each finding another site is evidence about the DESIGN — reserved
metadata living in the generic fields blob means every schema-aware consumer has
to remember a special rule — and that is TASK-2657's territory, not a bigger
version of this bug. This branch's scope was: a move destroys system metadata.
That is fixed, tested and mutation-verified.

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

* docs(mcp,cli): disclose the move/copy metadata rules where the ACTOR reads them; ToolSurfaceVersion 0.22 (BUG-2674)

Caught by the pre-push step my own record exists for: I had documented this
change carefully in commit messages, the PR body and the item trail — every one
of them read by a human REVIEWING the work — and not at all in the artifacts read
by the agent or operator ACTING on it. That is the same miss twice before, both
times in this exact file.

`field` is accepted for `pad_item.action=move` (catalog_item.go), so the refusal
this branch adds is a limit an MCP agent will hit. It now says so in the param's
own description and in instructions.md, which is the text agents receive at
handshake. CLAUDE.md's `pad item move` and `pad item copy` blocks — the operator-
facing reference — gain the carry rules and the github_pr exception.

## ToolSurfaceVersion 0.21 -> 0.22

BEHAVIOR bump on the v0.9 / v0.16 / v0.17 grounds: no tool, action enum or param
SHAPE changed, but two things an agent can observe did.

A move used to DESTROY implementation_notes / decision_log / github_pr /
convention, silently, and now preserves them; drops of ordinary fields are
reported in the move's activity entry instead of vanishing. And a `field` setter
naming one of those keys answers `malformed_override` instead of writing it —
a write that was never legitimate, since it bypassed BUG-2627's append guard and
could reintroduce a github_pr the migration had just dropped.

Compat posture stated deliberately: a caller passing such a setter today gets a
400 where it previously got a silent corrupt write. Relying on the old behaviour
is relying on a defect — the same reading v0.17 took for the fields-blob
shadowing.

The bump was not free, which is the point: TestInstructionsMDVersionMatchesTool-
Surface and TestReadmeVersionMatchesToolSurface both went red and forced the two
other surfaces to be updated. That is the enforcement working — a version
constant nobody could change without visiting every place it is published.

Gates re-run for this commit: lint 0 · go test ./... 0 · make test-pg 0 (3289).
CI was already 7/7 green on f6775bcb; pushing this restarts it, which is the
correct trade against shipping agent-facing docs that describe the old behaviour.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 16:53:03 -04:00
xarmian 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.
2026-08-17 19:02:53 -04:00
xarmian 900b0c428a fix(mcp): summary-shaped item list on the HTTP transport (BUG-2305) (#1122)
A bare pad_item.list over remote /mcp returned up to 50 items with
FULL content bodies — the exact token blowup TASK-2000's limit was
written to prevent, unmitigated on the transport the zero-CLI plugin
makes primary. The limit was symmetric (actionItemList injects it
before dispatch); the SHAPE was not: the exec path projects through
cli.ToItemSummaries in the CLI, while the HTTP path forwarded the raw
handler response.

Fix follows the trail's pre-committed approach (the body's "add a
summary param to mapItemList" is refuted there — the server has no
projection parameter and a RouteMapper has no response hook): a
hand-written dispatchItemList, same shape and same cli.ToItemSummaries
projection as HTTPResourceFetcher.fetchItemList. mapItemList stays the
single URL/filter builder; the routeTable entry is removed so the
hand-written method is the one path. full:true opts into complete
bodies on both transports (stdio forwards it as the CLI's --full).

Audited the other list-shaped actions per the body's ask: pad_search
is symmetric (the STORE zeroes Item.Content in search results —
internal/store/search.go); pad_project.activity returns enrichment
metadata, no content bodies, same endpoint on both transports. Only
item list had the asymmetry.

Also rewrites the misleading actionItemList comment ("summary vs full
is a CLI-side concern") that misdescribed the HTTP path.

Old scope/verified-email fixtures stubbed `{"items":[]}` — an object
shape the real endpoint never returns (it writes a bare array); they
only passed because the routeTable path packaged blindly. Fixtures
corrected to the real shape.

Tests: TestHTTPItemList_DefaultIsSummaryShape +
TestHTTPItemList_FullOptsIntoCompleteBodies drive the REAL server +
store as a counterfactual pair — the marker sits past the
content_preview cut, the full leg proves it flows through the same
path, the default leg proves the projection strips it.
Mutation-verified: removing the dispatch case fails (route gone);
skipping the projection fails (leak caught).

No ToolSurfaceVersion bump here: BUG-2302's PR carries the single
0.19→0.20 bump; this PR appends its lines to that changelog entry
after it lands (lead's sequencing ruling — every changelog sentence
true at its own merge time).

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-16 14:17:30 -04:00
xarmian 727cd80927 fix(mcp): explicit tool annotations from catalog write-shape knowledge (BUG-2302) (#1121)
mcp-go's NewTool injects default annotations on every tool —
ReadOnlyHint:false, DestructiveHint:true, OpenWorldHint:true — and
buildToolFromDef never overrode them, so every Pad tool advertised
itself as destructive, including pure reads like pad_search and
pad_project. Hosts use destructiveHint to decide whether to prompt;
mislabeling reads trains users to click through prompts.

Derive the block in buildToolFromDef from the catalog's own knowledge
(readOnlyActions — the same single source the tool-surface serializer
uses — plus a new sibling additiveWriteActions allowlist):

- every action read-only → ReadOnlyHint:true, DestructiveHint:false,
  IdempotentHint:true (pad_search, pad_project, pad_attachment,
  pad_meta, pad_playbook);
- writes all purely ADDITIVE → ReadOnlyHint:false,
  DestructiveHint:false (pad_workspace: invite/create/claim/restore;
  pad_library: activate — codex round 1: marking additive writes
  destructive reintroduces the prompt-training harm at tool level);
- any overwrite/delete-capable action → the conservative
  ReadOnlyHint:false, DestructiveHint:true (pad_item, pad_collection,
  pad_role) — unchanged on the wire from the old defaults;
- OpenWorldHint:false everywhere (pad tools are closed-world).

pad_set_workspace gets a hand-written block (write, non-destructive,
idempotent, closed-world).

Also adds the missing pad_item.history entry to readOnlyActions —
documented read-only since v0.14 but reported read_only:false on the
tool-surface descriptor.

ToolSurfaceVersion 0.19 → 0.20 (behavior bump, v0.9/v0.16 precedent):
no tool names, action enums, or param shapes changed. instructions.md
and README headings retitled per the drift tests. The changelog entry
describes only this change; BUG-2305 appends to it if it ships in the
same window (one bump total).

Tests: TestCatalogTools_AnnotationsExplicit pins a literal per-tool
read/additive/destructive table (deliberate second enumeration — a new
tool, or a write action added to an all-read or all-additive tool,
fails loudly until someone decides its class);
TestAdditiveWriteActions_NoStaleEntries guards the new allowlist
(real catalog pairs only, never overlapping readOnlyActions);
TestSetWorkspaceTool_AnnotationsExplicit covers both deployment
variants; pad_item.history joins the read spot-checks.
Mutation-verified both directions: destructive-polarity flip fails 10
tools; always-destructive fails the two additive rows.

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-16 13:49:00 -04:00
xarmian 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.
2026-08-15 20:24:31 -04:00
xarmian 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
2026-08-15 13:17:01 -04:00
xarmian 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
2026-08-15 12:12:32 -04:00
xarmian 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
2026-08-15 11:11:45 -04:00
Claude 02b302519e feat(nix): add flake packaging for pad with CI build
Adds a Nix flake exposing the pad binary as packages.default (buildGoModule
+ importNpmLock for the embedded SvelteKit UI), a devShell, and flake
checks (package build with `go test ./...`, plus a `pad --version` smoke
test). nix/package.nix is written nixpkgs-submission-ready (no
flake-specific inputs) so it can later be adapted for pkgs/by-name.

Also adds a GitHub Actions workflow that runs `nix flake check` and
`nix build` on push/PR, and documents `nix run` / `nix profile install`
/ `nix develop` in the README.
2026-07-26 22:49:10 +00:00
Dipak Chaudhari 69b361d7b2 docs: document shell completion setup in the README (#974)
Adds a Shell completion section to the CLI Reference covering install
steps for bash, zsh, fish, and PowerShell, and calls out the dynamic
completions that already exist (collection names, --workspace,
--status/--priority).

Closes #905
2026-07-20 18:02:50 -04:00
King Star 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).
2026-07-14 15:48:12 -04:00
xarmian c72fe5a663 feat(items): add unparented filtering contract (#926)
* feat(items): add unparented filtering contract

* fix(items): preserve unparented projection state

* fix(views): preserve reserved filter on reset

* fix(items): resync projection scope changes

* fix(items): address PR 926 review findings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

scopeEpoch advanced only after listIndex() returned, so a reconcile response
racing the fetch saw the old epoch and could clear the pendingResync the resync
set at start. Bump the epoch before the network await instead.
2026-07-13 22:46:55 -04:00
Ronnie Li e9d308a64e fix(agent): support OpenCode install target (#923) 2026-07-11 19:01:08 -04:00
Beniamin Kmieć 51d68e7d7d feat(cli): add pad item open command (#919)
* feat(cli): add pad item open command

* fix(cli): make item open use canonical web routes

* fix(store): preserve moved item refs in reads

* revert: keep item open change scoped

* fix(cli): open item URL directly
2026-07-11 18:53:54 -04:00
xarmian 43c31c826e feat(cli): add claude-code + codex targets to pad mcp install (TASK-2040) (#909)
Extend `pad mcp install/uninstall/status` beyond the three JSON desktop
clients to cover the two most prominent CLI agents:

- claude-code — writes a project-local `.mcp.json` in the current
  directory (JSON, same mcpServers shape as the other clients). Because
  the config is project-scoped, it's install-on-request only: excluded
  from `--all` and `pad mcp status`, which cover the per-user clients.
- codex — writes an `[mcp_servers.pad]` table into `~/.codex/config.toml`
  (TOML). New load/merge/write path (BurntSushi/toml) that preserves
  unrelated top-level keys and other mcp_servers entries, is idempotent,
  tightens perms to 0600, and refuses to clobber a non-table mcp_servers.

Generalizes the Agent struct with a Format discriminator (JSON/TOML) and
a CWDBased flag; Install/Uninstall/Status dispatch to the right
reader/writer and resolve cwd-vs-home per agent. Existing
claude-desktop/cursor/windsurf behavior is unchanged. FindAgent's error
string is now built from the agent list. Docs updated in README.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 22:04:22 -04:00
xarmian 0ed0381f1f docs: document blank template, note hidden demo in templates list (TASK-2041) (#908)
The templates section listed startup/scrum/product/hiring/interviewing but
omitted `blank` — the custom, system-collections-only template that is the
designated entry point for the agent-driven `/pad onboard` flow (PLAN-1496).
Add a `blank` example + prose framing, and document the picker-hidden `demo`
template (startup layout + sample data, buildable via `--template demo`).

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 21:51:04 -04:00
xarmian 5a6659ea66 docs: document Docker first-run bootstrap-token flow (TASK-2038) (#907)
Add a first-run subsection to the README Docker section: open :7777, grep
the 'Pad first-run setup' banner out of docker logs, and open the printed
/setup#token=<token> URL to create the first admin. Keep docker-exec
'pad auth setup' as the loopback fallback and note PAD_BYPASS_SETUP_TOKEN.

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

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

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

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

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

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

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

Tests added for each fix.

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:26:41 -04:00
xarmian 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
2026-07-08 15:50:33 -04:00
xarmian 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
2026-07-08 15:28:21 -04:00
xarmian 1735736cbd fix(mcp): sync tool-surface docs to v0.9 after item-list default bump (#844)
TASK-2000 bumped ToolSurfaceVersion 0.8->0.9 (summary-shaped item list)
in parallel with TASK-2005's v0.8 doc sync + drift guard; the two merged
cleanly as text but left instructions.md/README at v0.8, tripping the
guard. Bump both to v0.9 and note the change in the changelog line.
2026-07-07 16:34:03 -04:00
xarmian 8c609be2e3 feat(store): guard against schema-ahead downgrade + pre-migration snapshot + upgrade docs (TASK-2006) (#843)
The migration runner only applied missing embedded migrations and never
detected a DB that was AHEAD of the binary, so a brew/docker downgrade
silently ran old code against a newer schema. It also took no backup
before migrating, and there were zero upgrade docs.

- guardSchemaAhead: refuse to start when schema_migrations contains a
  version that sorts after the highest embedded migration (a downgrade).
  Escape hatch: 'pad start --force' / PAD_ALLOW_SCHEMA_AHEAD=1. Applied
  to both the SQLite and Postgres migration paths.
- snapshotBeforeMigrate (SQLite only): copy the DB file to
  <db>.pre-<VERSION> before applying pending migrations, but only when
  upgrading an existing DB (pending AND already-applied migrations).
  WAL-checkpointed, atomic temp+rename copy, and preserves an existing
  snapshot on retry so a failed multi-step upgrade can't clobber the
  original rollback point. Postgres is skipped (pg_dump/PITR is the DBA's).
- Docs: 'Upgrading Pad' in README + an 'Upgrading' section in
  docs/deployment.md (forward-only rule, guard behavior, snapshot, flow).
2026-07-07 16:32:20 -04:00
xarmian 6e964c4bd2 docs(mcp): sync tool-surface docs to v0.8 + drift guard (TASK-2005) (#841)
The in-binary MCP instructions.md and README declared v0.4/eight-tool
surface while version.go ships ToolSurfaceVersion 0.8. Every MCP session
got the stale map, so agents under-discovered v0.5-v0.8 tools
(pad_library; pad_item restore/backlinks/export/import; pad_workspace
deleted/restore; pad_project report; pad_collection/pad_role update).

- instructions.md: retitle to v0.8, add pad_library + all new actions,
  nine resource x action tools (ten total).
- catalog_meta.go: drop stale 'v0.4'/'eight' from agent-facing tool
  descriptions.
- README: catalog table + tool_surface_version bumped to 0.8.
- Add a per-tool drift guard (tool_surface_drift_test.go): fails when a
  catalog action is missing from its documented action list in
  instructions.md/README, or when the version strings drift from
  ToolSurfaceVersion.
2026-07-07 16:28:01 -04:00
xarmian f235a04316 docs(readme): reflect Pad Cloud + remote MCP as shipped (IDEA-1790) (#785)
README still framed Pad as strictly local-only ('no cloud, no accounts
required', 'never leaves your laptop') even though Pad Cloud and the
remote MCP server at mcp.getpad.dev are both live. Adds a hosted-option
section under Installation, a remote-MCP pointer in the MCP section, and
softens the local-only absolutes — while keeping the local-first
identity and self-hosted-first-class framing intact.

CLAUDE.md and getpad.dev docs were verified already up to date; this
closes the last stale surface from IDEA-1790.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 00:16:55 -04:00
xarmian a6ca1f3910 docs: add cross-link nav row to README (site, blog, changelog, X, Bluesky) (#617)
A single centered nav line right under the badges so readers can reach the
marketing site, docs, blog, changelog, and social accounts from the top
of the repo without scrolling.

Covers TASK-1569 (blog/X/Bluesky added) and TASK-1574 (getpad.dev/docs/
changelog quick-nav). Bundled into one PR since both edit the same file
and TASK-1574 explicitly called out the overlap.

Refs: PLAN-1571, TASK-1569, TASK-1574
2026-05-23 11:42:47 -04:00
xarmian 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.
2026-05-17 13:40:15 -04:00
xarmian 8c9974f6fb feat(cli,mcp): expose 'role update' via CLI and MCP catalog (TASK-1512) (#574)
* feat(cli,mcp): expose 'role update' via CLI and MCP catalog (TASK-1512)

Third of three TASK-1497 capability-spike follow-ups (after #572
and #573). The handlers_agent_roles.go::handleUpdateAgentRole PATCH
handler and the internal/cli/client.go::UpdateAgentRole HTTP client
method already existed. Only the agent-facing surfaces were missing.

- cmd/pad: new 'pad role update <slug-or-uuid>' Cobra subcommand
  with --name / --slug / --description / --icon / --tools /
  --sort-order flags. Uses cmd.Flags().Changed for omit-if-unset.
  Positional arg = lookup ref; --slug = new slug value (rename).
  Empty-string clears for description and icon (the store treats
  *string("") as "clear", matching collection update semantics).

- internal/mcp/catalog_role: new 'update' action + supporting params
  (new_slug, sort_order). The catalog disambiguates lookup-slug
  (in path) from rename-target (in body) with the new_slug input,
  avoiding the conflated-semantics footgun.

- internal/mcp/dispatch_http_routes: new mapRoleUpdate mapper.
  Path uses input.slug for the lookup; body's "slug" key is sourced
  from input.new_slug. String fields use key-presence semantics so
  empty-string clears round-trip to the store.

- Tests cover canonical body (with AgentRoleUpdate round-trip),
  new_slug-to-body-slug mapping, empty-string clearing, and
  required-arg validation.

- README.md + internal/mcp/instructions.md pad_role action lists
  updated to include "update".

Pairs with TASK-1510 + TASK-1511 to complete the workspace-mutation
trio the /pad onboard playbook (TASK-1499) needs to adapt seeded
roles, collections, and schemas to each project's actual shape.

Parent: PLAN-1496.

* fix(cli,mcp): rename role-update flag --slug → --new-slug (Codex round 1)

P1 finding on PR #574: pad_role.update via local stdio MCP was
silently broken. BuildCLIArgs translates MCP property "slug" to the
CLI's positional <slug> AND to the --slug flag (same key reused), so:

  pad_role.update slug=<uuid>
    → pad role update <uuid> --slug <uuid>
    → tries to rename the role's slug to the literal UUID. BAD.

  pad_role.update slug=implementer new_slug=engineer
    → pad role update implementer --slug implementer
    → new_slug ignored entirely, no rename.

The HTTP dispatcher had the disambiguation right (mapRoleUpdate
already mapped MCP new_slug → body slug). The CLI flag name was the
problem.

Renamed --slug to --new-slug. Now MCP "slug" maps to the positional
only (lookup), and MCP "new_slug" maps to --new-slug (rename target).
Both transports symmetric. Updated example in --help, the liveCmdhelpDoc
fake, and the change-detect block.

Parent: PLAN-1496, Codex round 1 on PR #574 / TASK-1512.
2026-05-17 02:33:36 -04:00
xarmian f76520f6e7 feat(cli,mcp): expose 'collection delete' via CLI and MCP catalog (TASK-1511) (#573)
* feat(cli,mcp): expose 'collection delete' via CLI and MCP catalog (TASK-1511)

Mirrors TASK-1510 (collection update). The HTTP handler at
handlers_collections.go::handleDeleteCollection already supported
DELETE on a collection (owner-only, soft-deletes the collection and
every item in it). Wires both agent-facing surfaces:

- internal/cli/client.go: new DeleteCollection client method.
- cmd/pad: new 'pad collection delete <slug>' Cobra subcommand
  (no --force; the help text is the confirmation contract).
- internal/mcp/catalog_collection: 'delete' action passes through
  to the CLI; tool description updated.
- internal/mcp/dispatch_http_routes: simple routeSpec entry for
  DELETE /api/v1/workspaces/{workspace}/collections/{slug}. No
  custom mapper needed — no body, no field coercion.

Pairs with TASK-1510 as the second adaptation primitive for the
/pad onboard playbook (TASK-1499): when the onboard interview
discovers a seeded collection that doesn't fit the project, the
agent now has a way to remove it before creating the right one.

Tests:
- TestRouteTable_CollectionDelete (route substitutes correctly)
- catalog_readonly bijection + liveCmdhelpDoc fake updated.

Parent: PLAN-1496.

* docs: correct collection delete contract per Codex review (round 1)

Two findings on PR #573 — both documentation, no code behavior change:

1. CLI Long help / Short blurb / MCP description claimed delete
   "removes seeded collections" and the onboard use case targets
   template-seeded collections. But store.DeleteCollection refuses
   any collection where is_default=true, and every template seed is
   is_default=true. The advertised use case wouldn't actually work.
   Updated docs to clarify: delete is for USER-CREATED collections;
   template seeds must be adapted via 'pad collection update'.

2. Both CLI help and MCP description claimed "AND every item in it"
   gets archived. The store delete path only sets collections.deleted_at
   and never touches items. The web UI hides them via the join, but
   raw API queries still surface them. Updated docs to be honest:
   items are NOT cascaded.

Captured the underlying behavior limitation as a follow-up: IDEA-1513
("Lift is_default restriction on collection delete or add a
cascade-items option") — surfaces options 1-4 for lifting the guard
plus the items-orphan issue.

Parent: PLAN-1496, addressing Codex round 1 on PR #573 / TASK-1511.

* docs: tighten collection delete contract per Codex review (round 2)

Three P3 documentation-drift findings:

1. internal/cli/client.go::DeleteCollection Go doc still said "and
   all items in it" — missed it in round 1. Updated to describe the
   actual behavior (collections.deleted_at only; items orphaned with
   soft-deleted collection_id; is_default rejected).

2. CLI Long help and MCP description claimed restore is available
   "via the API," but there is no restore endpoint and no
   RestoreCollection client method. Recovery is database-backup only.
   Both docs updated.

3. catalog_collection.go:33 slug ParamDef only mentioned action=update;
   action=delete needs it too. And the headline description still
   said "list, create, and update" — three actions when there are
   now four. Both fixed.

Parent: PLAN-1496, addressing Codex round 2 on PR #573 / TASK-1511.

* docs: update pad_collection action lists in instructions.md + README (round 3)

Codex round 3 finding: two top-level reference docs still advertised
pad_collection as list/create only. internal/mcp/instructions.md is
embedded into the MCP initialize() handshake instructions — stale
guidance there means MCP clients miss update/delete entirely. README's
catalog table had the same drift.

Parent: PLAN-1496, Codex round 3 on PR #573 / TASK-1511.
2026-05-17 02:15:37 -04:00
xarmian 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.
2026-05-13 18:02:34 -04:00
xarmian 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.
2026-05-12 21:44:45 -04:00
xarmian 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.
2026-05-04 10:32:51 -04:00
xarmian 273d75c06e docs(mcp): refresh README + SKILL.md for v0.2 surface (TASK-976) (#359)
Updates the in-repo documentation to match what shipped in PLAN-969:
- README.md's MCP section now describes the v0.2 catalog (8 tools,
  resource × action shape) instead of the retired v0.1 verb explosion.
  Documents both stability constants (CmdhelpVersion 0.1 +
  ToolSurfaceVersion 0.2) and points consumers at the structured
  error envelope contract.
- skills/pad/SKILL.md gets a one-line callout that the MCP surface is
  hand-curated and distinct from the CLI verb tree this skill drives.
  Prevents future "I added a CLI command, why isn't it in MCP?"
  confusion.
- CLAUDE.md was already updated in TASK-981; verified to match.

Companion change for getpad.dev/mcp/local lives in ../pad-web.

Parent: TASK-976 → PLAN-969.
2026-05-01 18:55:03 -04:00