Commit Graph

65 Commits

Author SHA1 Message Date
xarmian ff6d280bcc feat(skill): the skill and the ship playbook load an item's brief with pad item context (PLAN-3598 slice (d)) (#2104)
Before Performing Work in skills/pad/SKILL.md and step 2 of the shipped
ship playbook now load the item's brief in one call, `pad item context
<ref> --intent <intent> [--role <role>]`, instead of a convention query per
trigger. The brief carries the item and its seq, the parent chain with
acceptance criteria, the matching and always-on convention bodies in
relevance order (implement also pulls the commit and PR rules), matching
playbooks, dependencies, decisions and links, within a budget; omitted parts
are named by ref, and conflicts are flagged.

The per-trigger `pad item list conventions --field trigger=... --full`
queries stay, as the fallback for an action with no item and for a server
without the endpoint (lead ruling).

Gated on the lead's dogfood of the brief on :7777 at 4d017ddcf (#2101).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 10:05:35 -04:00
xarmian 3631139de0 feat(acceptance): one Go reading of a body's acceptance criteria (PLAN-3598 slice (a)) (#2091)
* feat(acceptance): one Go reading of a body's acceptance criteria (PLAN-3598 slice (a))

`pad item context` (PLAN-3598) returns a governing spec's acceptance
criteria as data. Until now the only readings were prose: the verify and
decompose playbooks tell the agent to find the section and skip skeleton
lines. internal/acceptance.Parse implements that same rule: the first
"Acceptance criteria" heading at any level, up to the next heading of the
same or higher level; "- AC-N: text" bullets, with indented continuation
lines joined; angle-bracket or bare-ellipsis text reported as a placeholder,
not a criterion; headings and AC lines inside code fences ignored.

The spec template's own skeleton is pinned to read as two placeholders and
no criteria, so a fresh spec never claims real ones.

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

* fix(acceptance): CommonMark fences, setext headings, and nested AC bullets (PLAN-3598 slice (a))

Codex round 1 at b1091c645 found three P2s. A fence closed on any line
sharing its first three characters, so a four-backtick block ended at a
three-backtick line or an info-string line, and fenced headings could end
the section. A bullet indented under a criterion counted as a criterion of
its own. Setext headings were not recognized at all.

Fences now close only on a run of their own character at least as long as
the opener with nothing after it, and a backtick opener with a backtick in
its info string is not a fence. Setext underlines make headings only under
a paragraph line, never under a list item or an indented continuation,
where the underline is a thematic break. A criterion is a top-level bullet;
an indented AC-shaped bullet joins the text of the criterion above it.

Six of the new cases fail on the previous parser.

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

* fix(acceptance): nesting follows the parent item's content column (PLAN-3598 slice (a))

Codex round 2 at ad43c92d1: the round-1 fix allowed a criterion at most
one space of indent, so a top-level list indented two or three spaces lost
its criteria. Nesting is now decided the CommonMark way: a bullet indented
to at least the open item's content column is nested in it, and joins the
criterion's text when that item is one. A bullet short of that column,
indented up to three spaces, starts a new top-level item.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 21:19:39 -04:00
xarmian bc329fb7f4 feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.

- server: `replace` request member; pad_content_writes_unguarded_total
  {door,kind}; warnings.unguarded_content_write on an agent door's unguarded
  content write; a seq update_conflict on a content write (applier route
  included) carries content_write, changed_at, changed_by_kind and a
  "do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
  a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
  on both transports; the update description leads with read, edit, write
  with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
  mcp.md, deployment.md, README, update help, plan and onboard playbooks.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 05:29:53 -04:00
xarmian b950f311c6 fix(playbooks): the shipped ship playbook loads conventions with --full and guards its status updates with expected_seq (TASK-3575) (#2040)
Step 2 listed conventions without --full, which prints the summary shape
with no content: the agent read convention titles but never the rules
(a convention's rule IS its content). Measured on :7777: the same list
has no content key without --full and the 2319-character body with it.

The status-update examples now carry --expected-seq (the seq from the
read, re-read before later writes), so a task changed meanwhile is
refused (update_conflict) instead of written over.

Swept every shipped playbook template: this was the only list call that
needed bodies (sdd_extract's specs list only checks for existing specs,
and the rest load with item show --agent).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:26:08 -04:00
xarmian 47ca9e521a fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B)

Lead ruling: a repeated or empty field key or select option is a broken
schema, and the web renders fields and options as lists keyed by them, so
one repeat blanked the view (BUG-3538). PR A made the web render such a
schema; this stops new ones being written.

- Collection create and update refuse, with 400 validation_error naming
  it, a schema in which a field has no key, two fields share a key, or a
  select field lists an empty option or the same option twice
  (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions).
  WRITE check only: a stored schema with a repeat still reads, and an update
  that sends no schema still applies. Live census on :7777: 0 of 213
  collections affected.
- Dynamic Client Registration registers a repeated redirect URI once (it is
  unauthenticated, and the connected-apps page lists URIs keyed by value).
- ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and
  ["1"] apart while both scanned to "1".
- Pins that convention commands and surfaces are deduped on read: they are
  already (normalizeItemConventionMetadata); the census verdict that said
  otherwise was wrong.

Not done, deliberately: refusing repeated app-manifest event names or
companion field keys. appmanifest.Parse also re-reads a STORED manifest on
upgrade, so a new refusal there would fail a read of an installed app; PR A
made both web lists render repeats.

ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls
they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400.

* fix(server): hold companion and template schemas to the key/option rule (TASK-3539)

Codex round 1: writers that bypass the collection handlers could still store
a schema that repeats or empties a key or option.
- An app's companion collection schemas are checked in the install/upgrade
  preview, on the NEW manifest beside the reserved-key check, and refused
  422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an
  installed app's stored manifest.
- Every template and default collection schema the repo ships is checked by
  a test (they seed through the store, not the handlers).
- The legacy-schema test also reads and creates items in a collection stored
  with a repeat.
Workspace and artifact import, and the library option widening (which
already dedupes), are unchanged: refuse on write only.

* docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539)

Codex round 2: the current-version labels said v0.83, the history began at
v0.82.

* docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539)

Codex round 3: the intro still said v0.81, stale since before this change.

* fix(server): the option check covers select and multi_select only (TASK-3539)

Codex round 4: it refused empty or repeated options on every field type, but
only a select or multi_select's options are a rendered list. Another type can
carry a legacy options list (the old field DSL put a third part there), and
refusing it would block an unrelated schema save. Tested both ways.

* test(collections): the template schema check is independent of the validator (TASK-3539)

Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was
circular. It now uses its own check. Also records, at the write, why the
library option widening is not held to the validator: it only appends missing
words (never a repeat), and refusing would fail an item create over a legacy
schema.
2026-10-10 03:04:20 -04:00
xarmian f5a00d5b13 feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1)

The switch, with no counting change yet (PR 2 server, PR 3 web).
CollectionSettings.tracks_work: false makes a collection REFERENCE
material; absent means work, today's behaviour, so old rows, old writers
and a client/server skew are unchanged. One Go predicate
(models.CollectionTracksWork) and one TS twin (collectionTracksWork).

- New collections: the creator's value, else system collections are
  reference and others are work iff their done field can finish. New
  template Docs collections are created as reference (Dave).
- Migration 135 / PG 107: existing Conventions and Playbooks become
  reference; everything else, Docs included, stays work.
- Settings are written wholesale, so an update whose settings omit the
  key KEEPS the stored value (CarryTracksWork), for every writer.
- A typed top-level `tracks_work` on POST/PATCH sets just that key
  server-side. CLI: --tracks-work=true|false (a string flag, so false
  survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A
  non-boolean is refused 400; the CLI checks the response, since an
  older server ignores the member.
- Web: the Display tab's "Track as work" toggle; the editor now starts
  its settings write from the STORED settings (buildCollectionSettings),
  so keys it does not render (content_template, tracks_work) survive.

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

* fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex)

The update now computes the carried settings from the row it re-read under
the write lock, so a concurrent settings write cannot be reverted by a carry
read before the lock. The SQLite migration evaluates json_type/json_extract
only inside a CASE guarded by json_valid, so malformed settings on a system
row cannot abort it.

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

* fix(models): a collection whose options the default terminals close is work (PLAN-3535)

DefaultTracksWork required declared terminal_options, but every lifecycle
reader falls back to DefaultTerminalStatuses when a done field declares
none, so a `status: open, done` collection finishes items and was being
created as reference. Work now means one of the done field's options is
terminal by the same resolver the readers use. Found by PR 2's store tests,
whose fixture collection is exactly that shape. Also corrects api.md's
MCP version (0.81).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 21:47:50 -04:00
xarmian 9d2572eb7b feat: adopt pre-origin conventions and playbooks as built-ins, once, by exact title (TASK-3462 U4) (#1878)
* feat: adopt pre-origin conventions and playbooks as built-ins, once, by exact title (TASK-3462 U4)

Items made before built-in origins were recorded get the origin their exact
title names (several entries sharing a title: the one whose body matches; no
title match: a playbook's invocation_slug), with no seed version, so they
read current or unknown_origin and get the 2-way preview (Dave's ruling: no
historical hashes).

It runs once per instance at server start, recorded in platform_settings,
and only for items created before the origin migration was applied: a later
item with no origin deliberately has none (a copy, an older import). Live
items in convention/playbook collections only. Non-fatal on failure.

Tests on both backends: exact title adopts, and an unedited copy then reads
current; an edited one reads unknown_origin; a renamed playbook adopts by
slug; soft-deleted and post-cutoff items are not adopted; a second run does
nothing. Each of the four mutants (cutoff, once, deleted, slug) is killed.
The matcher has unit tests, including ambiguous titles.

* fix: legacy built-in adoption counts the migration's own second, refuses a title/slug conflict, and reads only convention/playbook items (TASK-3462 U4, codex r1)

- The cutoff is at or before the migration's second (timestamps are
  second-precision), parsed rather than compared as text, since imported items
  keep their own format.
- A playbook whose title names one built-in and whose invocation_slug names
  another is ambiguous and is not adopted.
- The pass lists the convention and playbook collections first and reads only
  their items, instead of every originless item on the instance.
Both new tests failed on the previous code.

* fix: legacy built-in adoption requires the workspace to predate the cutoff too (TASK-3462 U4, codex r2)

Import keeps each item's created_at from the bundle, so an old bundle
imported after the origin migration but before the pass first ran read
as legacy. Every import door mints a new workspace and none writes into
an existing one, so the workspace's created_at bounds arrival.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 06:55:33 -04:00
xarmian deec62235e feat(decision): executable conventions, the server half (TASK-3119 U1a) (#1859)
* wip(decision): conventions set (TASK-3119 U1a)

* chore(eval): executable-conventions eval through the production question path (TASK-3119 U0)

Measures, before any chip exists, how often the decision provider says a
hand-labelled item breaks a convention at the 0.9 threshold: per
convention precision and recall (all cases and real-only), the false
'Breaks' rate of a behaviour convention, and the calls and tokens spent.
State is decision.BuildItemState, the question a decision.Noul worded as
the U1 contract says, the model decision.DefaultModel.

The hand labels were posted to TASK-3119 before any score existed; the
trail comment carrying them is dropped from every state, and the dry run
refuses a state that still carries them. Reads TYPESAFE_API_KEY only.

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

* chore(eval): -links and -only for the conventions eval (TASK-3119 U1a)

-links builds each state as the conventions set does, with the item's
links (from the dump: the parent link only; blocking links are not in
pad item show's JSON). -only runs one convention. Used for the lead's
U1a gate: CONVE-1286 rerun with links.

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

* fix(decision): liveness before EACH split call (TASK-3119 U1a, codex r1)

A split set makes several provider calls; the liveness check ran once
before them, so an item or workspace deleted between calls was still
sent in the later ones. It now runs immediately before every call. A
test deletes the item from inside the first call and the second is not
sent; checking only once fails it.

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

* fix(decision): decision_check has no default; allow-list the eval's decode (TASK-3119 U1a)

A default "on" was written into every new convention: imports gained a
decision_check field (artifact import parity) and an activated built-in
no longer matched its seed (TASK-3462 would read every one as diverged).
Absent already reads as on, so the field carries no default, in the
template and both migrations.

The conventions eval's readJSON is a raw decode the reserved-field guard
counts; allow-listed with its reason, like the attention harness.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 12:42:32 -04:00
xarmian 1c23a1bcbc feat: onboarding asks where an existing project's open work lives and offers to bring it in (TASK-3454) (#1853)
* test: onboarding an existing project asks where its open work lives and brings it in after saying there is no importer and confirming (TASK-3454)

* feat: the onboard playbook asks where a project's open work lives and offers to bring it in, saying there is no importer and confirming first (TASK-3454)

* test: open work is untrusted data, its destination is confirmed when B2 did not run, and the paste fallback does not assume gh (TASK-3454, codex r1)

* fix: open-work text is data not instructions; the destination is confirmed when B2 did not run; the paste fallback is keyed on being able to list issues (TASK-3454, codex r1)

* test: a full page of issues is reported as possibly truncated (TASK-3454, codex r2)

* fix: a full page of issues is reported as possibly truncated rather than silently cut (TASK-3454, codex r2)
2026-10-07 08:18:53 -04:00
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00
xarmian 05c039f131 fix: agents load items with --agent; --format markdown stays body-verbatim and names an empty body (TASK-3451) (#1842)
* test: markdown stays body-verbatim and names an empty body; no agent-facing text loads an item with it (TASK-3451)

* fix: skills and playbooks load items with --agent; --format markdown stays body-verbatim and names an empty body on stderr (TASK-3451)

* test: the guard covers every flag spelling and agent-read doc; a stale empty body is not claimed empty (TASK-3451, codex r1)

* fix(cli): the empty-body hint is not printed over a stale body (TASK-3451, codex r1)

* fix: remaining skill load lines use --agent; the guard exempts only round-trip descriptions and covers quoted refs and the read alias (TASK-3451, codex r2)

* fix(skill): the convention-body fallback loads with --agent (TASK-3451, codex r3)
2026-10-07 00:02:09 -04:00
xarmian a91fcce7f9 fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446)

The blank template seeds Conventions with trigger [always] / scope [all]
(Playbooks [manual] / [all]) and nothing widened them, so all 18 library
conventions were refused on every door: CLI and MCP library activate, the
web Activate button, and the onboard playbook's literal B3 create.

A create in a system collection declaring the convention or playbook
artifact kind now adds a missing trigger/scope value to the select's
options when the LIBRARY itself uses that word
(collections.LibraryOptionVocabulary, computed from the library). The write
goes through UpdateCollection with its concurrency token, ahead of
validation, and is reported in warnings.options_added (CLI: stderr) and in
the created activity's metadata, which the item timeline renders. Any other
value is refused as before. Editors may trigger it (lead ruling). The
onboard playbook's B3 now says how an owner adds an invented trigger.

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

* chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive)

Lead ruling: a create in a system conventions/playbooks collection now
adds a library trigger/scope word instead of refusing it, and its result
gains an omitempty warnings.options_added. No tool name, enum or param
moved; tools/list golden bytes are unchanged, initialize moves for the
version and the instructions note.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 16:48:50 -04:00
xarmian a4990d7cbb fix(project): a collection's own abandoned_options decide what the changelog, standup and reports count as shipped (BUG-2347) (#1464)
* fix(project): a collection's own abandoned_options decide what the changelog, standup and reports count as shipped (BUG-2347)

The completed-work views excluded abandoned closes by a global name list
(NegativeTerminals). A collection's own abandon word was invisible to it:
live, `pad project changelog` listed PLAN-2326 (plans status `overturned`)
as shipped, along with archived marketing posts and docs.

A select field gains optional `abandoned_options`, validated as a subset of
its terminal_options on collection create/update. One resolver
(PositiveTerminalValuesForDoneField) serves the changelog, standup, the CLI
copy and report throughput: completed = terminal - abandoned, with the global
names as the fallback when a field declares none. Templates seed it.

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

* fix(store): workspace import refuses abandoned_options that are not terminal, like the collection doors (BUG-2347, codex round 1 on #1464)

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

* test(store): the import test edits the parsed schema, not its text, so it holds on Postgres JSONB (BUG-2347)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 12:52:46 -04:00
xarmian d5085bba00 feat(items): multi_relation — an ordered list of references (TASK-2999 / PLAN-2857 U4) (#1342)
* feat(items): multi_relation shape validation, DSL target, and rename propagation (TASK-2999 / PLAN-2857 U4, rows 1/17/18)

The first three rows of U4's population table, chosen because two of them are
the rows the task's own 5-row scope table did not contain and both fail
SILENTLY.

The table came first (posted on TASK-2999). A mechanical enumeration of every
site keyed on the literal "relation" finds 37 decision points in 21 files — 21
Go sites in 9 files, 16 web sites in 12 — against a scope table naming 5. By
CONVE-35 that is a measurement of the enumeration, not a longer to-do list, so
the deliverable under review is the table.

ROW 1 — internal/items/validate.go, `case "multi_relation"`.

Shape only; this package is DB-free, so whether an element names a live item
stays the store's question, exactly as for a scalar relation. Two rules
DIVERGE from the scalar case, ruled day 64 as NEW rules rather than inherited
ones: an empty or whitespace-only element is REFUSED (the scalar resolver skips
such a value), and `[]` is accepted as a shape while `required` is enforced
against RESOLVED elements elsewhere. Skipping is how scalar relation ended up
with three stored spellings for "no target"; inside an ORDERED array it would
also silently change the element count.

There was nothing coherent to inherit, which is BUG-3028, filed with line
references: a required scalar relation is satisfied by "", because
validateFieldType only asserts val.(string) and the resolver then skips the
empty value at relation_referents.go:232-235 and 1504-1507. Its fix is
convergence on U4's rule, deliberately ordered AFTER U4.

ROW 18 — internal/collections/dsl.go.

`multi_relation` joins the relation branch rather than falling to the Options
arm. Cardinality changes how many targets a VALUE names, never how many a FIELD
declares. Left out, `tags:multi_relation` parses happily into Options=["people"]
with no target — the exact unwritable field the v0.31 bump existed to stop the
DSL minting, one type later. Error messages now name the type the caller typed.

ROW 17 — internal/store/collections.go, retargetRelationsInSchemaJSON.

BUG-2873 rename propagation. A multi_relation declares its target the same way,
so a rename that skipped it left the field pointing at a slug that names
nothing: no error, the schema still parses, and every later write refused with
`target_missing` for a rename nobody would connect to it. This function had NO
test coverage before now (verified by grep across internal/**/*_test.go), and
it is the highest-consequence row in the table.

EVERY new test was run against the unfixed code and fails there:

  * dsl.go reverted to `relation` only -> 3 of 4 new DSL tests FAIL
  * the `case "multi_relation"` block deleted  -> all 7 refusal legs FAIL
  * the retarget condition reverted           -> MovesBothRelationTypes FAILS,
    naming the orphaned field

The accept-legs deliberately carry no weight on their own: an unknown field
type falls through validateFieldType with no check, so "accepts an array" stays
green against a tree that has never heard of multi_relation. The refusal legs
are the instrument, and the counterfactual above is what says so.

Three counterfactual legs guard the other direction, where a fix goes too far:
multi_select still takes its options list and gets no Collection; scalar
relation still accepts "" (BUG-3028's territory, not U4's to change here) and
still refuses an array; and a non-relation field carrying a `collection` key is
NOT retargeted, which would otherwise pass every assertion in that test.

Verified by reading rather than assumed: U5 already did U4's index half.
relation_links.go handles `case []any:` with ordinals and its schema matcher
already names multi_relation; migration 065/088 carries `ordinal` explicitly so
U4 needs no ALTER. Two rows there stay OPEN in the table with that stated — the
SQL backfill's array handling, and what a scalar->array retype reindexes.

Gates on this tip: make test 0 FAIL / 30 ok packages, make lint 0 issues,
gofmt clean. web/build generated in this worktree by `npx vite build` so the
Go gates embed this tree (CONVE-34 corollary); no web/src change in this
commit.

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

* feat(store): multi_relation resolution by extracting the one relation ladder (TASK-2999 / PLAN-2857 U4, rows 2/14/15)

Rows 2, 14 and 15 of U4's population table. The shape of the change is an
EXTRACTION, not a second implementation: `ResolveRelationReferentsQ`'s
per-value UUID→ref→title ladder becomes `resolveRelationValueQ`, and both the
scalar path and the new `resolveMultiRelationField` call it.

One place knows the ladder, one place decides `VisibilityChecked`, one place
tells `ambiguous` from `wrong_collection` from `not_found`. That function holds
two separate rulings on `VisibilityChecked` — one of them a window deliberately
left to BUG-3012 — and a second copy of it would look correct while diverging
the moment either gained a case.

Rows 14 and 15, the two server doors, need no change of their own: they call
the store function and inherit the array handling. Stated rather than left
implicit, because "no diff" and "not covered" look identical in a table.

THREE DECISIONS, none of them visible from the code alone:

  * ORDER AND LENGTH ARE PRESERVED. An unresolvable element stays VERBATIM in
    the output; only resolved elements are rewritten to their canonical id.
    Dropping one would make the stored length depend on which elements were
    malformed, and the ratified model is an ordered list whose identity IS the
    array — the same thing U5's `ordinal` column already commits to.
  * NOTHING IS WRITTEN BACK UNLESS EVERY ELEMENT RESOLVED. The doors turn any
    issue into a refusal, so a partially canonicalised blob is a write the
    caller never asked for on a request that is about to fail.
  * A FIELD WITH NO TARGET COLLECTION REPORTS ONCE FOR THE FIELD, not once per
    element. Every element fails for the identical reason, and N copies bury
    the one thing the caller can fix.

DUPLICATES get a new reason, `duplicate_referent`, with its `Message()` case
and its `RelationIssueReasons()` entry. Detected AFTER resolution, which is why
it cannot be a shape check: the duplicated elements are usually DIFFERENT
STRINGS naming one item — a UUID and a ref, a ref and an exact title — so
nothing that cannot resolve them can see the duplication. The issue names the
SECOND occurrence, the one the caller can remove. Refused rather than silently
de-duplicated: a caller who sent a target twice either erred or thinks
multiplicity means something, and storing one copy answers neither honestly.

THE PARITY GATE DID ITS JOB. `TestCopyPreflightDropReasonsAreRenderedByTheDialog`
went red on `duplicate_referent` the moment the constant existed, and stayed red
until the reason was in the TS type union, in `COPY_DROP_REASONS`, and in the
`MESSAGES` map. That is BUG-2674's failure — a reason shipping and rendering to
a user as a raw enum — prevented instead of repeated.

`copyDropReasons.ts`'s header says "The test cannot catch a reason added to Go
and not added here". True of THAT module's vitest test, and it reads as though
the direction is uncovered: I believed it and was part-way into building a
second instrument before finding the Go gate. A paragraph now names the gate.

MUTANTS — four, each killed by exactly the test that should kill it:

  * multi_relation routing removed  -> all four resolver tests FAIL
  * duplicate check removed         -> DuplicateAfterResolution only
  * write-back made unconditional   -> OneBadElementRefusesAndWritesNothing only
  * output order reversed           -> CanonicalisesEveryElementAndKeepsOrder only

`EmptyArrayIsNotAnIssue` survives all four. Recorded rather than counted: it is
a CONTROL against a resolver that invents an issue for `[]`, not a detector of
any of these.

The risk this commit carries is the extraction moving SCALAR behaviour, and the
U1/U6 suites are the instrument for it: full `internal/store` 0 failures, full
`internal/server` ok in 436s, 0 failures.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
svelte-check 0 errors · copyDropReasons vitest 3 passed.

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

* refactor(models): IsRelation / IsMultiRelation predicates, and the field-level sites that join them (TASK-2999 / PLAN-2857 U4, rows 8/12/13 + 16's predicate)

Rows 8, 12 and 13 of U4's population table, plus the instrument that makes the
table maintainable.

WHY A PREDICATE AT ALL. The contract for this unit found 37 decision points
keyed on the literal "relation" with NO constant behind any of them, so "which
code decides relation behaviour?" was a question only a string search could
answer — and the string-search answer is wrong the moment a second relation
type exists, which is what this unit adds. `FieldDef.IsRelation()` and
`FieldDef.IsMultiRelation()` make it a symbol search, and `RelationFieldTypes()`
makes the vocabulary enumerable rather than greppable.

The two predicates are deliberately different questions. FIELD-level code asks
IsRelation: does this field have a target collection, should a rename touch it,
is it worth hydrating. VALUE-walking code has to ask IsMultiRelation too,
because the shapes differ — and a single predicate answering both the same way
would make that distinction inexpressible.

WIDENED (field-level, body already shape-agnostic):

  * relationTargetsUnavailable — availability is a property of the FIELD
  * relationTargetSlug         — returns the declared target; cardinality is
                                 not part of that question
  * relationSchemasForWorkspace — which schemas are worth hydrating at all
  * RelationKeysPresent        — presence only; it never reads the value

NOT WIDENED, and this is the more interesting half. `HydrateRelationTargetsQ`
keeps `def.Type != "relation"` with the reason written at the site. Its body
assumes ONE value per field — `perItem[item][key]` holds a single
`relationWant`, and `raw.(string)` skips an array silently — so widening the
predicate there would read as "multi handled" while hydrating nothing. I made
that widening, read the body, and reverted it.

Hydrating a `multi_relation` needs a WIRE-SHAPE decision first: whether
`relation_targets[key]` becomes a list or gains a parallel key. That is a v0.31
contract change, not a predicate edit, so it stays an OPEN row on the table
rather than being smuggled in as a one-liner.

The same trap is why `models.RelationFieldTypes`'s doc comment says explicitly
that the migration and default-resolution sites name their types by hand on
purpose: widening them with IsRelation alone would treat an array as a scalar.

TESTS. The predicates are pinned against every other field type this repo
ships, listed explicitly rather than derived so a new type forces a decision
instead of inheriting one, plus a leg asserting RelationFieldTypes agrees with
IsRelation — two expressions of one fact drift, and nothing else can notice.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
full internal/store ok (157s), internal/items, internal/collections,
internal/models all ok.

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

* feat(mcp): multi_relation hydration, migrate/default sites, and the v0.32 surface bump (TASK-2999 / PLAN-2857 U4, rows 4-7/9-11)

Closes the rest of U4's Go half. Rows 4-7 (MigrateRelationReferentsQ), 9-10 (the
two default-resolution passes) and 11 (read-side hydration), plus the surface
bump the read shape owes.

ROWS 4-7 AND 9-10 — the four migrate sites and the default passes asked
`_, isStr := raw.(string)`, which is the right question for exactly one of the
two types. Asked of a `multi_relation` it calls every legitimate array
malformed — and since those sites DELETE what they judge malformed, the answer
decides whether a valid value survives. Three small helpers now carry the
type-aware questions:

  * relationValueShapeIsValid — string for `relation`, array of strings for
    `multi_relation`
  * relationValueIsCleared    — the "no referent" form, which for an array is
    `[]` or an array of blanks. The origin split skips a cleared value so it
    lands in no bucket; without this an array the caller deliberately emptied
    would be bucketed as a referent, resolve to nothing, and be reported as a
    drop nobody could act on
  * relationIssueValue        — which value a FIELD-level issue names

`relationIssueValue` returns a SCALAR value UNTRIMMED, deliberately. The call
site it replaced read `raw.(string)` and reported it verbatim, and BUG-2870
settled that a caller's value bytes are carried as sent — so trimming would
quietly change what an existing scalar drop reports, a behaviour change
belonging to no row of this table. The multi case trims per element because it
is CHOOSING which element to name, not reporting one it was handed.

The cross-workspace carry now drops a `multi_relation` once for the whole field,
never per element, because the ruling is that the value drops WHOLE — so an
element count is not something the destination learns and an import cannot
change it.

ROW 11 — hydration, on the lead's ruling: `relation_targets[key]` carries EITHER
the scalar object v0.31 emitted OR a JSON ARRAY of those objects in stored order.
ONE map, not a parallel key: a consumer rendering "what does this field point
at?" should not have to ask two questions and merge them, and the field's type
already says which shape to expect.

`models.RelationTargetSet` holds either shape with a custom marshaller. THE
COMPATIBILITY CLAIM IS THAT A SCALAR ENTRY IS BYTE-IDENTICAL, and the test pins
it against a LITERAL rather than against another call into this code — comparing
it to itself would pass against any self-consistent change, including one that
renamed every key. It unmarshals both shapes too, because the CLI decodes item
JSON the server produced, and a marshaller without a matching unmarshaller is how
a shape change reaches a consumer as a decode error instead of as data.

Position is preserved: every stored element gets an entry, including one that
names nothing, which hydrates ID-only — the same honest shape a dangling scalar
gets. Dropping it would silently shorten the list for exactly the legacy rows
that need explaining, and a consumer could no longer line the list up against
`fields`. Visibility redaction is element-wise: a list with one visible and one
invisible target redacts only the second, since redacting the whole list
withholds what the caller may see and redacting none discloses what they may not.

An EMPTY list marshals as `[]`, distinct from the key being absent: absent means
"nothing hydrated this field", `[]` means "this field holds no references".

v0.32, AND A VERSION COLLISION STATED RATHER THAN RESOLVED SILENTLY. PR #1337
also claims 0.32 and was opened first; `gh pr view 1337` says OPEN and main is
still 0.31, and the standing instruction is to take 0.33 only once it HAS merged.
So this takes 0.32 and flags the contest: whichever merges SECOND owes a
renumber, in the constant and in the entry. 0.33 is free if the reviewer would
rather not have the contest at all.

The bump is owed by the read shape, not by the type. Every value rule here is
reachable only through a field whose schema says `multi_relation`, which no
existing schema can contain — but a strictly-typed client parsing
`relation_targets` will fail to decode an array where it expects an object, the
moment its workspace acquires such a field.

DOCS SWEPT, all three consumers: `internal/mcp/instructions.md` (the agent-facing
contract, with the four rules that are NOT guessable from scalar behaviour),
`README.md` (heading, `tool_surface_version`, changelog sentence), and CLAUDE.md —
which the 0.27 unit missed while sweeping the other two, and which says so in its
own text.

Test adaptations: `scalarHydrated` / `scalarTarget` helpers in the store and
server suites unwrap a scalar entry and FAIL on a list rather than returning a
zero value, because a scalar field hydrating as a list is precisely the
regression this type makes possible and a quiet zero would surface it as a
confusing assertion two lines later.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues (it caught an
unformatted file in internal/cli that would otherwise have been pushed) ·
internal/mcp drift gates green on all three documents.

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

* test(store): pin the reverse index's array handling, and renumber to v0.33 (TASK-2999 / PLAN-2857 U4, rows 20-22)

Closes the last three rows of U4's Go half — all three as QUESTIONS rather than
work, and the answer in each case is that U5 already built it right. That is not
a reason to leave them unpinned: reading a `case []any:` arm is not the same as
knowing the index holds the rows, and the whole point of the population table is
that a row's state is recorded rather than assumed.

ROW 20 — a `multi_relation` write indexes one row per element with the array
position in `ordinal`. The control is the part worth having: reversing the stored
array must reverse the ordinals, so an extractor emitting a constant 0 cannot
pass by the fixture happening to be sorted its way.

ROW 21 — the BACKFILL covers arrays, not just the write hook. Worth separating
because the two paths reach the index differently (per-write vs every live item
from a clean table) and because migration 065's own comment says a derived query
"on a multi_relation array silently matches nothing", which is easy to misread as
being about this path. It is not — the backfill reaches the same Go extractor via
`relationKeysFromSchemaJSON`, which has matched `multi_relation` since U5.

The test clears the rows by hand AND clears the once-only
`platform_settings` marker first. Both are preconditions without which it proves
nothing: the write hook has already populated those rows, so a backfill that did
nothing at all would pass, and a backfill that reported `Skipped` would too. It
asserts `!res.Skipped` for the same reason.

ROW 22 — a scalar->array RETYPE reindexes against what is STORED, not against
what the new schema says the shape should be. The ruling called a garbage retype
a U4 defect; it is not one, and this records WHY rather than leaving the row open.
The extractor is VALUE-shaped — it switches on the JSON type it finds — so after
a retype the still-scalar blob indexes as one row at ordinal 0, which honestly
describes what the item holds. Indexing by the DECLARED cardinality instead would
invent rows the blob does not contain.

MUTANT: disabling the extractor's `case []any:` arm kills rows 20 and 21. The
retype test SURVIVES it, correctly — its blob is a scalar, so that arm is not on
its path. Stated rather than counted, because a surviving mutant is worth
explaining either way.

VERSION RENUMBERED TO 0.33 on a lead ruling, in all four documents (version.go,
instructions.md, README.md, CLAUDE.md). 0.32 is now RESERVED for PR #1337, which
claims it and was opened first. The reasoning is in the changelog entry: version
numbers are not scarce, and two branches claiming one means whichever merges
second renumbers under merge pressure — which is exactly when a version bump is
least likely to be checked. A gap in the sequence is cheaper than that, so 0.32
may simply be skipped if #1337 is ever abandoned.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
internal/mcp drift gates green on all three versioned documents.

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

* feat(web): the type union, the schema editor, and the five field-level web sites (TASK-2999 / PLAN-2857 U4, web rows 1-5 + 11-15)

First batch of U4's web half: the sites where the right answer is "this field
points at other items, cardinality is irrelevant". Value-shaped surfaces — the
item-pane picker, the filter bar, board/list grouping — are NOT in this commit,
because each needs a decision about what a multi-valued answer means and a
predicate widening would paper over that.

`$lib/items/relationFieldTypes.ts` mirrors the Go predicates with the same split:
`isRelationType` for field-level questions, `isMultiRelationType` for value-level
ones, `isResolvableRelation` for the type+target pairing that appears at five
sites. Placed in `lib/items/` rather than under `components/` because
non-component code (`shareView`, `copyNeedsValue`) asks the same questions, and a
predicate living under `components/` invites a second copy for everything that
is not one.

SITES, and what each would have done left alone:

  * `types/index.ts` — the FieldDef union gains `multi_relation`. Without it
    every other change here is a type error.
  * `field-editor-types.ts` FIELD_TYPES — the schema editor can now DECLARE one.
    A type the API accepts and the editor cannot offer is a half-shipped
    surface: the only route in would be the CLI's DSL or a hand-written schema,
    which is a gap with a workaround, not a feature.
  * Create/EditCollectionModal (3 sites) — these preserve `def.collection` only
    when the type matched. Left alone, a `multi_relation` saved with NO target,
    and every later write to it was refused with `target_missing` — silently
    from the user's side, since the editor had shown them the target they
    picked.
  * `shareView.isPublicGroupable` — a public share refuses to group by a
    relation because the payload carries values with no index behind them. A
    `multi_relation` is strictly worse, each value being an ARRAY of ids, so a
    shared view grouped by one would render a group per stringified array.
  * `copyNeedsValue` (2 sites) — the copy dialog's collectable/uncollectable
    split, including IDEA-2899's distinction between naming NO target and naming
    an unavailable one, which had to survive the widening.
  * `[collection]/+page.svelte` (2 sites) — the relation-keys set, and the
    DESTRUCTIVE lane-order guard that would otherwise write item ids into the
    schema as `options`.

TESTS are at two levels on purpose. The predicate is pinned against every other
field type the app ships, listed explicitly so a new type forces a decision. The
SITES are then asserted through their own functions, because a test on the
predicate proves nothing about whether a given site calls it (CONVE-19) — each
with the scalar control that would catch a widening gone too far: `multi_select`
still groupable on a share, a non-relation copy row unaffected, FIELD_TYPES
still carrying what it had.

Counterfactual, run: reverting all three widenings turns 4 of the 8 wiring legs
red. The other 4 are controls and correctly survive.

ONE TEST REPOINTED, and it is worth reading. TASK-2998's source guard on
`handleGroupReorder` read for the literal `type === 'relation'`. Replacing that
with the predicate made the guard STRONGER — it now covers both types — and the
test went red for a spelling change. It now reads for the predicate CALL, plus a
leg requiring the guard to test the REORDERED field (`s.fields[idx].type`) rather
than merely calling the predicate about something. A source guard reading for a
spelling fails whenever the code improves; that is the cost of the technique, and
the comment now says so at the site.

Gates: web 2546 passed / 180 files (2545 before, +1 new file) · svelte-check 0
errors · vite build clean.

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

* feat(web): filtering a multi_relation is MEMBERSHIP (TASK-2999 / PLAN-2857 U4, web rows 8 + 12)

U7 deferred this to U4 in as many words, in both the function's doc comment and a
test, rather than guess: a `multi_relation` stores an ARRAY and no amount of
trimming makes `===` match one, so equality would have returned EMPTY for every
filter, forever, and silently.

Defined now as MEMBERSHIP: an item matches when its list CONTAINS the target. It
is the only reading of "filter by this target" that is true of a list.

Membership, not POSITION. An item whose second reference is Red matches a filter
for Red. Order is part of the stored value — it round-trips, and the reverse index
keeps it in `ordinal` — but it is not part of this question.

Elements are trimmed individually, on the scalar case's own reasoning: the write
path has refused padded values since TASK-2878, so there is nothing to migrate,
only old rows to read correctly. A non-string element is ignored rather than
thrown on; a corrupt blob must not take out the whole view, and an element that
is not a reference cannot match anyway.

`FilterBar` now offers BOTH relation types. Its picker needs no change — it still
returns ONE target id; what differs is the question asked of the stored value.

A U7 TEST WAS REWRITTEN, not deleted. `refuses a non-string, including the
multi_relation ARRAY shape` asserted that an array matched nothing, and explained
that the deferral was deliberate. That assertion now states the OPPOSITE of the
shipped behaviour. It keeps its non-string legs under an honest name, and the
comment records that U4 closed the deferral — the deferral was real and the record
of it belongs somewhere, which is the U4 block the array legs moved into. This is
the shape where a prior unit's prose asserts the absence of the thing you just
built; the prose has to move when the absence does.

Counterfactual, run: removing the membership arm turns 3 of the 6 new legs red.
The other three are the scalar control and the negative cases, which correctly
survive — a change that only ever looked at arrays would pass everything else and
break every existing relation filter, which is what that control is for.

NOT in this commit, and it needs a ruling rather than a guess: GROUPING by a
`multi_relation`. The same "membership" answer would put one item in N lanes,
which breaks the invariant U7 worked to preserve — `bucketByColumn` puts every
item in EXACTLY ONE lane, and U7's proving row asserts it by placing six items and
counting. So grouping is either a different rule (an item appears once per
target, which is a rendering change, not a predicate widening) or a refusal like
the one public shares already give. Raised on the trail; BoardView and ListView
are untouched meanwhile.

Gates: web 2551 passed / 180 files · svelte-check 0 errors · vite build clean.

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

* feat(web): grouping by a multi_relation is REFUSED, with the reason said out loud (TASK-2999 / PLAN-2857 U4, web rows 6 + 9 + 10)

Lead ruling, option (2) of the three I put up: refuse, same disposition a public
share already gives relation grouping, and never fall back silently.

WHY REFUSE rather than render. `bucketByColumn` puts every item in EXACTLY ONE
lane, and U7's proving row asserts that by placing six items and counting. An
item with three references belongs to three. The alternatives were rendering the
item once per target — which leaves a drag between lanes meaning something
undefined (add? move? replace?) — or grouping by the FIRST element, which
silently answers a different question than the user asked. The multi-lane
rendering is IDEA-3034 if it is ever wanted.

`relationGroupingRefusal` returns two distinct reasons, and the distinction is
the point: `no_target` is fixable by editing the schema, `multi_valued` is not.
One message for both would make a distinction the type draws invisible to the
person reading it, so a test asserts the two sentences DIFFER.

THE "NEVER SILENTLY" HALF IS MOST OF THE WORK. Before this, a `multi_relation`
group field fell through both views' scalar predicate, was treated as an ordinary
field, and — having no `options` — put every item in UNCATEGORIZED. An
Uncategorized lane with no explanation reads as "none of these items has a
value", which is false and unactionable.

  * ListView already had the mechanism: `relationWithoutTarget` drops it to
    ungrouped. That flag now derives from the refusal, so it covers both reasons
    instead of one, and a notice renders above the list.
  * BoardView cannot be "ungrouped" — a board IS lanes — so its fallback stays
    the single lane it already produced. What changes is that it says why.

The notice is plain and inline rather than a dismissible toast, deliberately: it
describes a standing property of the view's CONFIGURATION, not an event, so it
has to still be there after a reload.

ROW 6 also lands here: the schema editor's per-field "Relates to" picker now
shows for both relation types. FIELD_TYPES already offered `multi_relation` in
the type dropdown, so without this the editor let a user pick the type and then
gave them no way to say what it points at — every field declared through the UI
would have been refused on its first write with `target_missing`.

ONE THING WORTH RECORDING that needed no change: the board/list GROUP-BY PICKER
(`DisplaySettingsEditor`, fed by `selectFieldKeys`) only ever offered `select` and
`multi_select` fields. It never offered relation fields at all, so "the picker
does not offer a multi_relation" was already true by construction, and U7's
relation grouping is reachable only through a saved view or a hand-set
`board_group_by`. Verified rather than assumed — I went looking for the picker to
change it and found it already correct, which is the more useful half of the
ruling to have checked.

Counterfactual, run: removing the `multi_valued` arm turns exactly the leg that
should go red, red. Two control legs — a relation WITH a target still groups,
every ordinary type still groups — guard the opposite failure, which would
silently un-group every relation board U7 shipped.

Gates: web 2556 passed / 180 files · svelte-check 0 errors · vite build clean.

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

* feat(web): the item-pane picker edits a multi_relation (TASK-2999 / PLAN-2857 U4, web row W7)

W7 is the last of the 29 population rows and the one the rest of U4's web half
exists to make usable: a type the schema editor can declare but no item pane can
EDIT is still half-shipped.

A PARAMETERISATION, not a widening. The four resolution steps — resolve the row,
name its state, format its ref, build its href — were $derived values closing
over the single scalar `value`. They are now functions of ONE raw reference, and
the scalar path calls them with its single element, so there is one
implementation rather than a scalar one and a multi copy that drift. The chip
snippet takes the reference the same way. `relationValues` is the list both types
render from: one element for a `relation`, N for a `multi_relation`, none when
empty.

The 40 existing scalar tests are the instrument that this moved nothing; they
were not touched. Negative control run before trusting them: forcing
`relationHrefFor` to null turns one red, so they do read the parameterised path.

What the list behaviour is, all of it ruled in U4 and none re-litigated here:

  * ORDERED. Chips render in stored order, a choice APPENDS to the end, and
    Remove drops by POSITION — the user pointed at a row, not at an id.
  * `[]` is the one spelling of none. The last Remove writes the empty array, not
    the scalar's `''` (which this type's validator refuses); normalising it to an
    absent key is the write door's job and stays there.
  * DUPLICATES are prevented by not offering them: the picker is handed the
    field's own elements to hide. The exclusion set is the RAW stored elements
    because the write door canonicalises every value to its target's UUID, so a
    stored element IS the id — which also covers the element `localIndex` cannot
    resolve, the one whose target the cold picker can still hand back from the
    server.

A write-side duplicate guard was written and removed. It compared against the
same set the picker is given, so nothing the picker could offer could reach it
and no mutant could kill it — an unfalsifiable branch reading as rigour. The
server refuses a duplicate outright (`RelationTargetDuplicate`) rather than
de-duplicating, so the backstop behind the exclusion is a refusal, not a silent
one-copy write. A resolved-id half of the exclusion set went the same way for the
same reason: under canonicalisation it is the same set, and the mutant that
narrowed it away survived.

Affordances are per-element (Remove) plus one for the list (+ Add). The scalar's
Change/Clear pair does not translate — "Change" would have to mean "replace
everything". The picker's open/closed rule IS the scalar's, unchanged: open when
the field is empty or when asked for, closed once a choice lands.

Instrument: 22 new tests in `FieldEditor.multiRelation.svelte.test.ts`. Run
against the PARENT COMMIT's FieldEditor: 21 fail, and the one that passes is the
scalar CONTROL, which is what a control is for. That run is also why one leg
grew an assertion — "an empty field opens the picker with no Add button" passed
unfixed, because an empty value used to fall into the SCALAR branch, which also
opens a picker and also has no Add button. It now asserts the SHAPE the picker
writes, which is what tells the two branches apart.

Mutation matrix, each mutant compiled and each killed by the test that should
kill it:

| mutant | died in |
|---|---|
| append -> replace | APPENDS + inline-create-appends |
| last Remove writes `''` | the `[]` leg only |
| `excludeIds` dropped | all three duplicate legs |
| element order sorted | the order leg + 5 others |
| blank/non-string elements kept | the dropped-elements leg only |
| chip handler ignores its own row | the pane-open leg only |
| non-array value read as one reference | that leg only |
| empty list no longer auto-opens the picker | both picker-visibility legs |
| multi falls through to the scalar branch | 10 legs |

Two mutants SURVIVED a first pass and are the reason two things above are
smaller than they were: the blanks filter (the titles assertion could not see
extra EMPTY elements — the test now counts rendered units) and the write-side
duplicate guard (unreachable, removed). Both are recorded rather than patched
around.

TASK-2999 / PLAN-2857 U4

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

* test(server): the Q2 round-trip proving test for multi_relation (TASK-2999 / PLAN-2857 U4)

The design row asks for one property end to end: write a `multi_relation` value
as a list of REFS through the write door, read it back hydrated, export the
workspace and re-import it, and assert the stored array is identical and every
element still resolves. Negative leg: one unresolvable element refuses the WHOLE
write, naming that element, never a partial store.

AT THE DOOR, not in internal/store, and that is the point of the row. Every
component underneath already has direct tests — the resolver's canonicalisation
and ordering, the hydrator's list shape, the export remap. Those vouch for the
components and say nothing about whether the doors are BOUND to them: a create
handler that never reaches the resolver, or an import that never rewrites an id
inside an array, passes every one of them. A round trip is a claim about the
seams, which is exactly what no component test can make.

The export/import half is the VERIFICATION the design asked for rather than a
rebuild: `remapFieldIDs` substitutes quoted JSON tokens, so it reaches ids inside
arrays without knowing the schema. Nothing was added there. This is the test that
would go red if someone replaced that substitution with a scalar-shaped walk.

Three targets, not two, and supplied in an order that is neither sorted nor
reverse-sorted by id, ref or title — with two elements a reversed array and a
sorted one are the same observation, and "order preserved" would be satisfied by
a resolver that sorted.

Mutants, each compiled, each killed by the test that should kill it:

| mutant | died in |
|---|---|
| export remap skips values containing an array | the round-trip test |
| resolved array reversed before write-back | the round-trip test |
| a multi_relation hydrates as a SCALAR (first element) | the round-trip test |
| an unresolvable element reported as no issue | the refusal test |

Stated rather than counted, because the difference matters: the "no item was
created" assertion in the refusal leg is a REGRESSION GUARD, not evidence. The
refusal happens before any insert today, so no mutant short of reordering the
handler turns it red and I did not build one. It guards a future
create-then-validate ordering, which the 400 alone would not distinguish. The
comment at the site says so.

The third test is a CONTROL: a door that refused EVERY array satisfies both
assertions of the refusal leg, and this file would then read as proof of a
working feature.

Gates: `make lint` 0 issues · gofmt clean · `internal/server` ok (364s) ·
`internal/store` ok (165s) · `internal/items` ok. All on SQLite — `testServer`
calls `storetest.NewSQLite` unconditionally, so nothing built on it is
Postgres-backed regardless of which make target runs it.

TASK-2999 / PLAN-2857 U4

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

* fix(items): every site that dispatches on field type now knows multi_relation (TASK-2999 / PLAN-2857 U4)

Codex round 1 on the U4 tip returned five findings. All five were real, and
enumerating the class they belong to found NINE. They are one defect wearing
nine costumes: a site that dispatches on FIELD TYPE and was never extended for
the new one. I built the WEB population table for exactly this reason and never
built the GO one.

The instrument is two greps, and the second is load-bearing:
`grep -rn '"relation"' --include=*.go internal/ cmd/` finds 13 sites, but two of
the gaps live in switches that enumerate field TYPES and never mention
`relation` at all, so a relation-literal sweep cannot see them. The class is
"dispatches on field type", not "mentions relation".

## The nine

**Visibility (the P1).** A `multi_relation` element was never visibility-checked.
The store resolver takes `canSee`, but only its TITLE rung consulted it; the
UUID and REF rungs were judged by the SERVER's post-resolution pass, which is
gated on the field type. So an editor with no access to the target collection
could put a hidden item's ref inside an array and receive 201 with its canonical
UUID — the existence oracle U1 exists to close, reopened by the array door.

Per the lead's ruling the check moved INTO the shared per-element resolver,
where both types already resolve, rather than being duplicated in the server
pass — gating a security check on a type was the defect, so the fix is one site
and not two. Order matters there and has its own test: the visibility answer
comes BEFORE the wrong-collection answer, because that reason's message names a
live item and would disclose existence first. `VisibilityChecked` is now set on
both arms, which supersedes the note saying the server re-resolves a ref-derived
`wrong_collection`; that also closes BUG-3012's TOCTOU window for values
reaching this rung, since there is no longer a second lookup to race.

**The empty list** (lead ruling, P17). `validate.go` said normalising `[]` to an
absent key was "the write door's job". No door did it. It now happens at the one
entry every write passes through, in the two spellings the two write shapes
need: a FULL write's `[]` becomes an absent key, and the ordinary required
machinery refuses it for free; a PARTIAL write's `[]` becomes the patch path's
nil deletion sentinel, because deleting the key from a patch would turn "empty
this field" into "do nothing" while reporting success.

**Required** was enforced for scalars only, so a required `multi_relation` had
no enforcement anywhere: the key is deleted after validation has passed, and
nothing re-checks it.

**Defaults**, twice. `ValidateFields` assigns a schema default and skips its own
type check, so the late pass is the ONLY thing between an injected default and
the blob — and it skipped arrays, so a schema defaulting to `42` stored 42, and
one defaulting to refs stored refs. The invisible-default drop skipped them
twice over: once on the type gate and again on a `.(string)` that fails on an
array, which is an accident rather than a decision.

**The CLI could not write the type at all.** Both coercion sites JSON-parse only
`json` and `multi_select`, so `--field 'members=["PEOPL-1"]'` arrived as a raw
string and the validator refused a shape the caller never sent.

**The copy preflight** reported `collection_unavailable: false` for a
multi_relation aimed at a deleted collection, so the dialog offered a picker it
could never fill and explained nothing. Codex did not name this one.

**A refused board ate every drag.** `isRelationGroup` is `type === 'relation'`,
so a multi_relation board took the scalar arm: `currentValue` is the stored
ARRAY, `targetColumn` a lane string, never equal, so every drop fired a write
that would replace the list with a scalar. The server refuses it and the reorder
is silently dropped — while the refusal notice rendered correctly the whole
time, which is what hid it.

## Instruments

25 new tests across four packages, each fix with its own. Mutation matrix, one
mutant per fix, every mutant compiled, every one KILLED by the test that should
kill it:

| mutant | died in |
|---|---|
| visibility block removed | all three visibility legs |
| visibility moved AFTER the collection check | the seen-target control only |
| required gate reverted | the required leg only |
| full-write normalisation removed | both full-write legs |
| partial spelling made a delete | both partial legs |
| late-default gate reverted | all four default legs |
| invisible-default multi branch removed | that leg only |
| preflight gate reverted | that leg only |
| coerce case reverted | the coercion leg |
| CLI case reverted | the CLI leg |
| board refusal gate removed | the board guard |

Controls throughout, because most of these legs assert a refusal and a fix that
refused EVERYTHING would satisfy them: a visible element still resolves, a nil
predicate still means "not asked" rather than "deny", a `wrong_collection` a
caller may see keeps its specific message, a key present before validation is
still the caller's value and not a default, an empty `multi_select` is untouched,
and a scalar relation still takes a bare string at both coercion sites.

Prose swept where this falsified it (CONVE-23): validate.go's arm no longer
claims a door does the normalising, `ValidateFieldsDetailed`'s doc now says it
runs one mutation before its traversal, and `cmd_item.go`'s "all other types …
relation — string is correct" no longer names relation flatly.

## Gates

`make test` 0 FAIL / 30 ok packages · `make lint` 0 issues · gofmt clean ·
`svelte-check` 0 errors · web vitest 2580 passed · `vite build` clean, and
`web/build` regenerated so the Go gates embed this tree. `make test-pg` NOT run
for this commit: no SQL changed, only Go-side type dispatch.

TASK-2999 / PLAN-2857 U4

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

* fix(store): three defects in the previous commit's own fixes (TASK-2999 / PLAN-2857 U4, codex round 2)

Round 2 found three, and all three were in round 1's CORRECTIONS rather than in
the original code. That is the shape worth naming: I was reviewing the defect
and trusting the fix.

**A lenient element reader.** My `relationDefaultList` mapped a non-string
element to `""` on the reasoning that one bad element should not make the value
un-listable — and the resolver SKIPS a blank element, so `default: [42]` was
accepted, stored as `[""]`, and reported to nobody. A list is ONE value: an
element nobody can resolve makes the default unusable, exactly as a non-array
does. It is now strict about both non-string and blank elements, which is the
same shape rule `ValidateFields` applies to a caller's array, differing only in
disposition (dropped, not refused).

**An empty default on a required field, deleted in silence.** Validation had
just been satisfied by the PRESENCE of that default; deleting it afterwards with
no issue left a required field absent with nothing to refuse it.
`RequiredRelationIssues` promotes by key, so an issue is the only way a door
hears about it. Reported only when the field is REQUIRED: on an optional one an
empty default is a sensible way to say "starts with nothing", and a warning
there would be a false alarm on a schema doing nothing wrong.

**The order bug, and the sharpest of the three.** Every door captures relation
provenance with `RelationKeysPresent` BEFORE `ValidateFields` runs, so that
function saw the `[]` the new normalisation was about to remove. Counting it as
caller input marked the key "not a default"; validation then injected the schema
default into the hole normalisation had made; and the late-default pass skipped
the key on the strength of that mark. A caller sending `members: []` against
`default: 42` stored 42 — a value nobody sent, through the seam between two
correct-looking passes. `RelationKeysPresent` now reads an empty list as absent,
by the same rule, which is the one-site fix: the key is then a DEFAULT to every
later pass, which is exactly what it became.

Instruments: 5 new tests. Mutants, each killed by the test that should kill it —
lenient elements, blanks tolerated, the required report removed, the empty-list
rule reverted, and the empty-list rule LEAKING onto the scalar type (whose empty
spelling has its own meaning, BUG-3028).

One mutant SURVIVED a first pass and is recorded rather than quietly re-rolled:
"map a non-string element to empty string" is not a faithful negation of the
non-string rule, because its own output then trips the BLANK guard two lines
later and dies for the wrong reason. The faithful mutant stringifies instead —
`42` becomes `"42"`, which is neither blank nor resolvable — and that one is
killed by the non-string leg alone.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean.

TASK-2999 / PLAN-2857 U4

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

* fix(store): three more seams the empty-list rule opened (TASK-2999 / PLAN-2857 U4, codex round 3)

Two of round 3's three were again in the previous round's fixes, and they are
the same class as round 1's: a SECOND path that does not share the rule. The
lead's ruling put the empty-list normalisation at the one validation entry every
write passes through; these are the passes that run BESIDE it and had their own
idea of what a value is.

**A cleared list survived a move.** `MigrateRelationReferents` SKIPPED a cleared
relation value so it would be neither refused nor reported — correct for a
scalar `""`, which validation accepts and which is a legal stored spelling. For
a LIST it is not enough: the migrate doors validate AFTER resolving, so an array
of blanks stayed in the map and failed the shape check. Reachable as a
destination-schema DEFAULT, since `ValidateFields` assigns one without
type-checking it — so the same schema default returned 400 on an ordinary move
and was quietly dropped on a create. The list is now DELETED there; the scalar
is untouched, and a control pins that asymmetry.

**The bulk door resolved a default as though it were typed.** `applyBulkOp`
snapshots relation provenance and then validates, which injects defaults; its
relation pass collected "the keys `changes` names, read out of the
post-validation map". Those are different sets the moment normalisation removes
an empty list before the injection: the map then held a DEFAULT under a name the
caller had used. An unresolvable default must be dropped and reported, never
refused — one bad default in a schema would otherwise fail every bulk update
into that collection. It now gates on the snapshot that was already being taken
three lines above.

**Hydration depended on the rest of the batch.** A stored `multi_relation`
holding no resolvable reference got no `relation_targets` entry when read alone,
and an empty list when read beside an item that carried one — identical stored
bytes, two answers, decided by the batch. An absent key says "nothing hydrated
this field" and `[]` says "this field points at nothing"; those are different
statements and a caller cannot be handed either at random. The early return is
gone rather than special-cased: falling through costs nothing (the chunk loop
does not run on an empty id list) and duplicates nothing, where a second render
path is exactly how the two answers would drift apart again.

Instruments: 5 new tests. Mutants, each killed by the test that should kill it —
the cleared list skipped rather than deleted, the deletion LEAKING onto the
scalar spelling, the hydration early return restored, the bulk gate removed, and
the bulk gate widened to skip every relation key. The last two are a pair: one
proves the gate fires, the other proves it still lets a genuinely supplied value
be refused.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean · web
vitest 2580 passed.

TASK-2999 / PLAN-2857 U4

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

* refactor(store): one shape reader composes the next, and the three questions are named (TASK-2999 / PLAN-2857 U4)

CONVE-35's move rather than another review round: rounds 1, 2 and 3 each found a
new member of a class, which measures the enumeration and not the code. The
class rounds 2 and 3 belong to is THREE READERS OF ONE SHAPE — `relationArray-
Elements`, `relationDefaultList` and `relationListShape`, plus a fourth across
the package boundary in `normalizeEmptyRelationLists`. Both rounds found the
wrong one being asked in the wrong place.

`relationDefaultList` was a second parser with the same two cases as the general
reader. It now COMPOSES it and adds exactly one rule — no blank elements —
because that one rule is the entire difference and a line saying so cannot drift
from a copy that repeats the parsing.

`relationListShape` stays separate and that is deliberate, not an oversight: it
must admit an array with non-string elements, since a caller's malformed array is
still a value they supplied and only an EMPTY one is the absent spelling. The
three questions are now written down together where the first reader is defined,
so a fifth reader has to declare its question before it gets written.

Mutants on the composition, both killed: the blank rule dropped, and the shape
result ignored.

Gates: `make lint` 0 issues · `make test` 0 FAIL.

TASK-2999 / PLAN-2857 U4

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

* fix(store): a caller's malformed override is refused, a schema's is dropped (TASK-2999 / PLAN-2857 U4, codex round 4)

Both of round 4's findings were in round 3's fix, which is the third round
running where the review found my CORRECTION rather than the original code.

Round 3 added a deletion for a cleared `multi_relation` on the migrate path, and
put it BEFORE the origin split — so it treated a caller's malformed override
exactly like a schema's malformed default. Those get opposite dispositions on
purpose: a value someone ASSERTED is refused, a value nobody asserted is dropped,
and "silently clear the field" is neither.

**`[" "]` is not a cleared field.** It is a list of ONE reference that happens to
be blank, a shape every write door refuses. Counting it as cleared made a move
with `field_overrides: {"members":[" "]}` return 200 with the field absent: the
caller's own malformed value vanished instead of being refused. "Cleared" now
means the EMPTY list and nothing else, which is the same spelling the validators
normalise — the analogy with the scalar `" "` was the mistake.

**A mixed default `[validID, " "]` still 400'd a copy.** The destination-default
bucket used the LENIENT shape reader ("is this an array of strings"), so it
passed, resolution skipped the blank, and the shape check afterwards refused it —
on an optional field, for a default the caller never wrote. That bucket now uses
a strict reader, and the strict/lenient split has a named helper explaining why
the two origins are held to different standards: a refusal is visible to the
person writing the request, a drop is not.

Instruments: 6 new tests. Mutants: blanks counted as cleared again (killed by the
supplied-override leg), the default bucket back to the lenient check (killed by
both default legs), and the empty list no longer clearing (killed by its own leg).

ONE MUTANT SURVIVED and it found a coverage hole older than this unit. Applying
the strict list reader to the SCALAR type as well — which would drop EVERY scalar
relation default on a copy or move — survived the entire `internal/store` suite,
first under a `-run` filter narrower than its population and then unfiltered too.
Nothing covered the scalar arm of that bucket at all. Two tests now do, and the
mutant dies in the first of them.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean.

TASK-2999 / PLAN-2857 U4

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

* fix(store,web): the third origin and the second view (TASK-2999 / PLAN-2857 U4, codex round 5)

Both findings were incomplete ENUMERATIONS of mine — the same failure round 1
was about, twice more, on smaller sets.

**Three origins, and I had settled two.** Rounds 1-4 gave SUPPLIED the refuse
disposition and DESTINATION DEFAULT the drop disposition, and left CARRIED on
the lenient shape check. A carried value is asserted by nobody either —
TASK-2878's rule is that it is dropped, never refused — so a carried
`[validID, " "]` passing the lenient check meant resolution skipped the blank
and the shape check afterwards REFUSED an ordinary same-workspace move on an
optional field, which is the exact outcome that rule exists to prevent. The
helper is renamed from `relationDefaultShapeIsUsable` to
`relationDroppableShapeIsUsable`, because naming it for ONE of its two origins
is what let the other keep the wrong check for a round.

**Two views implement grouping, and I fixed one.** ListView had both of the
defects BoardView had: the drop handler compared the stored ARRAY against a lane
string and fired `onStatusChange(item, "")` on every reorder, and the status chip
was withheld only for `isRelationGroup`, so a refused multi_relation board left
it live to write a scalar into a list.

THE BOARD'S CHIP GATE WAS REMOVED AFTER BEING WRITTEN. A mutant reverting it
survived, and the reason is structural rather than a weak test: a refused
grouping takes the `field?.options ?? []` branch of `columns`, a multi_relation
declares no options, so `ItemCard.statusCyclable` (which needs
`statusOptions.length > 1`) is already false. The gate was unreachable by
construction — the same disposition W7's write-side duplicate guard got. ListView
genuinely needs its gate, because its `statusOptions` is a CALLER PROP rather
than its own lanes, and its mutant dies. Both facts are written at their sites.

TableView needs neither: no grouping, and its chip is gated on
`field.key === 'status' && field.options`, which a multi_relation cannot satisfy.

Instruments: 8 new tests. Mutants: carried bucket back to lenient (killed),
carried bucket dropping every list (killed by the control), ListView drag gate
reverted (killed), ListView chip gate reverted — BOTH halves, since either half
alone suppresses the chip and a one-half mutant dies for the wrong reason
(killed), and the chip withheld ALWAYS (killed by both views' ordinary-grouping
controls).

A VACUOUS TEST was caught by its own control, which is why the control was
written first: my ListView chip leg omitted `statusOptions`, which is a prop of
that view and not something it derives, so the chip never rendered and the
assertion held against any build. The control failed, which is the only reason I
looked.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues (run three times — one
earlier run exited 3 inside a piped command that swallowed its message; three
clean unpiped runs since, and no explanation offered for the one, since I cannot
reconstruct it) · gofmt clean · web vitest 2585 passed · `svelte-check` 0 errors
· `vite build` clean.

TASK-2999 / PLAN-2857 U4

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

* fix(web,server): the guard I deleted was reachable, and an empty override is not "your value" (TASK-2999 / PLAN-2857 U4, codex round 6)

**Round 5's reasoning was wrong and this restores what it removed.** I added a
refusal gate to BoardView's status chip, saw the mutant reverting it SURVIVE, and
concluded the gate was unreachable: a refused grouping takes the
`field?.options ?? []` branch of `columns`, and a `multi_relation` declares no
options, so the chip cannot cycle anyway.

Every step of that is true of the schemas anyone would WRITE and none of it is
enforced. Nothing strips `options` when a field's type changes, so a
`multi_select` retyped to `multi_relation` in the schema editor keeps them,
`columns` is non-empty, and the chip cycles — writing a scalar into a list. A
claim about schemas is not a claim about code; the preflight's own
`collection`-on-a-`select` comment says exactly that about a neighbouring field,
and I had read it earlier the same day.

So the surviving mutant was evidence about my FIXTURE, not about the guard. The
test now builds the retained-options schema and the mutant dies. The lesson is
narrower than "mutants lie": a survivor is a question, and the first question is
whether the fixture can reach the case — which is the same
check-the-faithfulness step that caught two survivors earlier in this unit and
which I skipped on this one because the conclusion was convenient.

**An empty override is not the caller's value.** The preflight already treats an
explicit `null` override as "leave this unset": it drops the key from `final` AND
from `origin`, so the destination default validation injects is reported as a
default. An empty `multi_relation` list is the same statement in the other
spelling — normalisation removes it, the default lands in its place — and it took
the other branch, so the preflight labelled that default `from: "override"`. On
the one surface whose entire job is to predict what a copy will do, attributing a
value to a caller who never sent it is the whole failure.

`items.IsEmptyRelationList` is now exported and both the normalisation and the
preflight ask it, rather than the preflight re-deriving a rule that lives in
another package.

Instruments: 4 new tests. Mutants: the preflight branch reverted (killed), the
preflight dropping EVERY override (killed by the control), and the board chip
gate reverted in BOTH halves (killed — where round 5's identical mutant survived,
which is the measurement that matters here).

Gates, each run unpiped after an earlier piped one swallowed a lint failure:
`make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30 ok · gofmt 0
files · `svelte-check` exit 0 / 0 errors · web vitest 2585 passed · `vite build`
exit 0.

TASK-2999 / PLAN-2857 U4

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

* fix(items,server,web): every spelling of none, every refusal-gated affordance, and a lost update in my own picker (TASK-2999 / PLAN-2857 U4, codex round 7)

Round 7 was asked to ENUMERATE rather than confirm, and returned five. Three
were siblings of round 6's fix — which is what an enumeration round is for, and
what six confirmation rounds had not produced.

**One sweep replaces round 6's point fix.** An empty `multi_relation` is about to
be removed by normalisation, and the destination default lands in its place, so
whatever `origin` says about the removed value gets said about the default
instead. Round 6 put that check in the preflight's OVERRIDE loop. That loop sees
the caller's RAW bytes, so `"[]"` is still a string there and only becomes an
empty list once `CoerceFields` runs; and it sees only overrides, so a CARRIED
empty list kept `from: "migrated"` by the same mechanism. One sweep over the
coerced map covers the override, the carried value and both string spellings,
because by that line they are all the same value.

**NULL is the third spelling.** On a full write the traversal treats a
present-but-nil key as absent for required-ness and defaults but does not REMOVE
it, so `{"members": null}` was stored — a third representation of "no targets"
beside the absent key and the empty list. Normalised on the full path only: on a
patch, nil is the deletion sentinel and the spelling being normalised TO.

**The lane create controls are refusal-gated too.** A `multi_relation` that
retained `options` produces NAMED lanes, so neither `isUncategorized` nor
`isRelationGroup` withheld the lane "+" or the lane menu's add: creating there
sent the lane string as the relation value. Third member of the affordance class
after the drag and the status chip — everything that writes the GROUP VALUE has
to ask the refusal.

**A lost update in W7's own picker.** Every list edit is a WHOLE-LIST write, and
they all derived from `value`, which only catches up after the server round trip:
remove A then B from [A,B,C] sent [B,C] and then [A,C], and the parent's 409
refetch-and-retry can persist the second, putting A back. The component now holds
the list it last SENT as the base for the next edit, released as soon as the prop
agrees by CONTENT.

THE FIRST VERSION OF THAT FENCE ONLY WORKED IN THE TEST. I cleared the hold from
an `$effect` reading `wsSlug` and `field.key` directly, which re-runs on every
prop assignment whether or not anything changed — so an ordinary parent
re-render mid-write released the hold and the stale prop came back as the base.
The two-click test never saw it, because nothing re-renders between two clicks.
A surviving mutant ("never release the hold") is what pointed at it, and the leg
that now pins it re-renders with IDENTICAL props mid-write. The hold is stamped
with the identity it was sent for and compared, which asks the question the fence
is for instead of one that merely correlates with it.

Instruments: 12 new tests. 12 mutants, all killed, including the four that
survived earlier passes and were each chased to a cause: a fixture that could not
reach the case, a fixture whose two outcomes coincided, a guard masked by its
neighbour, and the effect-timing one above.

Gates, unpiped: `make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30
ok · gofmt 0 files · `svelte-check` exit 0 / 0 errors · web vitest 2592 passed ·
`vite build` exit 0.

TASK-2999 / PLAN-2857 U4

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

* fix(store,server): two more spellings, and a shape answer that depended on the route (TASK-2999 / PLAN-2857 U4, codex round 8)

Round 8 returned five. Two are members of classes already closed in this unit and
are fixed here; the other three open NEW classes and are deliberately NOT fixed —
they are the first rows of the resume table on TASK-2999, per the lead's
disposition for this round.

**The coerced NIL spelling.** `CoerceFields` JSON-parses a `multi_relation`
string, so the override `"null"` reaches the preflight's post-coercion sweep as a
nil — a spelling `IsEmptyRelationList` deliberately does not claim, since nil is
the PATCH path's deletion sentinel and that rule belongs to the validators. At
the sweep the question is only "will this value survive to be described", and a
nil will not, so the destination default that replaces it was again labelled
`from: "override"`.

Scoped to the list type, and a CONTROL pins that: a `json` field's `"null"`
coerces to nil too, and there nil is a legitimate stored value rather than a
spelling of absence. A mutant dropping every coerced nil survived until that
control existed.

**A shape answer that depended on which door asked.** `CoerceFields` runs BEFORE
`ValidateFields` injects a default, so an injected default is uncoerced at the
create door and coerced at the migrate door, where the copy coerces after
merging. A default written as the JSON TEXT `["<id>"]` was therefore stored by
one path and dropped as `invalid_shape` by the other — the same schema, the same
value, two answers chosen by the route. Droppable-shape judgements now run the
value through the real coercion first; the control is that text parsing to a
NUMBER is still not a list.

Mutants, all killed: the raw-value judgement restored, the shape check made
unconditional, the nil arm removed from the sweep, and the nil arm widened past
multi_relation.

Gates, unpiped: `make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30
ok · gofmt 0 files · `svelte-check` exit 0 / 0 errors · web vitest 2592 passed ·
`vite build` exit 0.

TASK-2999 / PLAN-2857 U4

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

* fix(web): two whole-list writes to one field can no longer lose the newer one

Codex round 8, R8-1 and R8-2, fixed as one unit because they are two halves
of one question: which write to this field is current.

R8-1, the lost update. Every multi_relation edit is a WHOLE-LIST write, so
two of them in flight are not deltas the server can merge — each asserts the
complete value. Remove A then B from [A,B,C]: both PATCHes carry the same
expected_updated_at, the second commits first, and the first then 409s,
refetches and REPLAYS [B,C]. A is back on the row, with no error raised
anywhere. Round 7 fixed where the second edit got its BASE; this is the
ordering, which is a different mechanism.

R8-2, the hold outliving its write. The hold released only on prop
AGREEMENT, and a value the server REFUSES never comes back — so a rejected
clear of a required field kept showing the rejected list forever and ignored
every later server value.

The shared model is `$lib/items/fieldWriteOrder`: two counters per target, a
ticket at dispatch and a high-water mark of what WROTE, because each
single-counter version answers a different question than the one being asked
(harness note ticket-plus-high-water-request-ordering). The dispatch counter
gates the RETRY — the one place counting dispatches is right, since a retry
is a new send of an OLD body and nothing here discards a response. The
applied mark gates every write of `item`, giving newest-wins among responses
that resolve out of order. The field editor takes the same tickets so an
older write settling cannot release a newer one's hold, and the hold now ends
when the write is OVER: prop agreement, or the consumer's own settlement
where it gives one (`onchange` may now return a promise; returning nothing is
unchanged and still supported, which is what the consumers with no server
behind them do).

The pane's OCC retry loop is EXTRACTED rather than guarded in place:
ItemDetail is ~7,900 lines and not renderable, so the defect site would
otherwise only ever be grepped for spellings. It is now driven directly,
conflict by conflict, through the interleaving that loses the update.

Instruments — 23 tests, three files:
- fieldWriteOrder.test.ts drives the real loop, with a NEGATIVE CONTROL per
  single-counter alternative: no supersession question at all, and counting
  applies instead of dispatches, each of which puts A back.
- FieldEditor.multiRelation: a refused write stops holding and shows server
  truth, the next edit bases on the prop again, an older write settling does
  not release a newer one's hold, and a CONTROL that a consumer answering
  nothing still holds (round 7 preserved).
- itemDetailOrdersFieldWrites.test.ts guards the binding (CONVE-19), with its
  own limits written down.

Mutation matrix, 7 mutants. Killed: settlement release disabled (3 legs),
release without the ticket check (1), the catch's supersession check, one
claim gate, the `order` wiring, a per-item instead of per-field target.
SURVIVED and each chased rather than re-rolled: moving `take` to just before
the first send is behaviour-preserving and not a defect, so that leg was
REMOVED rather than kept as rigour nothing can falsify; dropping the flush
before release survives the whole file and is KEPT and labelled, because
unlike W7's duplicate guard it is not unreachable by construction — the
signature permits a consumer that resolves before it assigns.

The proximity bound in the binding guard has its receipt: assignments sit at
35/35/60/156/207 characters from their gate, deleting one gate pushes two to
359 and 410, so the bound is 300. It was 400 first, which caught only the
farther of the two and by ten characters.

TASK-2999 / PLAN-2857 U4

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

* fix(web): a board that refuses to group stops grouping (R8-3)

Codex round 8, R8-3. Round 7 gated every affordance that WRITES the group
value and left the LANES reading it, so the board went on bucketing under a
notice reading "Showing everything ungrouped."

The route is the same false premise round 6 corrected one derivation away:
nothing strips `options` when a field's type changes, so a `multi_select`
retyped to `multi_relation` keeps them — and a lane key is the STRINGIFIED
array, so `['id-red']` and `['id-red','id-blue']` match options `id-red` and
`id-red,id-blue` and the two cards land in two scalar-labelled lanes.

`columns` is now empty when grouping is refused, which sends every item to
UNCATEGORIZED and leaves exactly the one lane the notice describes. One lane
rather than keeping the notice and dropping it: it is the smaller change, it
makes the sentence true, and it matches the day-64 ruling that this unit
refuses grouping rather than reinventing it. IDEA-3034 holds the multi-lane
alternative. Taken as the lead's stated DEFAULT — no ruling on the product
question had reached this window.

WHAT THIS DID TO THE ROUND 5/6/7 GUARDS, measured rather than assumed. With
no named lanes, three of the four affordance gates are subsumed by the lane
they now cannot reach: deleting `groupingRefusal` from `cardStatusOptions`,
from the lane "+", or from the lane menu's add leaves all 63 tests in that
directory green. The drag one is NOT subsumed — its mutant still dies. The
gates are KEPT, because each affordance asking the refusal directly is what
made round 7's class enumerable; what is not kept is the pretence, so both
legs that stopped discriminating say so in place, naming the leg that does
fail when this regresses.

A forcing term that showed the lane even when empty was written and then
REMOVED: `items.length === 0` renders EmptyState instead of the lanes, so no
mutant could reach it. W7's disposition, not a comment explaining a guard
nothing can falsify.

Instruments: one leg with the finding's own fixture — two cards, retained
options matching their stringified values — asserting ONE lane holding BOTH,
with the refusal and both cards as preconditions so "one lane" is neither
"not refused" nor "a dropped card". Reverting `columns` turns it and the
round-7 lane-control leg red.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2616 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

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

* fix(web): the round-8 enumeration's in-class findings, including two this unit introduced

ONE enumeration-prompted codex round on the frozen tip, per the lead's
disposition: not "is this right?" but "enumerate every remaining way a write
can leave the client and the server disagreeing". It returned 36 items across
four categories and said no category was empty — which is CONVE-24 working:
an enumeration is the only round that can return "none left" as a finding,
and this one did not.

Fixed here: the members of classes this unit had already closed, and the two
defects the unit's OWN round-8 fix introduced.

MINE, both in the open-children path, both from gating too widely:

1. The DECLINED branch took a claim. Declining writes nothing — it re-props
   the value the client already had — but claiming advanced the applied mark,
   so a write that had genuinely COMMITTED and was still in flight lost its
   claim on return. Server changed, pane showing the old value, nothing left
   to correct it. A branch that asserts no new server truth must not take the
   mark for one.
2. A successful FORCED write was discarded on supersession. If the newer
   write then failed without writing, the server held the confirmed value and
   the pane held the old one. `claim` asks the question that matters — has
   anything newer actually WRITTEN — and supersession does not.

IN-CLASS, four more:

3. The confirm callback SENDS, and asked about supersession only after it
   returned. The dialog can sit open indefinitely; checking afterwards
   suppresses the display of a write that already reached the server.
4. `submitOrderedOCC` asked only about supersession after its refetch, so
   switching items during that await still produced another PATCH at the item
   the pane had left — invisible, because the caller discards the response,
   and landing on the row all the same. Both questions now, both times.
5. The hold's release: prop AGREEMENT is a coincidence test, not an answer.
   Remove C from [A,B,C] and add it straight back, and the second write holds
   a list equal to the prop nobody has changed — the effect read that as
   confirmation and released mid-write, the first write's answer became the
   base, and C was lost by the mechanism built to stop that. Settlement now
   owns the release where it exists; agreement keeps it where it does not.
   A synchronously-throwing consumer no longer strands the hold, and the
   agreement path checks the identity stamp it had been ignoring.
6. The lane menu's bulk "Move all to" is the FIFTH affordance that writes the
   group value; round 7 enumerated four. It offers `statusField.options` as
   destinations, so a `status` retyped to `multi_relation` with its options
   retained offers lanes that do not exist and sends a scalar into a list.

A SOURCE FILE WAS BINARY, and that is worth its own paragraph because it made
this unit's own verification lie. `fieldWriteTarget` was written with a real
NUL BYTE where the source should carry the two-character escape for one —
identical at runtime, but `file` reports "data" and grep then returns NOTHING
AND EXIT 1 for every pattern, which is indistinguishable from "not present".
A restore-and-verify grep over that file came back empty for a symbol four
lines below it. It shipped in a9f58ccb; git still shows that path as `Bin` in
this diff.

INSTRUMENTS — 9 new legs, every one shown red against the unfixed code:
the agreement release (its first fixture gave the same answer either way and
was rebuilt so it discriminates), the retarget stamp, the synchronous throw,
the post-refetch item switch, the bulk move (its first version passed against
a menu that never opened — it now proves the menu opened, with an entry that
is not gated on the refusal), and four source-guard legs pinning the two
dispositions above, the claim-as-GATE spelling codex showed was passable with
the `if` removed, and the callback's guard-before-send order. Two error
assertions now compare IDENTITY rather than class, which they were shown to
need.

NOT FIXED, filed as the lead ruled — new classes go into the package as
follow-up items, not another round: BUG-3036 (whole-item responses unordered
against each other), BUG-3037 (OCC cannot separate two writes inside one
second — the client guard never even runs), BUG-3038 (a whole-list retry
replays instead of re-deriving), BUG-3039 (a debounced edit cancelled by the
previous keystroke's response), BUG-3040 (a refused write leaves the rejected
value in scalar inputs), BUG-3041 (a conventional key typed multi_relation
crashes card rendering), BUG-3042, BUG-3043, BUG-3044, BUG-3045 (`npm test`
can exit 0 having run no .svelte.test.ts at all), BUG-3046, BUG-3047,
BUG-3049 (full-fields writes without OCC undo a field patch ON THE SERVER),
TASK-3048.

One existing test now describes a KNOWN LOSS and says so: the
ordinary-conflict leg asserts that a third party's addition disappears, which
is what the code does and not what it should do (BUG-3038). Invert it when
that lands.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2625 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

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

* test(web): stop the deliberate uncaught error failing the whole vitest run

CI's Web check went red at ab1bc203 with every test passing:

    Test Files  183 passed (183)
    Tests       2625 passed (2625)
    Errors      1 error
    ##[error]Process completed with exit code 1.

The error is the one my own leg provokes on purpose. "a consumer that throws
SYNCHRONOUSLY does not strand the hold" has `onchange` throw; the component
RETHROWS, which is the behaviour I want — the error belongs to the consumer,
not to the picker — and a throw escaping a DOM event handler surfaces as an
uncaught error on the window, in jsdom exactly as in a browser. vitest counts
that at the RUN level and exits non-zero however green the tests are.

The leg now installs a window 'error' listener scoped to that one message and
removes it in a `finally`, so a genuine uncaught error anywhere else still
fails the run. The product is unchanged: the rethrow stays, and the mutant
that removes the hold's release still turns this leg red.

HOW IT REACHED CI, which is the part worth keeping. I ran the web suite as
`npx vitest run | grep -E "Tests "` and read "2625 passed". The pipe hands the
exit status to grep — CONVE-30, gates are never piped — and "Errors 1 error"
is not on the line the pattern matched. So the evidence package's "web vitest
2625 passed" was true about the tests and false about the gate, which is the
only thing the gate was being asked.

Every Web CI step re-run individually since, each exit code read from a
redirect rather than a pipe: `npm run build` 0 · `npm run check` 0 (0 errors,
6 pre-existing warnings) · `npm run test` 0 · `npm run check:tiptap-pins` 0 ·
`npm run audit:ci` 0. Go side: `make lint` 0 / 0 issues · `make test` 0, 0
FAIL / 30 ok · gofmt 0 files.

Second false green in this window from reading a summary line instead of an
exit status; the first was the source file that had gone binary, where grep
answered "nothing, exit 1" for every pattern.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 16:01:12 -04:00
xarmian 162eccb42b feat(relations): exact-title resolution and hydrated reads for relation fields (PLAN-2857 U6 / TASK-2996) (#1326)
PLAN-2857 unit U6, closes TASK-2996.

U1 made a relation value have to name a live item. U6 adds the third
spelling a human actually knows — the item's TITLE — and the read side
that lets a client render any of them.

  - a value may be a UUID, an issue ref, or an EXACT TITLE, in that
    order, so an item literally titled COLO-3 is unreachable by title
    while the ref COLO-3 resolves;
  - title matching is scoped to the collection the field declares: a
    title unique only workspace-wide REFUSES naming the collection
    searched, and two or more matches inside it are `ambiguous` rather
    than `not_found`, which would state the opposite of what happened;
  - reads carry `relation_targets` — field key to {id, ref, title} —
    BESIDE `fields` rather than inside it, so read-modify-write keeps
    working;
  - the --fields DSL takes a relation's target collection. It used to put
    the third part into Options and build a field with NO target, which
    every subsequent write then refused with target_missing; a bare
    `owner:relation` is refused at parse time now;
  - ToolSurfaceVersion 0.31 — a behaviour bump, because that DSL refusal
    is a call that used to be accepted.

THE RULED PROPERTY: among live exact-title matches in the declared
collection, only VISIBLE ones count — zero is not_found, exactly one
RESOLVES, two or more is ambiguous — and a hidden match never changes the
answer a caller gets.

It took three attempts to put that in the right place. It was a
SERVER-SIDE second pass first, which left the cross-workspace copy —
running the resolver inside its own transaction — deciding without it, so
preflight and copy disagreed about one input. It lives in the resolver
now, taking the store.RelationVisibilityFunc this codebase already had
for the shape, and which resolveRelationsForWrite's own comment had named
as the missing piece. nil means no requester and every live match counts.

CARRIED values are exempt by provenance: nobody asserted them, and
judging them by the mover's visibility is what MigrateRelationReferentsQ's
comment calls out as making the stored bytes depend on who moved the
item. Supplied values and injected defaults are judged.

EIGHT CODEX ROUNDS; seven found something real. The ones worth keeping:
a CAP on the candidate list was itself an oracle (one visible plus cap
hidden read differently from one visible plus cap+1); the paging cursor
used the NULLABLE item_number and excluded legacy rows the deciding count
included; hydration filtered on COLLECTION visibility, which is
documented as deliberately nav-lenient, so an item-level grant on one row
exposed every sibling's ref and title; and hydration scanned that
nullable item_number into an int, so one pre-006 row erased
relation_targets from the ENTIRE response.

Round 7's "P1" about changed exported signatures was REJECTED, not fixed:
internal/store cannot be imported outside this module, so the compiler
had already proved every caller updated — which is why a required
parameter is right rather than a variadic a caller can silently omit.

TESTING: every fix carries a mutation. Three verdicts were thrown away
rather than counted — two mutants did not COMPILE (a build failure
produces FAIL lines identical to a real kill) and one mutation never
applied. In one sub-task three successive tests passed for reasons that
had not been established: a field that never arrived, a fixture whose
encoder filtered the key before the wire, and a schema reshape that
silently failed. One mutation is recorded UNCOVERED rather than dropped:
the canSee==nil count-before-nil-check is a race with no seam to force,
so that fix is argued from the code path and not demonstrated.

Filed, not fixed: BUG-3012 (ref-path TOCTOU), BUG-3013 (U2 chip wording),
BUG-3014 (bundle-import title renders as dangling), BUG-3015 (the
relationsCarry posture drops silently, medium).

Gates on 29bb6e2c: go test ./... 0, vet 0, gofmt clean, lint 0 issues,
web vitest green. All 8 CI checks green.

Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
2026-09-10 21:06:40 -04:00
xarmian 367aae8e18 fix: one prefix grammar, and the ref parser widens to it (BUG-2943) (#1286)
* fix(collections): a DERIVED prefix is A-Z only (BUG-2943)

DerivePrefix took the first BYTE of each word, so a collection named
'TEMP Rook A 2870' got the prefix 'TRA2'. parseItemRef resolves a
PREFIX-NUMBER ref only when every prefix character is A-Z and otherwise falls
through to a slug lookup, so every item in that collection printed an issue ID
the CLI then refused: 'pad item show TRA2-2942' answered 'item not found'
while the slug resolved fine. Two functions, each locally reasonable,
disagreeing about what a prefix may contain — and the generator was the
permissive one, so the failure surfaced at read time on an identifier the
product itself minted and printed.

The first-BYTE bug had a second half: a word starting with a multi-byte rune
contributed a UTF-8 lead byte, so a collection named in most non-Latin
scripts produced a prefix that is not even valid text.

Non-letters are SKIPPED rather than mapped — there is no honest A-Z
substitute for '2' or 'Omega', and inventing one puts a character in the ID
that is in nobody's collection name. A name with no ASCII letters yields the
empty string, which store.CreateCollection already turns into its ITEM
fallback.

SCOPE, stated because the first draft of this message overstated it (codex
round 1 [P2]): DERIVED prefixes are safe now; the INVARIANT IS NOT ENFORCED.
Three other doors store a prefix verbatim and unvalidated — CreateCollection
with an explicit input.Prefix, UpdateCollection, and workspace import — so
the same unresolvable-ID defect is still reachable through the API, the
--prefix flag and a restore. Named on the trail with their call sites, held
for a ruling rather than swept into this commit, because the import door
wants a different answer from the other two: refusing a restore is not
obviously right.

The parity test lives in internal/store, where parseItemRef is: it asserts
the generator against the RESOLVER rather than against a restatement of the
resolver's rule, which is how these two drifted apart in the first place.

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

* fix(store): one prefix grammar at all four doors, and the parser widens to it (BUG-2943)

The ruled shape, which dissolves the import dilemma rather than choosing a
side of it: collections.IsValidPrefix is the single definition — an uppercase
letter followed by uppercase letters or digits — and parseItemRef now asks it
instead of carrying its own stricter A-Z rule.

Because the PARSER widened, a workspace already carrying a prefix like AB1
resolves every item by its printed ID the moment this ships. No migration, no
rewrite of an identifier a user's other records may reference.

The four doors:

- derive: unchanged from the previous commit, still letters-only, still
  within the grammar;
- create with an explicit prefix: REFUSED if outside the grammar, with a
  message naming the rule. The caller typed it, so a refusal is actionable;
- update: same, and it matters more here — update is the door someone reaches
  for to FIX a bad prefix, so it must not accept another one;
- import: the most permissive door that can still be honest. Anything the
  parser resolves is accepted (which now includes digits); only a prefix NO
  surface could resolve is refused, naming the collection and saying the
  export can be edited. Carrying that verbatim would restore a workspace
  whose items print IDs the CLI answers 'not found' to, which is this
  item's defect rather than a compatibility owed.

An ABSENT prefix on import is not an unresolvable one. Old exports and every
fixture in the suite carry "", and the first version of this check refused
them — turning a fix for unresolvable IDs into one that cannot restore an old
bundle at all (caught by three server tests). It now takes the same
derive-then-ITEM fallback CreateCollection applies, which also upgrades it: an
empty prefix is itself unresolvable, since the ref would begin with a dash.

A prefix accepted only because the parser widened is logged at WARN, so an
operator can see an id-space that would have been rejected before rather than
inferring it from a resolve failure that no longer happens.

Tests for each door follow in the next commit.

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

* test(store): one test per prefix door, plus the parser round trip (BUG-2943)

Each door asserted separately: 'they all call the same helper' is a claim
about the code, not about behaviour, and the bug was two definitions
disagreeing.

- create with an explicit prefix: AB1 accepted and resolves; ab1, 1AB, 'A B',
  A-B, A!, a non-Latin letter and a bare digit refused, with the rule named;
- update: AB1 accepted, a bad replacement refused AND the stored prefix
  unchanged after the refusal — update is the door someone uses to FIX a bad
  prefix, so it must not swap one unresolvable id-space for the next;
- import: a digit-bearing prefix restores unrewritten and resolves; one no
  surface can resolve is refused naming the collection and the export; an
  ABSENT prefix takes the create-path fallback and comes back resolvable;
- the parser: every prefix the doors accept round-trips, and 1AB / 9 / 'A B' /
  A! / a trailing dash / a bare prefix stay refused.

One correction: my first version of the parser test asserted that 'ab1-42' is
refused. It is not, and the code is right — parseItemRef upper-cases before
splitting, which is what makes "pad item show task-5" work. Case-insensitivity
is now PINNED rather than mis-asserted, because a later reader working from
the grammar comment alone would otherwise 'fix' it.

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

(This message was rewritten once: the sentence above lost its example because
the original was written with backticks inside a double-quoted shell string,
which the shell EXECUTED and replaced with the command's empty output. The
span was blank in the commit as first written.)

* fix: the widened grammar reaches its consumers too (BUG-2943)

Codex round 2. Widening parseItemRef without widening what CONSUMES a ref
would have left the same two-definitions bug this unit is about, introduced
by its own fix:

- cmd/pad/cmd_github.go matched [A-Z]+-\d+, so 'pad github link' on a branch
  carrying a digit-bearing ref silently found nothing;
- web localSearch's palette Enter fast-path could not recognise one either.

Both now match collections.IsValidPrefix.

Tests strengthened, both on codex's reading:

- the import fallback pinned the VALUE, not just resolvability — asserting
  'non-empty and parseable' passes an implementation that stamps ITEM on
  every absent prefix, giving every collection in a restored workspace the
  same id-space. Two legs now: an ordinary name derives TASK, a letterless
  name falls through to ITEM, which is what makes it 'derive, THEN ITEM';
- the WARN the ruling asked for had no test, so it was a line nobody would
  notice was gone. Now asserted, with a control that an ordinary prefix does
  NOT warn — a log everything trips is a log an operator learns to skip.

Three comments still described the parser's A-Z rule as current, including
one in the file that changed it.

STILL OPEN, on the trail for a ruling: web paneTarget.ts keeps the narrow
grammar on PURPOSE — its comment argues a digit-permitting shape would
misclassify a slug like 'roadmap2-5' as a ref — and that argument cited the
server rule this unit just widened. Whether the guard follows or stays is a
question about the widening's blast radius, not a line to change quietly.

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

* fix(web,store): the pane guard follows the server, and the precedence is written down (BUG-2943)

Both lead-ruled after codex round 2 surfaced them.

paneTarget's REF_SHAPE kept a LETTERS-ONLY grammar deliberately, citing the
server's A-Z loop as its warrant. The server dropped that rule, so the guard
was holding a grammar nothing else holds — which does not avoid a wrong
answer, it produces a different one. It now matches IsValidPrefix.

The cost is real and is stated in the test rather than buried: an HREF whose
last segment is ref-shaped under the wider grammar is compared by NUMBER with
the prefix discarded, so a genuine slug like 'roadmap2-5' now counts as the
same pane target as TASK-5. The existing test pinned the opposite and is
REPLACED, naming what changed and why. The prefix is dropped because a moved
item keeps a stale one (the server's own number-only fallback) and
PaneGuardItem carries no prefix to compare; tightening that means widening
that type and its callers, which is a separate change and is on the trail.

The SLUG-channel leg is kept as its own test: provenance, not grammar, is
what protects it — a target naming an item by slug is judged only as a slug.

ResolveItem's ref-before-slug precedence is now documented on the function
and pinned in both directions: 'ab1-42' resolves as a SLUG when no AB1-42
exists, and a live ref wins when it does (case-insensitively). The widening
made more strings ref-shaped, so 'is my slug still findable' needed an answer
that does not depend on reading the resolver.

Web unit tests run here via a node_modules SYMLINK to the main checkout,
which CLAUDE.md permits; npm ci was not run and must not be.

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

* fix(web): the self-pane guard was the fourth copy of the ref grammar (BUG-2943)

Codex round 3 [P1]. The route-level refNumber() still matched [A-Za-z]+, so a
master whose ref is R2-1 parsed as null, its item_number fell back to 0, and
the same-item guard stopped recognising ?item=R2-1 as the master — mounting a
second provider for the item already on screen.

That is the FOURTH consumer found carrying its own copy of this grammar
(github branch extraction, the search palette, the pane target guard, and now
this). Four independent copies is the argument for the shared definition
rather than for four careful edits, and it is why the widening had to be
swept rather than applied where it was noticed.

Also from round 3: comments saying these client regexes 'match
collections.IsValidPrefix' were imprecise — the validator accepts uppercase
only, while the client patterns accept either case on purpose, because a user
types a ref however they like and the server upper-cases before splitting.
They mirror the ref GRAMMAR, and now say so.

Web gates run here through a node_modules symlink to the main checkout
(permitted; npm ci is not): vitest 2195 passed, svelte-check 0 errors.

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

* fix(collections,e2e): the fifth and sixth copies, and the docs that taught the old rule (BUG-2943)

Codex round 4, after I claimed the sweep was complete twice.

- Two SEEDED PLAYBOOK BODIES carry their own ref grammar and instruct agents
  with it: playbook_library_plan.go and templates_sdd_spec.go both said a ref
  'matches ^[A-Z]+-\d+$'. An agent following those literally would refuse to
  treat AB1-42 as a ref — a grammar copy that lives in PROSE and is executed
  by a reader rather than a regexp engine, which is why two sweeps of the
  code missed it.
- Three e2e comments taught the defect as a rule: one of them carries the
  empirical confirmation ('GET /items/BS1-10 404'd while the slug worked'),
  which is precisely this bug. They now say the by-ref 404 is fixed and that
  the explicit prefix those suites pass buys DETERMINISM rather than dodging
  it.

Counting honestly: six live copies of one grammar, found in four rounds of
review, two of which I opened by asserting there were no more. The shared
definition is the fix; every one of these was a place that had quietly made
its own.

Gates: go test ./... 0, make lint 0 issues, vitest 2195 passed,
svelte-check 0 errors (web run through a node_modules symlink to the main
checkout — permitted; npm ci is not).

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 17:32:36 -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
xarmian 7332a7f9f8 feat(collections): add spec workspace template — spec-driven development (IDEA-2527) (#1081)
* refactor(collections): extract tasksCollection/ideasCollection helpers

Pulls Tasks and Ideas out of Defaults() into standalone functions,
mirroring the existing docsCollection extraction. Seeded schema is
byte-identical; this just lets a template compose Tasks/Ideas without
also getting Plans, which the upcoming spec template (IDEA-2527) needs.

* docs(collections): generalize decompose playbook to plan-or-spec

The shared `decompose` library playbook was worded plan-only
(target description, pre-flight checks, body-analysis step). Broadens
the wording to also recognize a spec's `## Implementation plan` /
`## Acceptance criteria` sections as decomposition sources, ahead of
the spec template (IDEA-2527) reusing this playbook. Title is
unchanged (looked up by exact string elsewhere); wording is additive
so startup/scrum/product, which have no Specs collection, are
unaffected.

* feat(collections): add spec workspace template (IDEA-2527)

New "spec" template positions Pad as a spec-driven-development
platform: Specs (SPEC, draft→in-review→approved→implemented→
superseded, version+area fields, content_template skeleton) replaces
Plans as the parenting artifact — idea/bug → spec → tasks → PRs.
Deliberately no Plans collection; implementation-plan material lives
in the spec body's optional "## Implementation plan" section instead
(cheaper than maintaining two overlapping artifacts).

Ships:
  - SpecConventionTriggers/SpecPlaybookTriggers, extending the
    software trigger vocab with on-spec-draft/approve/change
  - Four seed conventions gating implementation, PR review, and
    spec-edit discipline on the spec lifecycle
  - Three full-prose playbooks: `/pad spec` (draft-first interview
    with IDEA/BUG graduation), `/pad verify` (walk acceptance
    criteria against the diff/behavior), `/pad extract-specs`
    (brownfield extraction with a subsystem-map human checkpoint and
    provenance-marked observed-behavior specs)
  - Reuses `decompose` (generalized in the previous commit) and
    `ship` (unchanged) as the remaining two seed playbooks

Registered in templates.go; adds the three new playbook bodies to
TestInvocationFramingStaysNLCanonical's scanned surfaces. Dedicated
tests in templates_sdd_test.go cover registration, Specs schema
shape, extended (not replaced) trigger vocab, the four conventions,
and the five playbooks.

Positioning/marketing page descoped from this PR — recon found no
marketing-page infrastructure in this repo to extend (the root route
redirects straight to /console); tracked separately.

* fix(collections): gate spec graduation, fix strict-parser arg contract

Codex round 1, findings 1-2:

- `/pad spec` accepted ANY ref matching the generic ref pattern
  (e.g. TASK-7) and would enter graduation mode, terminalizing an
  unrelated item's status. Dispatch now resolves the ref, checks its
  collection is actually Ideas-like or Bugs-like before graduating,
  and otherwise falls back to using it as recon context for a
  new-topic draft with no status flip.
- `target` was documented/declared optional, but the strict CLI/MCP
  parser only fills REQUIRED args positionally (internal/server/
  handlers_playbooks.go) — `pad playbook run spec "<topic>"` silently
  failed to bind it. Made `target` required, matching the `plan`
  playbook's `topic` precedent. `extract-specs`'s `target` stays
  optional by design (bare invocation is a supported flow); its
  Arguments docs now show the strict-path key=value form instead of
  implying positional works.

Adds a regression test (TestSpecPlaybookTargetArgumentRequirement)
pinning target's required-ness for both playbooks.

* docs: sync decompose's structured arg metadata + SKILL.md to plan-or-spec

Codex round 1, finding 3: the decompose playbook body was generalized
to plan-or-spec in an earlier commit, but its structured
`arguments` JSON metadata (the queryable contract) and
skills/pad/SKILL.md's Decomposition entry were left plan-only —
exactly the drift the body's own comment says these two surfaces
must not have.

* fix(collections): treat unedited Implementation-plan placeholder as absent

Codex round 1, finding 4: the spec content_template always ships a
populated "## Implementation plan" section, which meant decompose's
"no implementation plan -> fall back to acceptance criteria" path
never triggered for skeleton-created specs — it would treat the
placeholder's angle-bracket instruction text as a real task
candidate. The skeleton placeholder now tells the author to delete
the section if unused, and decompose's source-analysis step treats
an unedited placeholder the same as a missing section.

* docs(collections): drop phantom TASK-2528 reference

Codex round 1, finding 5: TASK-2528 doesn't exist in the docapp
workspace. The tasksCollection/ideasCollection extraction comments
now cite IDEA-2527 only.

* docs: add spec-target routing example to SKILL.md Planning section

Codex round 2, finding 1: the Planning section's decomposition routing
example only showed a plan target ("break plan 2 into tasks" → PLAN-2).
Adds a spec-target example alongside it, consistent with the
Decomposition entry further down (already generalized to plan-or-spec)
and the decompose playbook body/arguments.

* fix(collections): generalize ship playbook target to plan-or-spec

Codex round 3, P1: the spec template seeds ShipPlaybook() unchanged,
but its target contract documented only PLAN-ref | TASK-ref. Decompose's
step-7 report tells the user to run `/pad ship <target-ref>` on the
source ref, which in a spec workspace is SPEC-N — so the seeded
idea->spec->tasks->ship handoff broke at the last step.

Generalizes target's wording across all three surfaces (the Arguments
line, the argument-parsing PLAN-ref bullet, and the arguments-JSON
description) to PLAN-ref | SPEC-ref | TASK-ref, mirroring the same
additive pattern already used for decompose: a spec is a parenting
artifact with identical expansion mechanics to a plan (same
--parent-child wiring), so the change is inert for startup/scrum/product,
which have no Specs collection. No test pins the exact argument text,
so existing structural tests (TestStartupTemplateShipsShipPlaybook,
TestPlaybookLibrary_ShipBodyShared) pass unchanged.

* fix(collections): generalize ship's remaining plan-only mentions

Codex round 3 follow-up: two plan-only mentions left over from the
target-contract generalization.

- Commit-message template's "Parent: PLAN-XXX." -> "Parent: PLAN-XXX /
  SPEC-XXX.", plain aliasing matching everything else already
  generalized.
- Step 11's parent-closing guidance keeps the existing plan sentence
  as-is and adds the spec case as a judgment-trigger pointer rather
  than a parallel unconditional flip: a spec's terminal status is
  gated by verification (that's what the `verify` playbook is for),
  so ship tells the agent to run `/pad verify SPEC-XXX` instead of
  flipping the spec's status directly — it moves the spec to
  `implemented` only once the acceptance criteria actually hold.

* fix(collections): ship's PR-body guidance cites specs and their criteria

Codex round 4, P1: ship's PR-context generation still said "parent
plan" and templated the PR body under <PLAN-REF> only — so
`/pad ship SPEC-N` produced a PR that never cited the governing spec,
directly violating the spec template's own seeded on-pr-create
convention ("PRs cite the spec and which criteria they satisfy").

Generalizes the PR-body template to <PARENT-REF> (PLAN-ref or
SPEC-ref) and adds explicit guidance: when the parent is a spec, the
PR must also list which acceptance criteria it satisfies (e.g.
"Implements TASK-12 under SPEC-4, satisfies AC-1, AC-2") — this is
what makes /pad verify fast later, since the reviewer walks the cited
criteria instead of re-deriving intent. Also generalized step 1's
"check the parent plan's content" to plan-or-spec, since a spec
parent's acceptance criteria are exactly what step 8 needs to cite.

* fix(collections): verify gates the implemented flip on spec approval

Codex round 4, P1: /pad verify only excluded draft specs from the
flip-to-implemented, so it could promote an in-review or superseded
spec straight to implemented on the strength of passing acceptance
criteria alone — bypassing the workspace's own approval lifecycle.

Verification still runs and reports AC results regardless of status
(useful information either way), but the Resolve step's all-pass path
now branches on status: approved -> offer the flip (unchanged);
already implemented -> report the re-verify confirms it still holds,
nothing to flip; in-review -> report the pass but tell the user
approval isn't done yet, point at finishing review; superseded ->
report the pass but point at whatever spec replaced this one, since
that's the one that should be verified and implemented going forward.

* fix(collections): decompose treats placeholder ACs as absent too

Codex round 4, P2: the AC-fallback path treated unedited skeleton
placeholders (AC-1: <a statement...>) as real task candidates, so a
fresh untouched spec could decompose into bogus tasks. Extends the
same placeholder-as-absent rule already applied to the
Implementation-plan section: an AC-N line still holding the unedited
angle-bracket instruction text isn't a real criterion and doesn't get
a task proposed for it. If every AC-N is still a placeholder, there's
nothing to decompose from either source — decompose stops and tells
the user the spec has no real acceptance criteria yet.

* fix(collections): unify AC placeholder idiom, cover bare-ellipsis form

Codex round 5, P2: the seeded skeleton's AC-2 used a bare-ellipsis
placeholder ("AC-2: ...") while AC-1 used angle brackets and the
decompose placeholder rule only named the angle-bracket form — a
literal-minded agent could propose a bogus task for an untouched AC-2.

Two one-line fixes: the skeleton's AC-2 now uses the same
angle-bracket idiom as AC-1 ("AC-2: <the next verifiable criterion>"),
so the seeded skeleton has one placeholder style; decompose's
placeholder rule now also names bare ellipsis ("AC-N: ...") as a
placeholder form, as belt-and-suspenders for user-typed shorthand
beyond just the seeded skeleton.

No test pinned the AC-2 text, so no test changes needed.

* fix(collections): spec's circulate-for-review branch actually sets in-review

Codex round 6, P2: the "circulate for review" branch said to leave the
spec at in-review and stop, but the create command always uses
--status draft and no update followed it on that path — so the spec
silently stayed draft forever. Since round 4's fix gates verify's
implemented-flip on approval status, an item stuck at draft (never
even reaching in-review) is a stuck workflow, not just a label
mismatch.

Adds the explicit `pad item update <new-spec-ref> --status in-review
--comment ...` step to the circulate branch, parallel to the
approved-outright branch's existing update command, keeping the
audit-comment habit consistent with the rest of the body.

* fix(collections): add resume mode so circulate-for-review specs converge

Codex round 7, P1: the circulate branch stopped the playbook before
the graduation step, and nothing ever completed it — a later
`pad item update SPEC-N --status approved` was a bare status flip with
no agent step attached, so the source IDEA/BUG never got terminalized.
Worse, the advertised rerun path was broken: `/pad spec SPEC-N`
dispatched as non-graduation (a spec isn't Ideas/Bugs-like per the
round-1 gate) and would have created a SECOND spec instead of
resuming the first.

Three coordinated edits:

- New dispatch mode: a target resolving to the specs collection
  itself enters resume mode, never creates anything. Branches on the
  spec's status — in-review is the normal resume case (confirm
  approval, complete any pending graduation, offer decompose);
  draft/approved/implemented/superseded get the sensible remainder
  (offer the original choice again, report already-resolved state, or
  point at the successor spec).
- The circulate branch now records a "Graduation pending approval:
  <source-ref>" comment on the new spec when in graduation mode, so
  resume mode has something mechanical to find rather than relying on
  re-deriving intent from the Context section.
- The circulate branch's stop text now tells the user how the loop
  closes: rerun `/pad spec SPEC-N` when review is done.

Updated the `target` argument docs (body + arguments JSON) to name the
third accepted form. templates_sdd_test.go doesn't assert argument
description text, so no test changes needed.

* fix(collections): restructure graduation as one idempotent reconcile rule

Codex round 8, two P1s: the approved/implemented branch of Resume
never checked the pending-graduation marker (so a plain manual
`--status approved` never graduated the source), and the circulate
branch's marker write sat after "stop here" — a skippable step three
rounds of findings kept landing on. Scattering graduation state and
handling across branches was the actual bug; restructuring so the
class can't recur, not another branch-local patch.

- The graduation-link comment now gets written unconditionally at
  spec-creation time (step 5, graduation mode), before any
  approve/circulate branching — no ordering problem, no skippable
  step, exists on every path including a crash before either branch
  completes.
- One Reconcile rule, stated once in the Resume section: on every
  resume that reaches it (draft/superseded stop earlier and skip it;
  in-review-not-yet-approved also skips it), if the spec is now
  approved or implemented and its comments name a graduation source
  that's still open, complete the graduation — idempotent, so it's
  safe to run on every resume regardless of how approval happened
  (through this playbook, a crash-recovery rerun, or a bare manual
  status flip outside the playbook entirely).
- Step 6 (approve-outright path) is now just an invocation of
  Reconcile rather than parallel instructions — graduation mechanics
  described exactly once, referenced from both call sites.

Dispatch and Arguments text re-read coherent after the restructure;
no changes needed there beyond what round 7 already added.

* fix(collections): graduation idempotent by source, custom collection, promise wording

Codex round 9, five findings — the last substantive round before
remaining crash-window-shaped gaps become documented limitations
rather than more branches:

1. (High) Rerunning /pad spec IDEA-x mid-flight created a second
   spec — graduation wasn't idempotent by SOURCE, only by spec ref.
   Pre-flight step 2 (graduation mode) now checks, before drafting,
   whether the source's trail already shows a "Graduating into
   <spec-ref>" comment, or whether a search of the specs collection
   finds a spec whose Context names this source. Either match means
   this run is really a resume — switch to resume mode on the found
   spec instead of creating.
2. (High) A crash between create and the marker comment left an
   unlinked spec. Step 5 now writes markers on BOTH sides (source and
   new spec) immediately after create, back-to-back, shrinking the
   window. Documents the actual recovery mechanism instead of
   pretending atomicity: the skeleton's Context section always names
   the source, so finding 1's recon check catches even a marker-less
   spec on the next run.
3. (Medium) The resume-mode dispatch check tested only the literal
   "specs" collection, breaking for a custom `collection` argument.
   Now tests against the resolved `collection` argument (checking
   `pad collection list` if renamed), consistent with the existing
   ideas/bugs check.
4. (Medium) The opening promise ("nothing gets created until the
   user approves") contradicted the circulate path, which creates an
   in-review item. Reworded to match actual behavior: nothing is
   created until the user chooses approve-or-circulate; the draft is
   always presented in chat first.
5. (Note) The superseded branch skipped Reconcile unconditionally,
   stranding any pending graduation. Now checks the marker before
   stopping: if the source is still open, the pending graduation
   transfers to the successor spec (if findable) via the same marker
   comment, so the successor's own future Reconcile picks it up.

* fix(collections): make graduation's recovery claims actually true

Codex round 10, four sentence-scale edits closing the gap between
what the prose claimed and what it actually did:

1. (High) Pre-flight step 2's "recon check is the actual recovery"
   claim was false for a marker-less spec after a crash: the search
   found the spec, but Reconcile still needed marker comments that
   were never written, so it would no-op and strand the source. Now
   the discovery path repairs — writes both sides' markers right then
   if missing — before proceeding to resume, so the recovery claim
   holds by construction instead of by accident.
2. (High) Transferring a pending graduation to an already-approved-or-
   implemented successor (superseded branch) wrote the marker but
   never re-triggered anything to act on it. Now runs Reconcile on the
   successor immediately in that case (idempotent, source ref already
   in hand) instead of waiting on a rerun that might never come.
3. (Medium) "Skip straight to Resume below" bypassed Resume's own
   pre-flight (loading the spec's comments), which Reconcile depends
   on. Now explicit: run Resume's pre-flight first.
4. (Medium/borderline) "Never creates a second spec" overstated the
   guarantee for a SPEC-ref passed with a mismatched --collection.
   Softened to "never creates a duplicate within the resolved specs
   collection" everywhere the claim appears (Arguments prose, Dispatch,
   pre-flight, and the arguments JSON description) — consistent
   scoped truth in every location rather than a strong claim in one
   place and a weaker one elsewhere.

* fix(collections): enforce the Context-citation premise, walk the chain

Codex round 11, two findings:

1. (High) The whole crash-recovery mechanism (pre-flight step 2's
   search, step 5's recovery claim) depends on a graduated spec's
   Context section naming its source — but nothing enforced that; the
   skeleton's own Context hint says "(if any)" since most specs
   aren't graduated, and step 3 never mandated the citation for the
   ones that are. Added the explicit rule to step 3: in graduation
   mode, Context MUST cite the source ref by ID (e.g. "Grew from
   IDEA-12"), stated with the reason — it's the search key crash
   recovery depends on. Reinforced in step 5's and pre-flight step
   2's claim text: attributed the guarantee to the playbook's own
   mandate, not to "the skeleton," which doesn't itself enforce
   anything.
2. (Medium) Transferring a pending graduation to a successor that is
   itself superseded parked the marker somewhere no rerun would ever
   look — chains of supersession weren't walked. The superseded
   branch now walks to the LIVE HEAD of the chain (bounded, ~10 hops)
   before transferring or reconciling; a loop or dead-end mid-chain is
   treated the same as no successor found, rather than guessed at or
   walked forever.

* fix(collections): don't silently pick a branch in the supersession chain

Codex round 12, the last: the chain-walk from round 11 silently picked
one live head when supersession branches (more than one spec claims to
supersede the same spec). One clause, grouped with the existing
loop/dead-end stop rule: if more than one spec claims to supersede the
same spec at any point in the walk, stop and ask the user which is
canonical before transferring — don't pick silently.
2026-08-12 14:20:02 -04:00
xarmian faf9b3734a feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274)

Board becomes the baseline default view for new collections; existing
collections keep their stored default_view (no migration).

- Frontend fallback (settingsDefaults, collection-page defaultMode,
  shareView coerce, initial viewMode) -> board
- Create/Edit collection modals default -> board
- Backend template seeds (defaults.go, templates*.go) list -> board for
  ideas/plans/docs/hiring/interviewing collections (tasks was already board)
- CLI `pad collection create` and MCP mapCollectionCreate defaults -> board
- Curated create-modal presets with deliberate list curation (Meeting
  Notes, Decisions, OKRs) intentionally left as list
- Pin the three list-keyboard-nav pane E2E tests to ?view=list

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

* fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1)

Codex review found the public share route (s/[token]) derives its owner
default view via a separate `?? 'list'` fallback that bypassed the
coerceSettings change, so settings-less/legacy collections rendered List
on public share pages. Align it (and the pre-init selectedBase) to board.
Also align ItemDetail's inline CollectionSettings fallback (default_view
is unused there, but keep it consistent with settingsDefaults).

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

* fix(collections): group Contacts board by relationship, not status (Codex round 2)

Contacts has no `status` field, so defaulting it to Board grouped by the
default `status` rendered every card in a single Uncategorized lane. Set
BoardGroupBy=relationship so the board shows real lanes. All other
board-defaulted seed collections have a status field or an explicit
board_group_by (verified: Companies/Conventions/Playbooks/Docs have status).

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

* fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3)

buildCollectionUrlParams treated List as the implicit URL view and omitted
it. With Board now a possible collection default (IDEA-2274), a List
selection on a board-default collection produced a URL that, when copied or
opened without the sender's localStorage, resolved back to Board. Always
serialize the view mode; add a covering unit test. Verified the pane E2E
suite (URL-equality assertions) stays green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 13:33:01 -04:00
xarmian db1472123e test(collections): drift-guard for NL-canonical playbook invocation (TASK-1862) (#752)
Backstops PLAN-1858 / IDEA-1846 against regression: a denylist test that
fails when agent-facing copy reframes `/pad <slug>` as THE invocation form.

internal/collections/invocation_framing_test.go scans SKILL.md, the MCP
server instructions, and the four rendered seeded playbook bodies (plan /
decompose / onboard / ship) for canonical-framing phrasings ("maps directly
to /pad", "invoke via /pad", "say /pad …", "directly invokable as /pad",
"canonical /pad", "dispatches /pad", "Playbooks available: /pad"). It also:
- asserts the NL-canonical principle stays stated in SKILL.md (so the scan
  can't pass vacuously after a deletion), and
- self-checks that each banned regex matches its own representative example
  (so a typo can't silently neuter a pattern).

Deliberately a low-false-positive backstop, not a generator: labeled
shortcuts ("/pad ship in Claude Code") and slug-routing examples are fine.
The companion convention CONVE-1863 (created as workspace data) covers the
surfaces this test doesn't mechanically scan (MCP catalog/prompt Go consts,
the web UI).

Parent: PLAN-1858 (final task).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 15:36:47 -04:00
xarmian a2827ecb33 feat(playbooks): NL-canonical invocation in seeded bodies + CLAUDE.md (TASK-1861) (#751)
De-hardcode `/pad <slug>` as the primary invocation form across user/agent-
facing copy:

- Seeded playbook bodies (plan / decompose / onboard): the cross-references
  and recap lines now lead with intent / the playbook name, with `/pad`
  labeled as the Claude-Code shortcut where shown.
- CLAUDE.md: the invocation_slug description, the library section (now also
  reflecting the `▶ <slug>` chip from TASK-1860), the onboarding sections,
  and the needs_onboarding nudge quote (updated to the shipped NL-canonical
  active offer from PLAN-1847).

Left Go `//` dev comments as shorthand — not user-facing, and out of scope
for the drift-guard (TASK-1862 scopes to body strings + markdown).

Parent: PLAN-1858.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 15:31:25 -04:00
xarmian fa8064e2b9 feat(onboard): capture workspace intent at creation, warm the onboard run (TASK-1855) (#746)
Intent-as-seed for the onboarding bridge. The workspace `description` column
already existed end-to-end but nothing captured or surfaced it:

- Web: CreateWorkspaceModal gains an optional "What are you tracking?"
  textarea (create-only), sent as `description` on create.
- Bootstrap: AgentBootstrapWorkspace now carries `description` (omitempty,
  additive) so the onboard playbook can read the user's stated intent.
- Onboard playbook: pre-flight reads workspace.description; B1 reflects it
  back ("You mentioned this is for X — let's build around that") instead of
  opening cold with "what is this project?", falling back when absent.

Net effect: a user who types one line at creation gets an onboard interview
that starts warm instead of from zero.

Parent: PLAN-1847 (Phase 3).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 12:25:46 -04:00
xarmian 619465a24b feat(onboard): suggest an independent AI code reviewer (model != implementer) (TASK-1645) (#654)
Encode the independent-reviewer principle (a reviewer model different from the
implementer catches more than self-review) as opt-in onboarding guidance — no
tool-specific operational lore (that stays ours).

- New generic library convention "Independent AI code review" (quality,
  on-pr-create, nice-to-have): states the principle; names review tools as
  examples (a review CLI, claude review, a GitHub bot) without operational depth.
- /pad onboard build (B3) + audit (A3) steps: the agent notes its own model
  (the implementer), probes for / asks about a review tool, and when a
  different-model reviewer is available, proposes activating the convention,
  naming the detected tool (e.g. codex) as the concrete suggestion. Skips when
  the only reviewer would be the same model. Never blocks.

Parent: PLAN-1628.
2026-05-29 18:38:42 -04:00
xarmian 24707fc2ad chore(templates): make seeded ship playbook review loop tool-neutral (TASK-1644) (#653)
The seeded ship playbook ships into every new workspace, so it must not carry
our local Codex operational lore. Strip the Codex name + the < /dev/null /
--full-auto / stdin-wedge details PR #646 added → a tool-neutral review loop
("use whatever synchronous review tool you have"), with a pointer that /pad
onboard can wire up an independent reviewer. Our Codex specifics stay only in
this workspace's PLAYB-1405 + the personal ship-tasks skill.
2026-05-29 18:35:10 -04:00
xarmian d2bcbd3b9b chore(deps,docs): bump x/image v0.41.0 + sync seed ship-playbook codex guidance (#646)
* chore(deps,docs): bump x/image to v0.41.0 (GO-2026-5031/5032) + sync seed ship-playbook codex guidance

- golang.org/x/image v0.39.0 → v0.41.0: clears GO-2026-5031/5032 (reachable
  via attachment image decode). go mod tidy. govulncheck clean.
- templates_startup_ship.go (seeded ship playbook for new workspaces): drop
  the deprecated `codex exec --full-auto`, add `< /dev/null` + a codex-specific
  note that open stdin causes the zero-output "wedge" (not prompt length), and
  the stdin-first rule-out in the wedge/safety notes. Matches the ship-tasks
  skill + PLAYB-1405.

* fix(docs): show review prompt as positional arg in seed ship-playbook example per Codex review (round 1)

codex exec reads the prompt from stdin when none is passed as an argument, so
the `-o <file> < /dev/null` example without a prompt would review nothing. Show
the prompt positional and note the gotcha.
2026-05-29 16:37:55 -04:00
xarmian 6433cc51ea feat(mcp): pad_library catalog tool + ToolSurfaceVersion 0.5 (TASK-1563) (#615)
* feat(mcp): pad_library catalog tool + ToolSurfaceVersion 0.5 (TASK-1563)

MCP catalog wiring for PLAN-1560 (`pad_library` MCP tool + matching CLI
surface). Closes IDEA-1514 — pure-MCP agents (notably the /pad onboard
playbook from PLAN-1496) can now browse and activate library entries
without shelling out.

## New tool

`pad_library` joins the v0.5 catalog as the ninth resource × action tool.
Three actions, all passThrough to the `pad library` CLI:

- `list`     — Browse conventions + playbooks. Defaults to summary mode
               for playbooks (compact bodies via the ?summary=true
               endpoint flag); conventions always carry full content.
               Optional type / category / full inputs.
- `get`      — Full body of one entry by exact title. Conventions-first
               precedence mirrors `activate`.
- `activate` — Create a workspace item from a library entry by title.

`Workspace: true` on the tool — list/get ignore it; activate validates
and uses it. The schema-level declaration gives activate automatic
pad_set_workspace session-default resolution (same precedent as
pad_meta's mixed-workspace actions).

## Dispatcher extensions

- `dispatchLibraryList` forwards `category` to BOTH endpoints and
  passes `summary=true` to the playbook endpoint by default (unless
  input.full=true). MCP-default summary mode keeps agent context
  budgets tight; CLI default already aligned in TASK-1562.
- `library get` added to the routeTable as a clean GET to
  /api/v1/library/entry with `title` mapped to the query string.
  Cleaner than another explicit dispatcher case — matches
  playbook list / playbook show shape.

## Version bump

ToolSurfaceVersion bumped from 0.4 → 0.5. Pure addition; no existing
tool/action/param/bootstrap shapes changed. Backwards-compatible for
any v0.4 consumer that doesn't enumerate the new tool. Documented in
version.go with the same comment-block structure as prior bumps.

## Test coverage

- catalog_readonly_test.go — pad_library added to the want{} map; three
  library action → cmdPath entries in expected{}; library list / get /
  activate added to liveCmdhelpDoc stubs.
- dispatch_http_project_test.go — 4-case table test (defaults, category,
  full=true, category+full) pins category/summary query-param forwarding;
  library get routing test confirms the routeTable entry resolves.

## Live MCP verification

- `initialize` handshake advertises padToolSurface.version=0.5.
- `pad_meta version` returns tool_surface_version=0.5.
- `pad_library list type=playbooks category=agent-workflows` returns
  4 playbooks in summary mode (content stripped, summary populated,
  invocation_slug + arguments present).
- `pad_library get title='Ship tasks'` returns
  {type: playbook, playbook: {…, content (9512 chars), invocation_slug: ship}}.

Parent: PLAN-1560. Unblocks TASK-1564 (cleanups).

* fix(onboard): update playbook body to use pad_library MCP tool per Codex review (round 1)

Codex P2 on PR #615: the /pad onboard playbook body in
internal/collections/playbook_library_onboard.go still told MCP-only
agents that the library catalog was "not yet exposed as an MCP tool"
and to work from memory — directly contradicting the pad_library tool
this PR just landed and breaking the main advertised consumer of the
new surface.

Updated step B3 (conventions) to mention both surfaces side-by-side
(`pad library list --type conventions` / `pad_library` with
`action: list, type: conventions`), and rewrote step B5 (playbooks)
the same way so the activate path doesn't drift either.

Pre-PLAN-1560 IDEA-1514 reference removed from the body — the idea
is now closed.

No test pins the playbook body content; `make check` passes; the
playbook seed still validates against the playbooks collection schema
since trigger/scope/invocation_slug/arguments are unchanged.

Closes the onboard-side scope of TASK-1564 (stale dispatch_http_slice4
hint + CHANGELOG still pending there).
2026-05-21 19:18:19 -04:00
xarmian 2df6edeaab feat(server): library endpoints gain ?category, ?summary, /library/entry (TASK-1561) (#612)
Extends the convention + playbook library HTTP layer to support the shape
the upcoming `pad_library` MCP tool and the updated `pad library` CLI need:

- `GET /api/v1/convention-library?category=X` — server-side filter,
  case-sensitive exact match. Unknown categories return an empty slice,
  not 404.
- `GET /api/v1/playbook-library?category=X&summary=true` — same filter
  plus a new summary mode that strips Content and injects Summary
  (first non-heading paragraph, ~240 char cap). Web UI and existing
  consumers omit the flag and see the legacy full-body shape. Summary
  mode deep-copies category slices so a request never mutates the
  package-level library data — TestPlaybookLibrary_SummaryDoesNotMutate
  Global pins this.
- `GET /api/v1/library/entry?title=X` — NEW. Returns one matched entry
  in a `{type, convention|playbook}` envelope. Conventions-first
  precedence mirrors the dispatcher's `library activate` so a title
  resolves to the same kind in both surfaces. 400 on missing title,
  404 on no match.

Hoisted `playbookSummary` to `collections.PlaybookSummary` so the
bootstrap handler and the new library endpoints share one algorithm.
Bootstrap continues to call it for every playbook entry it returns.

Adds 12 handler tests + the existing bootstrap-summary test stays
green after the move. Lint clean on touched packages; `make check`
gate is blocked by a pre-existing gofmt issue in
internal/store/workspace_members.go captured as BUG-1565.

Parent: PLAN-1560. Unblocks TASK-1562 (CLI) and TASK-1563 (MCP catalog).
2026-05-21 13:08:02 -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 507793e565 feat(playbooks): author canonical /pad onboard library playbook (TASK-1499) (#576)
* feat(playbooks): author canonical /pad onboard library playbook (TASK-1499)

The fourth invokable library playbook (alongside ship/plan/decompose).
This is the workspace bootstrap interview the agent runs to turn a
freshly-created workspace into one whose collections, conventions,
playbooks, and roles actually match the user's project.

Files:
- internal/collections/playbook_library_onboard.go (new):
  - onboardPlaybookBody — surface-agnostic instruction set teaching
    the agent to ADAPT seeded artifacts, not curate from the library.
    Mode-aware: build (blank workspace), audit (templated workspace),
    revisit (already-onboarded), defaults (escape hatch). The body
    explicitly tells the agent to use pad_item/pad_collection/pad_role
    MCP actions OR pad CLI — never assumes a shell. Lean on the
    TASK-1510/1511/1512 mutation primitives shipped earlier in
    PLAN-1496.
  - onboardPlaybookArguments — mode (enum), defaults (flag),
    skip-codebase (flag). Mirrors the body's ## Arguments section
    for the strict CLI parser.
  - OnboardPlaybook() — LibraryPlaybook constructor.
- internal/collections/playbook_library.go: register OnboardPlaybook()
  in the agent-workflows category alongside ship/plan/decompose.
- internal/collections/playbook_library_test.go:
  - TestPlaybookLibrary_InvokableEntriesPresent now expects 4
    invokable entries (was 3) and includes onboard in wantSlugs.
  - New TestOnboardPlaybook_Contract locks the design contract:
    invocation_slug=onboard, trigger=manual (compatible with the
    blank template's minimal vocab), mode/defaults/skip-codebase
    argument shape, and presence of the "ADAPT, DON'T CURATE"
    posture in the body.

Design notes captured at top of playbook_library_onboard.go:
  1. Adapt, don't curate — library entries are starting points,
     rewrite using the project's actual commands.
  2. Surface-agnostic — describe intent, not specific CLI commands;
     pure MCP users must follow the same flow.
  3. Mode-aware — blank/audit/revisit/defaults paths.
  4. Confirmation before mutation.
  5. Self-removing nudge — the playbook produces user-created items
     which clear the bootstrap onboarding flag (TASK-1504, separate).

Parent: PLAN-1496. Unblocked by TASK-1497 + TASK-1510/1511/1512.

* fix: auto-seed onboard playbook + correct CLI form in body (Codex round 1)

Addresses two PR #576 findings:

1. P1 — folding TASK-1500 into this PR: without auto-seed, the
   library entry alone makes /pad onboard manually-activatable but
   not invokable on day one. Codex correctly flagged that the PR as
   originally drafted shipped a half-feature.

   Wiring (PLAN-1496 / TASK-1500):
   - OnboardSeedPlaybook() in playbook_library_onboard.go returns
     the playbook as a SeedPlaybook with status=active,
     trigger=manual, scope=all, invocation_slug=onboard,
     arguments=onboardPlaybookArguments. Body + args are shared
     with the library entry (same pattern ShipPlaybook uses for
     ship) so they cannot drift.
   - SeedCollectionsFromTemplate appends OnboardSeedPlaybook to
     EVERY workspace created with a non-empty templateName —
     blank, startup, scrum, product, hiring, interviewing, demo.
     The empty-templateName path is preserved as the explicit
     backward-compat escape hatch (tests + direct API callers
     that want a bare workspace with zero items). cmd/pad/init.go
     always supplies a non-empty template (interactive picker or
     defaultTemplateName), so real user-facing workspace creation
     always lands in the seeded branch.

   Tests:
   - TestSeedFromTemplateAlwaysIncludesOnboardPlaybook walks all
     six real templates and confirms the onboard playbook is
     seeded into each.
   - TestSeedWithEmptyTemplateNameSkipsOnboard locks the
     escape-hatch invariant.
   - TestSeedFromBlankTemplate updated: blank workspace now ships
     exactly one item (the onboard playbook) instead of zero,
     because that's TASK-1500's whole point.

2. P2 — the body referenced 'pad library list-conventions', which
   doesn't exist. Corrected to 'pad library list --type conventions'
   (the actual CLI form), with a parenthetical pointing MCP users
   at pad_meta.action: bootstrap for the same data.

This PR now covers both TASK-1499 (author playbook) and TASK-1500
(auto-seed) — combining them because Codex's P1 made it clear they
ship together or not at all.

* docs: correct MCP library-browse fallback in onboard body (Codex round 2)

P2 finding on PR #576: the body told MCP-only users to read the
convention library via 'pad_meta.action: bootstrap'. Bootstrap
returns workspace STATE (collections, conventions, playbooks
actually present in the workspace), not the global library
catalog. So MCP users following that instruction would see only
what's already activated, not what they could activate.

The honest answer is that there is no MCP library-browse surface
today. Updated the body to say so explicitly: if the agent has a
shell, use 'pad library list'; if not, work from domain knowledge
and have the user paste any library bodies they want as starting
text.

Captured the underlying gap as IDEA-1514 (Expose library catalog
via MCP) and linked from the playbook body. Three options outlined
there: new pad_library tool, pad_meta.action: library, or embed in
bootstrap.

Parent: PLAN-1496.
2026-05-17 11:52:20 -04:00
xarmian cc0b1c0bf3 feat(templates): finalize 'blank' template with minimal-vocab seeds for /pad onboard (TASK-1498) (#575)
* feat(templates): finalize 'blank' template for /pad onboard flow (TASK-1498)

A blank template entry was already present in templates.go (drafted
for IDEA-1479) but its seeded trigger/scope vocabularies leaked the
software domain — on-commit, on-pr-create, on-implement, etc., baked
into a template whose whole point is being domain-agnostic. The
/pad onboard playbook (PLAN-1496 / TASK-1499) needs a true blank
starting point so the interview can broaden vocabulary to match the
project's actual domain, whatever it is.

This commit:

- Replaces the software-flavored seed with minimal vocab: trigger=
  always for conventions, trigger=manual for playbooks, scope=all
  on both. The constants live in templates_blank.go so future tweaks
  to the seed surface have a focused diff. The agent broadens via
  pad collection update (TASK-1510) during onboarding.
- Updates the template's description and icon to point at the
  onboard flow ("Empty workspace — run /pad onboard to build it out",
  sparkles instead of memo).
- Adds an in-place comment explaining the design choice so the next
  reader doesn't re-leak software triggers into the seed.
- New test: TestBlankTemplateUsesMinimalVocabularies locks the
  minimal-seed posture; any regression that adds domain-flavored
  triggers fails this test and triggers a fresh design conversation.

Pre-existing IDEA-1479 tests (Shape, ExcludesSoftwareCollections,
AppearsInPicker) still pass — the contract they describe is
preserved (2 system collections only, no user-facing leaks, Custom
group placement).

Parent: PLAN-1496.

* fix(test): blank-vocab assertions use literal slices, not the vars they came from (round 1)

P3 finding on PR #575: TestBlankTemplateUsesMinimalVocabularies
compared template output to BlankConventionTriggers /
BlankPlaybookTriggers — the same vars used to build the template.
Widening either var would silently widen the "minimal" definition
and the test would still pass, defeating the drift-guard intent.

Switched to literal expected slices. Now any change to the var that
adds a domain trigger fails the test loudly.
2026-05-17 07:52:58 -04:00
xarmian f5579300fb feat(cli,mcp): expose 'collection update' via CLI and MCP catalog (TASK-1510) (#572)
* feat(cli,mcp): expose 'collection update' via CLI and MCP catalog (TASK-1510)

The HTTP handler at handlers_collections.go::handleUpdateCollection
already supported PATCHing a collection's name, icon, description,
prefix, schema, settings, and sort_order (plus field-value migrations).
The CLI and MCP surfaces never exposed it, so agents couldn't rename
collections, swap icons, or reshape schemas — a hard blocker for the
adaptive /pad onboard playbook (TASK-1499) which needs to rewrite
seeded collections to match each project's actual vocabulary.

This wires both agent-facing surfaces to the existing handler:

- cmd/pad: new 'pad collection update <slug>' Cobra subcommand with
  --name / --icon / --description / --prefix / --schema / --fields /
  --sort-order flags. Only flags explicitly set are sent (uses
  cmd.Flags().Changed); --schema and --fields reuse the existing
  collectionSchemaJSONFromFlags helper so DSL parity stays.

- internal/mcp/catalog_collection: add 'update' action plus the
  slug, prefix, and sort_order params on padCollectionTool.

- internal/mcp/dispatch_http_routes: new mapCollectionUpdate handles
  the schema-object-vs-string coercion. The catalog declares schema
  as a JSON object for MCP ergonomics, but
  models.CollectionUpdate.Schema is *string — and its UnmarshalJSON
  only flexes settings, not schema. The mapper re-marshals object
  input to its JSON-string form before sending, symmetric to what
  the CLI does via collectionSchemaJSONFromFlags.

Tests cover canonical body, schema-object-to-string coercion
(round-trip through CollectionUpdate.UnmarshalJSON), schema-string
pass-through, empty-field omission, and required-arg validation.
catalog_readonly_test bijection + liveCmdhelpDoc fake updated.

Parent: PLAN-1496.

* fix(mcp): collection update — clear-on-empty + fields DSL parity per Codex review (round 1)

Addresses two P2 findings on PR #572:

1. The catalog advertises `icon=""` / `description=""` / `prefix=""`
   as clear-the-field, and the CLI flag help says the same, but the
   HTTP mapper filtered empty strings via `v != ""` — leaving MCP HTTP
   callers unable to clear fields the CLI can. Switched to key-presence
   semantics for the four string fields so explicit empty strings
   round-trip to the store (which honors *string("") as "clear").

2. The catalog advertises `fields OR schema` as mutually exclusive
   (mirroring `pad collection create`), but the mapper only consumed
   `schema`. An MCP HTTP request with `fields=...` produced a `{}` PATCH
   body silently. Extracted the DSL parser to a shared package
   (internal/collections/dsl.go::ParseFieldsDSL + FieldsDSLToSchemaJSON)
   so the CLI and the mapper share one parser; mapper now resolves
   fields-or-schema with the same mutual-exclusion guard the CLI has.

Tests added in dispatch_http_routes_extras_test.go:
- TestMapCollectionUpdate_EmptyStringClearsField
- TestMapCollectionUpdate_AcceptsFieldsDSL (round-trips through
  models.CollectionSchema to confirm the parsed shape)
- TestMapCollectionUpdate_RejectsFieldsAndSchemaTogether

cmd/pad/main.go's parseFieldsDSL becomes a one-line alias for
collections.ParseFieldsDSL so the CLI's behavior stays identical.

Parent: PLAN-1496, fixing PR #572 / TASK-1510.

* fix(mcp): collection update — use encodeSchemaForBody + normalize empty schema (round 2)

Addresses two more findings from Codex round 2 on PR #572:

1. P2: mapCollectionUpdate bypassed encodeSchemaForBody, so structured
   schemas didn't get label backfill and string schemas weren't
   validated before PATCH — diverged from collection create + CLI.
   Now reuses encodeSchemaForBody (the same encoder collection create
   uses at dispatch_http_routes.go:418), getting label-backfill via
   the Title-Case-of-key heuristic and shape validation for free.

2. P3: schema=null or schema="" plus a real fields=... update tripped
   the mutual-exclusion check. Now normalizes empty inputs as absent
   BEFORE checking exclusivity, matching the relaxed handling
   collection create has for optional empty params.

Tests:
- Renamed TestMapCollectionUpdate_PassesSchemaStringVerbatim to
  TestMapCollectionUpdate_AcceptsSchemaString — the new property is
  round-trip parity + label backfill, not verbatim pass-through.
- New TestMapCollectionUpdate_EmptySchemaDoesNotBlockFields covers
  both nil and empty-string schema combined with a real fields value.

Parent: PLAN-1496, addressing Codex round 2 on PR #572 / TASK-1510.
2026-05-17 01:45:40 -04:00
xarmian 7c663a3d3f feat(collections): add blank workspace template + retire auto-upgrade hook (IDEA-1479) (#560)
* feat(collections): add blank workspace template (IDEA-1479)

Introduces a `blank` workspace template that seeds only the two system
collections (Conventions, Playbooks) — no Tasks/Ideas/Plans/Docs, no
seeded items, no starter conventions or playbooks. Solves the
agent-self / non-template-fit use case where the existing software
templates leave undeletable ghost collections in the workspace.

Adds a new `CategoryCustom` ("Custom") top-level category so the blank
template doesn't mis-group with `startup` / `scrum` / `product`.
Category is appended last in `CategoryOrder` so it doesn't displace
recommended-path templates in the picker.

Tests:
- TestBlankTemplateShape — exactly 2 system collections, no seeds.
- TestBlankTemplateExcludesSoftwareCollections — no tasks/ideas/plans/docs.
- TestBlankTemplateAppearsInPicker — surfaces under a Custom group.
- TestSeedFromBlankTemplate — bootstrapping produces 2 collections, 0 items.

* fix: address codex review for blank template (IDEA-1479)

- CreateWorkspaceModal: remove hard-coded 'blank' picker entry that
  silently fell through to collections.Defaults(). The API-driven blank
  template (under the Custom category) is now the canonical surface.
- Dashboard: gate '+ New Task' button on tasks collection existence so
  blank workspaces don't render a button that targets a missing
  collection.
- OnboardingChecklist: accept collectionSlugs prop and filter steps
  whose target collection (plans/tasks/docs) is absent. Conventions
  step remains unconditional since the conventions collection ships
  with every template, including blank. Empty-steps guard added to
  progressPct to avoid NaN.
- web/src/lib/utils/templates.ts: add 'custom' -> 'Custom' to mirror
  the Go CategoryOrder + categoryLabels updates.
- cmd/pad/templates_picker_test.go: extend the visible-template
  assertion list to include 'blank' and assert the Custom category
  header renders.

* fix(store): gate SeedDefaultCollections on zero-collection workspaces (IDEA-1479)

The server's startup auto-upgrade hook (cmd/pad/main.go) called
SeedDefaultCollections against every workspace at boot. That hook
dates to the initial release — long before workspace templates
existed — and was written as a backfill for workspaces created
before tasks/ideas/plans/docs landed in Defaults().

Post-templates, the hook unconditionally re-materialized the
Software-template collections into any workspace missing them —
including blank-template workspaces (IDEA-1479), which ship only
Conventions + Playbooks by design. Result: every restart silently
regrew the ghost user-facing collections the blank template was
explicitly built to avoid.

Fix: SeedDefaultCollections now returns nil immediately when the
workspace has any existing collection (system or user-facing). The
rescue path still triggers for genuinely-empty workspaces, preserving
the original backfill intent.

Tests:
- TestBlankWorkspaceSurvivesSeedDefaultCollections — blank workspace
  remains 2 collections after auto-upgrade (and after a second pass).
- TestEmptyWorkspaceStillGetsDefaults — zero-collection workspace
  still gets the full Software default set.

* refactor(server): remove SeedDefaultCollections auto-upgrade at startup (IDEA-1479)

The startup auto-upgrade hook in cmd/pad/main.go dated to the initial
release, predating workspace templates entirely. Its original intent
was per-collection backfill — workspaces created before a new entry
landed in Defaults() would acquire it on next boot. Post-templates,
that semantic is incompatible with templates that legitimately
diverge from Defaults() (e.g. `blank`, which ships only Conventions
+ Playbooks by design).

Round-2 of the IDEA-1479 review attempted to keep the hook by adding
a "zero collections" guard, but Dave (after codex round 3) decided
the cleanest fix is removing the hook entirely. The codebase has
proper migration infrastructure now; any future "add a default
collection" work should land as an explicit migration where the
author chooses which workspaces to backfill.

SeedDefaultCollections itself is preserved (with the round-2 guard)
as a building block for any future explicit rescue command or
migration. Its doc comment is updated to note it's no longer
auto-invoked at startup. The round-2 regression tests
(TestBlankWorkspaceSurvivesSeedDefaultCollections,
TestEmptyWorkspaceStillGetsDefaults) still apply and pass unchanged.

* fix(store): rescue gate uses COUNT(*), not ListCollectionsMinimal (IDEA-1479)

Postgres CI on PR #560 caught a regression introduced in commit 3e71fe8:
SeedDefaultCollections's zero-collection guard called
ListCollectionsMinimal, whose SELECT uses COALESCE(settings, '') against
a JSONB column. Postgres parses the '' literal as JSON at plan time
and fails with SQLSTATE 22P02 (invalid input syntax for type json),
breaking the rescue gate and ~12 cascade test fixtures that depend on
the seeder succeeding.

The gate only needs to know whether any collection exists, not their
schema or settings. Switch to a direct COUNT(*) on the collections
table: portable across both drivers, cheaper than the minimal lister,
and avoids the broken JSON COALESCE path entirely.

Verified locally against both drivers:
  - SQLite (default): go test ./... — all PASS
  - Postgres (make test-pg infra):
    PAD_TEST_POSTGRES_URL=... go test ./... — all PASS, including
    the three direct failures (TestBlankWorkspaceSurvives…,
    TestEmptyWorkspaceStillGetsDefaults, TestSeedDefaultCollections)
    and the cascade FTS/search fixtures.

Note: ListCollectionsMinimal's COALESCE(settings, '') expression
appears to also affect production callers (handlers_dashboard,
handlers_items) on Postgres, but fixing that is out of scope for
this PR — those paths have their own tests that aren't failing in CI.
Flagged for separate follow-up.
2026-05-15 14:46:26 -04:00
xarmian 1db0e6a505 docs+test: closes the PLAN-1397 library overhaul loop (TASK-1404) (#533)
* docs+test: closes the PLAN-1397 library overhaul loop (TASK-1404)

## Tests

New `internal/collections/playbook_library_test.go` with 4 regression
guards for the invokable-first library:

- TestPlaybookLibrary_InvokableEntriesPresent — asserts ship + plan +
  decompose are all present by invocation_slug, that each has at
  least one Argument declared, and that at least 3 invokable entries
  exist. Catches T1 widening getting half-reverted or T6's library
  rebuild dropping an entry.
- TestPlaybookLibrary_AllTriggersKnown — asserts every library
  entry's Trigger is one of the canonical values (manual,
  on-implement, on-review, on-plan, on-triage, on-release,
  on-deploy, on-pr-create, on-task-complete, always). Catches typos
  that would seed an invalid trigger.
- TestPlaybookLibrary_ShipBodyShared — confirms the library `ship`
  entry and ShipPlaybook() seed share the same body constant. The
  whole point of T3 was to avoid duplication; this prevents drift.
- TestPlaybookLibraryArchive_BodiesCompiled — keeps archivedPlaybooks()
  greppable and verifies the canonical retired title ("Implementation
  Workflow") is still in the archive.

## CLAUDE.md

- Added a "Library — discovery surface" subsection to the Playbooks
  section explaining the invokable-first lineup, the archive, and
  how `softwareStarterPlaybookTitles` seeds plan + decompose into
  software workspaces.
- Expanded "Code map" with the four new/refactored library files
  (playbook_library.go, playbook_library_plan.go,
  playbook_library_decompose.go, playbook_library_archive.go).
- Cross-referenced PLAN-1397 alongside PLAN-1377 for design history.

## skills/pad/SKILL.md

- "Planning: 'Let's create a plan'" and "Decomposition: 'Break plan
  X into tasks'" now name `/pad plan` and `/pad decompose` as the
  canonical entry points, with the inline workflow as the fallback
  when those playbooks aren't activated in the workspace.
- The high-level "Planning:" intent list at the top of the routing
  section updated to match.
- Note: SKILL.md is embedded into the Go binary at build time. The
  next `make install` distributes the skill update; the .agents/
  copy regenerates automatically.

Parent: PLAN-1397. Closes the loop — all 7 tasks of the playbook
library overhaul shipped.

* fix(test): source knownPlaybookTriggers from SoftwarePlaybookTriggers per Codex review (round 1)

Round 1: the hand-maintained `knownPlaybookTriggers` map admitted
`on-pr-create`, `on-task-complete`, and `always` — all valid in
the schema for *conventions*, but NOT in `SoftwarePlaybookTriggers`
(templates.go:266). A library entry could ship one of those and
the regression test would pass, but the same trigger would be
rejected when seeded into a software workspace at activation.

Fix: derive the test's known-set from `SoftwarePlaybookTriggers`
directly. If the schema widens or narrows, the test follows
automatically — no drift between the assertion baseline and the
actual schema. Also added a note clarifying that templates with
domain-specific trigger vocabularies (e.g. hiring's
on-candidate-advance) would need their own library scoped to
that template.

The set is now exactly: manual, on-implement, on-triage,
on-release, on-plan, on-review, on-deploy.
2026-05-13 07:17:58 -04:00
xarmian 8fa1dd36f9 refactor(library): archive 9 pre-PLAN-1377 playbook bodies, rebuild library as invokable-first (TASK-1403) (#532)
Retires the legacy trigger-only library entries from the public
surface and replaces the 4-category structure with a single
`agent-workflows` category housing the three invokable workflow
playbooks (ship, plan, decompose).

## Changes

- New file `internal/collections/playbook_library_archive.go` —
  holds all 9 retired bodies in package-private `archivedPlaybooks()`.
  Bodies stay compiled so they're greppable and refactor-safe;
  per-entry "convert to invokable" / "promote to convention" /
  "retire" decisions are tracked in IDEA-1396. `var _ = archivedPlaybooks`
  keeps the symbol referenced for unused-symbol linters.

- `internal/collections/playbook_library.go::PlaybookLibrary()` —
  removed the 4 categories (workflow, planning, quality, operations)
  and the 9 bodies inline. Replaced with a single `agent-workflows`
  category containing ship + plan + decompose (the invokable trio
  landed in T3/T4/T5). All three carry InvocationSlug and Arguments
  so the library teaches the PLAN-1377 invocation model from the
  first card.

- `internal/collections/playbook_library_plan.go` /
  `playbook_library_decompose.go` — bump each helper's `Category`
  field from `workflow` to `agent-workflows` to match the new
  registry grouping.

- `internal/collections/templates.go::softwareStarterPlaybookTitles` —
  updated from the retired pair ("Implementation Workflow", "Code
  Review Process") to the new invokable pair ("Plan a new
  initiative", "Decompose a plan into tasks"). `startup` template
  separately prepends `ship` (templates.go:~441), so every software
  workspace now seeds the full invokable trio from day one.

- `internal/mcp/dispatch_http_slice4_test.go` — the activate-by-title
  fixture used "Implementation Workflow"; switched to "Ship tasks"
  (still a real library entry with trigger+scope in its activation
  payload). T6's verify section flagged this fixture; addressing it
  here keeps the build green on this branch rather than deferring
  the breakage to T7.

- `cmd/pad/main.go` — `pad library activate --help` example used
  "Implementation Workflow" as a sample title; switched to "Ship
  tasks" so the example still resolves.

## Verify

- `go build ./...` clean (no dangling references to the 9 titles in
  production code paths).
- `go vet ./...` clean.
- `go test ./...` — all packages green.
- `grep -r "Implementation Workflow" --include="*.go" --include="*.ts"
   --include="*.svelte"` returns only:
  - `playbook_library_archive.go` (expected — the archive)
  - `playbook_library_plan.go` (historical comment, intentional)
  - `templates.go:868` (demo-workspace seed item content — unrelated
    to library lookup; literal item title in a template, would not
    benefit from being retitled in this PR's scope)

Pre-existing workspaces' already-activated copies of the 9 entries
keep working — they live in workspace data, not library code. Only
future activations are affected (the legacy titles no longer resolve
via `pad library activate` or the web Library UI).

Parent: PLAN-1397. Depends on T3/T4/T5 — the library is never empty
because ship, plan, and decompose are already in place.
2026-05-13 07:11:01 -04:00
xarmian 8eb927cbbc feat(library): author the decompose invokable playbook (TASK-1402) (#531)
New library entry: `/pad decompose <PLAN-ref>` — takes a plan and
creates the child task items its body implies, with the user's
approval at every step. The natural follow-up to `/pad plan`:
that playbook creates the plan; this one turns the breakdown into
actionable items linked back via --parent.

Code shape follows the templates_startup_ship.go pattern:

- internal/collections/playbook_library_decompose.go (new) — holds
  decomposePlaybookBody + decomposePlaybookArguments + a
  DecomposePlaybook() helper.
- internal/collections/playbook_library.go — registers
  DecomposePlaybook() in the workflow category right after PlanPlaybook().

Arguments:
- target (required, ref) — the plan to decompose
- dry-run (flag, default=false) — propose without creating
- collection (optional, string, default=tasks) — for non-default
  workspaces (bugs, work-items, etc.)

Body walks: load plan + existing children → analyze the body for
actionable work → reconcile against existing children → propose
task list with priorities and dependency hints → confirm in bulk
→ create approved tasks → wire dependencies via `pad item block`
→ report with suggested next moves.

Mirrors skills/pad/SKILL.md's "Decomposition: 'Break plan X into
tasks'" workflow so the conversational and invokable forms stay
in lockstep (T7 covers SKILL.md sync).

Closes the loop opened by T4: `/pad plan` step 7 now has a real
`decompose` playbook to delegate to when it's activated in the
workspace.

Parent: PLAN-1397.
2026-05-13 07:01:49 -04:00
xarmian ad3926f643 feat(library): author the plan invokable playbook (TASK-1401) (#530)
* feat(library): author the plan invokable playbook (TASK-1401)

New library entry: `/pad plan <topic>` — a conversation-first
playbook for co-designing a new plan with the user. Subsumes the
pre-PLAN-1377 "Plan Creation" library entry, reframed as a
structured invokable procedure with explicit arguments.

Code shape follows the templates_startup_ship.go pattern:

- internal/collections/playbook_library_plan.go (new) — holds the
  body + argument spec as package-level constants and exports
  PlanPlaybook() LibraryPlaybook.
- internal/collections/playbook_library.go — registers
  PlanPlaybook() in the workflow category right after ship.

Arguments (mirroring the body's `## Arguments` section):
- topic (required, string)
- parent (optional, ref)
- collection (optional, string, default=plans)

Tone is generic across project types — no software-only vocabulary,
no Pad-specific assumptions. A research workspace can plan an
experiment; a hiring workspace can plan a recruiting push; the
structure adapts via the conversation. Mirrors the "Planning: 'Let's
create a plan'" workflow in skills/pad/SKILL.md so the conversational
and invokable forms stay in lockstep (T7 docs pass calls this out).

The body's step 7 invites the user to follow up with
`/pad decompose <new-plan-ref>` — T5 (TASK-1402) authors that
playbook next.

Parent: PLAN-1397.

* fix(library): plan playbook handles ref-form quick-action invocation, softens /pad decompose forward-reference per Codex review (round 1)

Round 1 P2: /pad decompose forward-reference in step 7 would resolve to
an unknown playbook until T5 (TASK-1402) merges. Reworded step 7 to
check whether a `decompose` playbook is activated and fall back to
inline `pad item create task` calls otherwise. Body now works
regardless of whether T5 has landed.

Round 1 P3: existing UI quick-action prompt (defaults.go:157) emits
`/pad plan {ref} "{title}" — outline goals, deliverables, and
timeline` on plan-collection cards. With my original topic-first arg
shape, the agent's NL dispatcher would bind the ref as `topic` and
treat the title as an extra positional, getting the wrong invocation
mode.

Fix: documented the dual-purpose `topic` argument explicitly. New
"## Dispatch" section in the body tells the agent to detect:
- First positional matches `^[A-Z]+-\d+$` AND resolves to a real
  item → elaborate mode (load item, work with user to expand it,
  update via `pad item update <ref> --stdin`)
- Otherwise → create-new mode (full conversation flow below)

planPlaybookArguments updated to mirror: topic now described as
"string OR ref", parent/collection noted as create-new-mode only.
Strict CLI parsing remains string-typed; the dual semantics are
agent-interpreted per PLAN-1377's design.

The existing "Plan this" quick-action prompt now routes correctly
through the elaborate path without needing changes to defaults.go.

* fix(library): align plan playbook philosophy section with step 7 fallback per Codex review (round 2)

Round 2 P3: Philosophy said "never create tasks here" which
contradicted step 7's new inline-task-creation fallback (added in
round 1 to handle the case where no decompose playbook is active).
Reworded to permit the fallback explicitly while keeping the
"don't create before user approval" rule intact.
2026-05-13 06:57:29 -04:00
xarmian 8efcc21ef5 feat(library): add ship playbook to library, shared body with startup seed (TASK-1400) (#529)
The headline invokable example from PLAN-1377 (`/pad ship <args>`) was
previously only seeded into `startup`-template workspaces. Library
discovery is the canonical place users learn what invokable playbooks
are, so `ship` now lives there too — available to any workspace
template (`scrum`, `product`, `hiring`, `interviewing`, future ones).

Single source of truth: the library entry references the same
package-level `shipPlaybookBody` + `shipPlaybookArguments` constants
that ShipPlaybook() uses. Updates to the playbook body live in one
place; both surfaces (library activation + startup seed) consume it.

The title "Ship tasks" matches ShipPlaybook()'s SeedPlaybook so the
library UI's title-keyed `activePlaybookTitles` check renders the
entry as "Active" in startup workspaces where it's already seeded —
no duplicate activation, no behavior change.

Placement: top of the existing `workflow` category. T6 will rearrange
the category structure when retiring the legacy entries; for now
adding to workflow is the least-intrusive prominent placement.

Smoke-tested locally that ship resolves through PlaybookLibrary() with
the expected title, trigger, args count, and shared content; the
permanent regression test ships in T7.

Parent: PLAN-1397.
2026-05-13 06:43:04 -04:00
xarmian 4bbd0a210d feat(collections): widen LibraryPlaybook with InvocationSlug + Arguments (TASK-1398) (#527)
* feat(collections): widen LibraryPlaybook with InvocationSlug + Arguments (TASK-1398)

Adds two optional fields to LibraryPlaybook:
- InvocationSlug — kebab-case slug for `/pad <slug>` routing (PLAN-1377)
- Arguments — argument spec mirroring the body's `## Arguments` section

Both fields are tagged with `omitempty` so existing library entries
(none of which set them) serialize unchanged. seedPlaybookFromLibrary()
now forwards both into the seeded item's Fields JSON only when set,
matching the shape ShipPlaybook() already writes.

This is the foundational task that unblocks T2 through T6 of the
playbook library overhaul.

Parent: PLAN-1397.

* fix(library): propagate invocation_slug + arguments through activation paths per Codex review (round 1)

Codex round 1 caught that the activation paths for library playbooks
rebuild the fields map and drop the new fields, so any library entry
declaring invocation_slug/arguments would lose `/pad <slug>` routing
after activation.

Fixed in three places:
- internal/cli/client.go LibraryPlaybook (the client-side mirror used
  by the CLI `pad library activate` command)
- cmd/pad/main.go libraryActivate (CLI subprocess activation)
- internal/mcp/dispatch_http_slice4.go dispatchLibraryActivate (MCP
  pad_project action=library-activate)

All three now forward invocation_slug and arguments only when set,
matching ShipPlaybook()'s shape exactly.

Web client (web/src/lib/api/client.ts) activation payload is T2's
explicit scope — left for that PR.
2026-05-13 00:26:09 -04:00
xarmian 936145c524 feat(collections): seed generic ship playbook in startup template (TASK-1386) (#523)
* feat(collections): seed generic ship playbook in startup template (TASK-1386)

Adds the de-personalized `ship` playbook to the startup template's seeded
playbooks slice, the headline example of PLAN-1377's playbook invocation
model. A fresh `pad workspace init --template startup` workspace now ships
PLAYB-N with invocation_slug=ship, ready to drive via `/pad ship <args>`
or `pad playbook run ship`.

Body is derived from the personal /ship-tasks slash command, with
project-specific bits (Codex CLI, branch naming, commit format, build
commands) marked customizable so the playbook reads cleanly on day one
without forcing the seed user to wire two playbooks together to ship
anything. ## Arguments section in the body mirrors the structured
arguments JSON field so the web UI editor (TASK-1384) can keep them
in sync.

Parent: PLAN-1377.

* fix(collections): align ship playbook target/limit with strict parser per review (round 1)

Codex round 1 findings:

1. target docstring said "space or comma separated" task refs, but the
   structured spec only has one positional 'target'. The strict CLI/MCP
   parser would bind TASK-10 then reject TASK-11. Updated body to:
   - declare 'comma-separated list' in the ## Arguments line
   - note that the agent's NL parser collapses space-separated refs to
     comma form before binding (so '/pad ship TASK-10 TASK-11' still works)

2. limit said 'default=∞' in markdown but had no default in the structured
   arguments JSON. Removed the bogus default — limit is now genuinely
   optional with 'unset = no limit', so the markdown and the spec agree.

Parent: TASK-1386 / PLAN-1377.
2026-05-12 20:16:28 -04:00
xarmian c38b3bf5cd feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378) (#517)
* feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378)

Foundational change for PLAN-1377 — playbooks become first-class invokable
procedures. Two new optional fields land on the Playbooks collection
schema:

- `invocation_slug` (text, kebab-case, unique-per-workspace among non-null
  values): enables `/pad <slug>` direct invocation. Nullable so
  trigger-only playbooks (e.g. on-release checklists) don't need one.
- `arguments` (json, array of {name, type, required, default, description}):
  declares the playbook's argument contract; mirrors the body's
  `## Arguments` section in queryable form.

Plumbing pieces:

- `models.FieldDef` grows two general-purpose options — `Pattern` for
  regex validation and `UniqueScope` for collection-level uniqueness.
  Both are opt-in; existing schemas are unaffected.
- `items.ValidateFields` learns the `json` field type (accepts any
  JSON-decodable value) and applies `Pattern` to string-typed values.
- `handlers_items.checkUniqueFields` queries `Store.ListItems` to enforce
  `UniqueScope == "workspace_collection"` on create + update.
- Two migrations (SQLite 054, Postgres 033) JSON-patch the playbooks
  schema on existing workspaces so the new fields show up without a
  workspace re-init.
- TypeScript `FieldDef` mirrors the Go side.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 1 findings (TASK-1378)

P1 — EditCollectionModal now round-trips opaque pattern/unique_scope
metadata. EditableField carries the new keys; the load + save paths
preserve them so re-saving the playbooks collection from the UI doesn't
strip server-side validation rules the modal doesn't yet expose
dedicated controls for. fieldFromDef mirrors the change for templates.

P2 — checkUniqueFields' pre-write ListItems check is now backed by a
partial unique index (idx_items_invocation_slug_per_collection,
SQLite + Postgres) scoped to non-empty, non-deleted rows. The pre-check
still gives users a friendly error message in the common case; the
index closes the TOCTOU race between two concurrent writers. The
create-conflict error message is now generic enough to cover both the
slug constraint and the new invocation_slug index.

P2 — `json` field type now rejects raw strings, numbers, and bools. Only
objects, arrays, and null are accepted, so a generic web text input
can't silently corrupt a structured field by emitting "[]" instead of
an actual array. FieldEditor.svelte routes `json` fields to a
read-only summary in both readonly and edit modes; dedicated editors
(like TASK-1384's playbook editor that owns `arguments`) own the
structured form.

P3 — invocation_slug regex now requires a minimum of two characters
(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) in the Go const, the SQLite migration,
the Postgres migration, and the validate tests. Single-letter slugs
would shadow plausible NL tokens (e.g. `/pad a ...`) and the doc
comment already claimed the two-char floor; this aligns code with
intent.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 2 findings (TASK-1378)

P2.1 — checkUniqueFields no longer passes IncludeArchived=true. The
application-layer pre-check now matches the partial unique index's
`deleted_at IS NULL` predicate so a soft-deleted playbook releases its
slug back to the pool and reclaiming it succeeds instead of 409'ing.

P2.2 — handleUpdateItem now maps UNIQUE constraint / duplicate key
errors from UpdateItem to HTTP 409, mirroring the create path. A true
concurrent-update race that slips past checkUniqueFields and trips the
partial unique index used to surface as a misleading 500.

(Not addressed in this round: Codex's third finding — concern about the
partial unique index applying to "every collection" — is, on close
reading, not what the index does. `ON items(collection_id, json_extract(...))`
scopes uniqueness to the (collection_id, slug) pair, so two items in
different collections with the same `invocation_slug` value coexist
fine. The migration-failure risk is theoretical: `invocation_slug` is
a brand-new field key, so no pre-existing items can have it set, and
no migration-time duplicates can exist. If a future custom collection
adopts the same field name, opting into per-collection uniqueness is
exactly the intended semantic of FieldDef.UniqueScope.)

Parent: PLAN-1377.

* fix(playbooks): map restore-path UNIQUE violations to 409 (TASK-1378)

Codex round 3: restoring an archived playbook can hit the partial
unique index on invocation_slug if a replacement item already claimed
the slug. Map UNIQUE constraint / duplicate key errors from RestoreItem
to HTTP 409 with a targeted message, matching the create + update paths.

Parent: PLAN-1377.

* fix(playbooks): map collab-snapshot UNIQUE violations to 409 (TASK-1378)

Codex round 4: the collab-snapshot PATCH branch under
`s.collab.UnderItemLock` ran its own UpdateItem call and fell through
to writeInternalError on any non-stale-snapshot error. A concurrent
edit racing the invocation_slug partial unique index would surface as
500 instead of 409. Mirror the main UpdateItem error mapping.

Codex's other round-4 finding — the partial unique index applying to
"every collection" — is not addressed because the index IS already
collection-scoped: `ON items(collection_id, json_extract(fields,
'$.invocation_slug'))`. Two items in different collections with the
same slug coexist; only same-collection duplicates conflict. Migration
duplicates are impossible because `invocation_slug` is a brand-new
field key with no pre-existing items setting it. A custom collection
that later adopts the same field name opts into per-collection
uniqueness, matching the FieldDef.UniqueScope="workspace_collection"
semantic.

Parent: PLAN-1377.
2026-05-12 17:17:56 -04:00
xarmian abf017c4e7 feat(onboarding): make banner + CLI hint template-aware (TASK-1150) (#409)
The IDEA-1 trigger phrase is no longer hardcoded — fresh scrum
workspaces surface "use pad to get BACK-1", product workspaces surface
"use pad to get FEAT-1", and any future template that ships an
agent-onboarding seed declares its primary ref once and gets the
banner / hint for free.

Mechanism:

  1. WorkspaceTemplate gains an OnboardingPrimaryRef string field —
     the canonical declaration of "this template's IDEA-1-style
     primary entry." Set per template that ships the pattern
     (startup → "IDEA-1", scrum → "BACK-1", product → "FEAT-1");
     left empty for hiring/interviewing/demo where the agent-onboarding
     pattern intentionally doesn't apply.

  2. Server: handleGetDashboard identifies the seeded primary by
     walking allItems looking for item_number=1 + source="template"
     + created_by="system" + collection_slug ∈ {ideas, backlog,
     features}. The collection-slug whitelist is what keeps hiring's
     REQ-1 (also seeded with item_number=1 + source=template) from
     being flagged as an onboarding entry — those are example items,
     not agent scripts. The dashboard response gains an
     onboarding_seed field with ref/title/slug/collection_slug/status
     plus a server-computed `active` boolean (true iff status equals
     the schema initial value).

  3. CLI: printOnboardingHints accepts the template name, looks up
     the primary ref via collections.GetTemplate, and prints the
     right "use pad to get X-1" line. Templates without a declared
     primary skip the line entirely (so hiring's pad init success
     doesn't promise a non-existent BACK-1 / IDEA-1).

  4. Web frontend: dashboard reads dashboard.onboarding_seed,
     gates the banner on `active=true`, passes ref/slug/collection
     to OnboardingIdeaBanner. The component renders the trigger
     phrase, copy button, and "Read it first" deep link from those
     props — no more hardcoded IDEA-1.

ensureWorkspace's signature gains a returned templateName so init.go
+ main.go can pass it through to printOnboardingHints. The five
existing test call sites updated.

New tests:

  internal/collections/templates_test.go
    - TestTemplatesDeclareOnboardingPrimaryRef — locks the per-template
      OnboardingPrimaryRef values (and the explicit emptiness of
      hiring/interviewing/demo).

  internal/server/handlers_dashboard_test.go
    - TestDashboardOnboardingSeed_StartupTemplate
    - TestDashboardOnboardingSeed_ScrumTemplate
    - TestDashboardOnboardingSeed_ProductTemplate
    - TestDashboardOnboardingSeed_HiringTemplate (asserts NO seed —
      hiring's REQ-1 is example data, not an onboarding entry)
    - TestDashboardOnboardingSeed_EmptyWorkspace (no template)

Removes the loadIdeaOne race-guard from +page.svelte — the dashboard
poll itself now carries the onboarding_seed.active flag so the banner
state lives entirely in the dashboard response. Drops ~50 lines of
frontend code.

Parent: PLAN-1146.
2026-05-04 12:05:07 -04:00
xarmian 8fc0cb3b8b feat(collections): seed onboarding items + explicit prefixes for scrum + product templates (TASK-1149) (#408)
* feat(collections): seed onboarding items + add explicit prefixes for scrum + product templates (TASK-1149)

Mirrors TASK-1133's pattern (PR #402) for the remaining software-category
templates. After this lands:

  - fresh `pad workspace init --template scrum`   → BACK-1 / SPRINT-2 / BUG-3 / DOC-4
  - fresh `pad workspace init --template product` → FEAT-1 / FB-2 / ROAD-3 / DOC-4

Each is a first-person note from the workspace owner's future self —
agent-invocable via `/pad let's discuss <REF>`, schema-aware terminal
verbs ("mark me done" / "completed" / "shipped" / "archived"), no
"tutorial" / "lesson" language. Bodies pulled verbatim from
DOC-1152 (scrum) and DOC-1153 (product).

Precondition fix: explicit Prefix set on five collections so DerivePrefix
doesn't yield awkward refs:

  Backlog       BACKL  → BACK
  Sprints       SPRIN  → SPRINT
  Features      FEATU  → FEAT
  Feedback      FEEDB  → FB
  Roadmap Items RI     → ROAD

Mirrors hiring template's pattern of explicit prefixes on its custom
collections. Existing scrum/product workspaces (forward-only fix) keep
their derived prefixes — the seeder doesn't migrate.

New tests:

  internal/collections/templates_test.go
    - TestScrumOnboardingItemsOrderAndShape
    - TestProductOnboardingItemsOrderAndShape
    - TestScrumProductTemplatesShipOnboardingSeedItems
    - TestScrumProductTemplatesUseExplicitFriendlyPrefixes (locks
      the prefix-fix precondition)

  internal/store/items_test.go
    - TestSeedCollectionsFromTemplateScrumRefSequence
    - TestSeedCollectionsFromTemplateProductRefSequence
      (Both also assert the prefix lands on each seeded item — drift
       in templates.go would surface here as a test failure pointing
       at the PLAN-1146 prefix precondition.)

Existing onboarding test (TestSeedCollectionsFromTemplateStartupRefSequence)
still passes — startup template untouched.

Parent: PLAN-1146. Source content: DOC-1152, DOC-1153.

* docs(comments): clarify the post-signup hint is wired in TASK-1150, not this PR (Codex review round 1)

Codex flagged that the helper-file + templates.go comments said things
like "the post-signup hint will name BACK-1" — which read as "it does
today" but actually means "it will once TASK-1150 lands." Until that
ships, the dashboard banner and CLI hint still hardcode IDEA-1 from
PR #403, so a fresh scrum/product workspace gets the seeded items but
no UI prompt that names them.

Comments now explicitly call out the in-flight state so readers
between this PR and TASK-1150 know what's wired and what isn't.

No behavior change.
2026-05-04 11:41:29 -04:00
xarmian 96253f18a2 feat(collections): seed IDEA-1/PLAN-2/TASK-3/DOC-4 in startup workspaces (TASK-1133) (#402)
* feat(collections): seed IDEA-1/PLAN-2/TASK-3/DOC-4 in startup workspaces (TASK-1133)

A fresh `pad workspace init --template startup` now seeds four onboarding
items — one per user-facing collection — that any agent can fetch and
meaningfully converse around. The post-signup hint will name IDEA-1
specifically, but PLAN-2 / TASK-3 / DOC-4 are all viable entry points
for `/pad let's discuss <REF>`.

The bodies are first-person notes from the workspace owner's future self
that introduce each collection's purpose by inviting a real conversation
about the user's project — no marker, no skill detection, no schema
fields. Word-audit clean: no "tutorial / lesson / step / walkthrough".
Bodies pulled verbatim from DOC-1139.

Sequence-stability: the existing seeder loop in store.SeedCollectionsFromTemplate
already runs SeedItems before conventions/playbooks, so the workspace-scoped
item_number sequence naturally lands at IDEA-1 / PLAN-2 / TASK-3 / DOC-4.
A dedicated test (TestSeedCollectionsFromTemplateStartupRefSequence) locks
the invariant down — drift means the post-signup hint silently misfires.

Scope: startup template only. Scrum and product templates have different
collection sets (Backlog/Sprints/Bugs and Features/Feedback/Roadmap
respectively) and need their own bodies — tracked as follow-up under
PLAN-1131. People-category templates (hiring, interviewing) are PLAN-1140.

Parent: PLAN-1131. Source content: DOC-1139.

* fix(collections): use schema-valid terminal statuses in onboarding bodies per Codex review (round 1)

The seed bodies told agents to "mark me done" but ideas/plans/docs don't
have a `done` terminal status — the HTTP/MCP update path validates select
options, so an agent following the seeded copy would hit a validation
error instead of completing the seed item.

- IDEA-1: "mark this idea done" → "mark this idea implemented"
  (Ideas terminal: implemented|rejected)
- PLAN-2: "mark me done" → "mark me completed"
  (Plans terminal: completed)
- TASK-3: unchanged — "done" is the canonical terminal for Tasks
- DOC-4: "mark me done" → "archive me"
  (Docs terminal: archived)

Caught by Codex on PR #402. Same hard validation path the rest of the
app honors — the seed copy needs to be schema-aware.
2026-05-04 09:10:31 -04:00
xarmian d84f1180a7 feat(mcp): pluggable Dispatcher + HTTPHandlerDispatcher for remote MCP (TASK-965) (#343)
* feat(mcp): pluggable Dispatcher + HTTPHandlerDispatcher for remote MCP (TASK-965)

Architectural prerequisite for PLAN-943's remote MCP at /mcp. The
existing ExecDispatcher (PLAN-942) shells out to the pad binary and
inherits credentials from ~/.pad/credentials.json — fine for local
stdio MCP where the user IS the subprocess owner, but unworkable for
a multi-tenant /mcp endpoint where the dispatcher must serve many
OAuth-authenticated users from a single process.

This PR ships the alternative path: HTTPHandlerDispatcher calls
pad-cloud's existing HTTP handler chain in-process, with the
requesting user attached via context. Same handlers, same audit /
event-bus / webhook plumbing — just no fork().

## What's in

- internal/mcp/dispatch.go: keeps the existing Dispatcher interface
  (so ExecDispatcher unchanged) and adds a context-keyed
  WithDispatchInput helper. The registry attaches the original JSON
  input map to the dispatch context so dispatchers that prefer
  structured data over reverse-parsed cliArgs can use it.
- internal/mcp/registry.go: forwards the merged input (user-supplied
  values + session workspace + root flags) to the dispatcher via
  WithDispatchInput. ExecDispatcher ignores it.
- internal/mcp/dispatch_http.go (new): HTTPHandlerDispatcher
  implementation with a routeTable[cmdPath]→RouteMapper mapping. Seed
  entry: `item create`. Adding more commands is one RouteMapper per
  cmdPath plus a routeTable insert.
- internal/server/context.go (new): exported WithCurrentUser /
  WithAPITokenAuth / WithTokenWorkspaceID + read-only
  CurrentUserFromContext / IsAPITokenFromContext. Lets internal/mcp
  synthesize an authenticated request without reaching the
  package-private context keys.
- internal/server/middleware_csrf.go: extends the existing "Bearer
  token requests skip CSRF" rule to also honor the ctxIsAPIToken
  context flag. Same semantic — non-cookie auth means no CSRF risk —
  but covers the in-process dispatch path where TokenAuth never sets
  the Authorization header. Safe because ctxIsAPIToken can only be
  set by trusted in-process code (TokenAuth on the live Bearer path,
  or server.WithAPITokenAuth from the dispatcher).

## What's tested

- Unit:
  - TestHTTPHandlerDispatcher_RoutesItemCreate — full happy path
    with a recordingHandler asserting method/path/body/user-context.
  - TestHTTPHandlerDispatcher_UnsupportedToolReturnsErrorResult —
    tools not yet in the routeTable produce IsError-flagged results
    rather than panicking.
  - TestHTTPHandlerDispatcher_NoUserReturnsErrorResult — UserResolver
    returning nil produces an IsError, never a nil-deref.
  - TestHTTPHandlerDispatcher_HandlerErrorSurfacesAsToolError — 4xx
    handler responses come back as IsError MCP results matching
    ExecDispatcher's `pad <cmd> failed: <stderr>` format.
  - mapItemCreate validation + parseFieldKVP variants.
- Integration: TestHTTPHandlerDispatcher_Integration drives the full
  *server.Server (real chi router, real SQLite store, full middleware
  chain) with a synthesized OAuth user and asserts the item lands in
  the DB.

## Scope discipline

The DoD called for "dispatch item.create end-to-end" — that's the seed
entry. Wiring the remaining ~70 MCP-exposed commands into routeTable
is naturally a follow-up before TASK-950 ships /mcp to real users
(captured as a separate task post-merge).

Audit-log assertion in the integration test is deferred until TASK-960
(B6b) lands the audit log itself.

Parent: PLAN-943.

* fix(mcp): roll status/priority/category/parent into fields JSON per Codex review (round 1)

Codex caught: mapItemCreate placed status / priority / category /
parent at the top level of the JSON body, but handleCreateItem only
reads them after unmarshalling the Fields string from the request. As
written, MCP-driven `item create` would silently drop those flags —
breaking parity with the CLI for almost every realistic call (parent-
linked tasks, priority-set items, status-overridden ideas, etc.).

Mirrored the CLI's behaviour (cmd/pad/main.go ~L2200): build a fields
map from the named flags, overlay the repeatable --field entries on
top, JSON-encode into ItemCreate.Fields. The handler's existing
schema-validation + parent-resolution path now runs unchanged.

Repeatable --field still wins last-write — locked into a new test so
it doesn't drift.

Also rejects --assign / --role with a clear error rather than silently
dropping them. The CLI resolves user-name → user-ID and role-slug →
role-ID via additional API calls before posting; replicating that
pre-resolution belongs in a follow-up that expands the route table for
production use. Failing loudly is better than partial parity.

Tests:
- TestHTTPHandlerDispatcher_RoutesItemCreate now asserts the
  status/priority/category/parent values land in fields, not the top
  level — guards against the regression directly.
- TestMapItemCreate_ExplicitFieldOverridesNamedFlag locks the
  last-write-wins precedence between --status and --field status=...
- TestMapItemCreate_RejectsUnsupportedAssignRole asserts the
  defensive error path for the deferred flags.

Parent: PLAN-943.

* fix(mcp): persist source=cli for HTTPHandlerDispatcher calls per Codex review (round 2)

Codex caught: actorFromRequest derives source from the Authorization
header — without one, dispatcher-driven calls would persist
source="web" instead of source="cli", regressing dashboard/standup/
audit attribution vs. ExecDispatcher.

Same pattern as the round-1 CSRF fix: extend actorFromRequest to also
honor the ctxIsAPIToken context flag (which TokenAuth sets on the live
Bearer-auth path and HTTPHandlerDispatcher sets via
server.WithAPITokenAuth on synthesized requests). Both signals mean
"non-cookie authenticated, attribute as CLI/agent traffic".

Integration test now asserts source="cli" on the created item, so any
future regression of this attribution surfaces immediately.

Parent: PLAN-943.

* fix(mcp): normalize collection aliases in HTTPHandlerDispatcher per Codex review (round 3)

Codex caught: CLI's `item create task ...` works because
cmd/pad/main.go's normalizeCollectionSlug maps singular/short forms
("task" → "tasks", "doc" → "docs", etc.) to the canonical slug
before posting. HTTPHandlerDispatcher's mapItemCreate skipped that
step, so the same documented call shape would 404 through the HTTP
transport even though it worked through ExecDispatcher.

Extracted the alias map to internal/collections.NormalizeSlug so the
two transports stay in lockstep without duplication. cmd/pad/main.go's
normalizeCollectionSlug now delegates to it; the in-process
dispatcher calls it from mapItemCreate after pulling the collection
out of input.

TestMapItemCreate_NormalizesCollectionAliases locks every documented
alias plus a passthrough case for custom collections.

Parent: PLAN-943.

* fix(server): WithTokenWorkspaceID actually clears on empty input per Codex review (round 4)

Codex caught: the docstring said "Pass an empty string to clear" but
the implementation early-returned `ctx` unchanged in that case,
leaving any stale ctxTokenWorkspaceID set further up the chain
active. Always overwrite so the contract holds: passing "" produces
a context where tokenWorkspaceID(r) returns "", same as a never-set
context.

Parent: PLAN-943.
2026-05-01 11:51:28 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian 157ca4e88f chore: bump Go toolchain to 1.26 (TASK-763) (#247)
* chore: bump Go toolchain to 1.26 (TASK-763)

Bump Go from 1.25 to 1.26 across all toolchain pins:

- go.mod — go 1.25.0 → go 1.26.0
- Dockerfile — golang:1.25-alpine → golang:1.26-alpine
- .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs)
- .github/workflows/release.yml — release pipeline

No `toolchain` directive: the repo is pre-launch with no external
contributors yet, so we set the floor where we want it (hard requirement).

Verified locally before commit:
- golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI)
- golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub
- go build ./... clean
- go vet ./... clean
- go test ./... all pass

Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish).

* chore: gofmt -w under Go 1.26 (TASK-763)

Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all
struct-tag whitespace realignment — no semantic changes. Verified:

- gofmt -l ./cmd ./internal returns empty after
- go build ./... still clean
- go test ./... still passes (run before commit)

Bundling the gofmt diff with the toolchain bump in the same PR because
the formatting drift is a direct consequence of moving from 1.25 to
1.26; splitting them creates a mandatory two-PR ordering for no value.

Parent: PLAN-644.

* docs: bump documented Go floor to 1.26 (TASK-763)

Match go.mod's hard 1.26.0 requirement in the source-build instructions.
Caught by Codex review round 1 on PR #247.

- README.md:158 — "Go 1.25+" → "Go 1.26+"
- CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+"
2026-04-25 11:35:19 -04:00
xarmian e0f3583333 feat(cli): categorized template picker + interactive select (TASK-616) (#148)
* feat(cli): categorized template picker + interactive select (TASK-616)

Turns the CLI template picker from a flat alphabetical dump into a
category-aware flow that reflects the Software / People / Research /
Content / Operations / Personal taxonomy established by PLAN-609.

Library
-------
- collections.GroupTemplatesByCategory returns visible templates
  bucketed into CategoryOrder with a trailing slot for any
  custom-category templates — one canonical grouping that both CLI
  and the upcoming web picker (TASK-617) can consume.
- collections.CategoryLabel turns category slugs into display labels
  ("software" → "Software") with passthrough for unknown values.
- collections.CategoryOrder exposes the canonical display order.

CLI
---
- New cmd/pad/templates_picker.go defines:
  - printGroupedTemplates: writes the grouped listing with icons,
    aligned columns, and a dim "(default)" marker on startup.
  - pickTemplateInteractive: prompts when the user hasn't passed
    --template and is on a TTY. Accepts a number OR a template name;
    enter selects the default (startup); invalid input re-prompts.
  - canPromptForTemplate: TTY detection so scripts never block.
- pad workspace init --list-templates now uses the grouped printer.
- pad workspace init / pad init error messages for unknown templates
  show the grouped list instead of a flat dump.
- pad init now triggers pickTemplateInteractive when no --template
  flag is set AND stdin/stdout are TTYs. Non-TTY invocations fall
  back to the "startup" default unchanged.
- --template flag help no longer hardcodes "startup, scrum, product"
  since the list now grows with non-software templates.

Tests
-----
- Library: TestGroupTemplatesByCategory (canonical order, no hidden,
  every visible template assigned), TestCategoryLabel.
- CLI: TestPickTemplateInteractiveDefault / ByName / ByNumber /
  RetriesOnInvalid, TestPrintGroupedTemplatesIncludesEveryVisibleTemplate
  (smoke: every visible template renders, demo hidden, category
  headers present).

Parent: PLAN-609.

* fix(cli): propagate non-EOF prompt read errors in template picker

Per Codex review on PR #148. pickTemplateInteractive previously
mapped any ReadString error to silently selecting the default
template. A detached PTY returning EIO or a similar read failure
would quietly create a workspace with the startup template even
though the user never made a valid choice. Restrict the silent
fallback to io.EOF (which is benign for pipes, tests, closed
stdin) and bubble up any other error so the command aborts.

* test(cli): cover non-EOF read error path in template picker

Adds TestPickTemplateInteractiveSurfacesNonEOFReadErrors to verify
the behavior change from the previous commit — a non-EOF read
failure propagates up instead of silently selecting the default
template.
2026-04-18 06:31:24 -04:00
xarmian fed365d914 feat(templates): ship interviewing template (TASK-615) (#147)
* feat(templates): ship interviewing template (TASK-615)

Candidate-side companion to the hiring (company-side) template. Same
People category, near-zero collection overlap — a real proof that
one category can hold two templates with barely-related schemas.

Collections
-----------
- Applications (APP) — roles being tracked, stages from researching →
  applied → screen → interviewing → offer → accepted / rejected /
  withdrawn
- Interviews (INT) — individual rounds, child of an Application,
  with round type, format, date, prep_status (including completed)
- Companies (CO) — standalone research notes on companies, referenced
  from Applications via wiki-link so notes are persistent even across
  multiple Applications at the same company
- Contacts (CON) — referrals, recruiters, interviewers — tracked
  independently for followup hygiene
- Docs, Conventions, Playbooks

Trigger vocabularies
--------------------
- InterviewingConventionTriggers: always, on-application-submitted,
  on-interview-scheduled, on-interview-completed, on-stage-change,
  on-offer-received, on-rejection, weekly-review
- InterviewingPlaybookTriggers: on-application-submitted,
  on-interview-scheduled, on-interview-completed, on-stage-change,
  weekly-review, manual
- Scopes: all, research, applications, interviews, followups

Starter pack
------------
- Conventions (3): 48h prep notes (should/on-interview-scheduled),
  end-of-application retros (should/on-stage-change), 24h thank-yous
  (should/on-interview-completed)
- Playbooks (3): Log an Interview, Weekly Job Search Review,
  Interviewing Workspace Onboarding
- Seed items (2): one example Application, one example Company

Tests
-----
- TestInterviewingTemplate — collections present, interviewing
  triggers present and distinct from both software AND hiring triggers
- TestSeedCollectionsFromTemplateInterviewing — end-to-end: seven
  collections created, starter pack populated, prefixes correct

Parent: PLAN-609.

* fix(templates): mark interviewing Companies.closed as terminal

Per Codex review on PR #147. The Companies status field had a
'closed' option but no TerminalOptions declared, so done-state
resolution fell back to the global default set which doesn't
include 'closed' — closed companies were being counted as active
in dashboard and progress views. Adding TerminalOptions fixes
lifecycle metrics without changing user-facing options.

* fix(templates): Log-an-Interview playbook uses only interviewing collections

Per Codex review iteration 2 on PR #147. The playbook step
referenced 'Create a follow-up Task' but the interviewing
template doesn't ship a tasks collection. Rewrote the step to
use the collections that actually exist — log the thank-you as
a comment on the Interview and update the matching Contact's
last_contact. Keeps the starter playbook consistent with the
schema it ships alongside.
2026-04-18 06:10:27 -04:00
xarmian b891ba84a4 feat(templates): ship hiring template (TASK-614) (#146)
* feat(templates): ship hiring template (TASK-614)

First non-software template under PLAN-609. Proves the machinery
built by TASK-610 through TASK-613 end-to-end: category grouping,
per-template trigger vocabularies, template-owned starter packs,
domain-specific seed items.

Collections
-----------
- Requisitions (REQ) — open roles, with status/team/level/location
- Candidates (CAND) — applicants, parent-linked to a Requisition
- Interview Loops (LOOP) — interview rounds, parent-linked to a Candidate
- Feedback (FB) — per-interviewer debriefs, parent-linked to a Loop
- Docs — rubrics, process notes
- Conventions + Playbooks — using hiring trigger vocabulary

Trigger vocabularies
--------------------
- HiringConventionTriggers: always, on-candidate-advance,
  on-loop-scheduled, on-feedback-submitted, on-offer-extended,
  on-close-requisition
- HiringPlaybookTriggers: on-candidate-advance, on-interview-scheduled,
  on-feedback-submitted, on-close-requisition, manual
- HiringConventionScopes / HiringPlaybookScopes: all, sourcing,
  screening, interviewing, offers

Starter pack
------------
- Conventions (3): PII handling (must/always), requisition linking
  (should/always), 24h debriefs (should/on-feedback-submitted)
- Playbooks (2): "Advance a Candidate" (on-candidate-advance),
  "Hiring Workspace Onboarding" (manual)
- Seed items: one example Requisition, one example Candidate (both
  labeled as seeded so users can delete or overwrite)

Tests
-----
- TestHiringTemplate — collections present, trigger vocabulary uses
  hiring values and does not leak software triggers (on-commit etc.)
- TestSeedCollectionsFromTemplateHiring — end-to-end: seeding
  creates all seven collections plus populates the starter pack

Parent: PLAN-609.

* fix(web): display hiring triggers on conventions + playbooks pages

Per Codex review P1 on PR #146. The conventions page hardcoded a
software-only TRIGGERS list in its grouping loop, silently hiding
any convention whose trigger wasn't in that list — so a hiring
workspace's seeded conventions (on-candidate-advance etc.) never
appeared in the primary management UI. Same issue on the playbooks
page's filter dropdown.

- conventions page: grouping now iterates the union of the hardcoded
  TRIGGERS (in original order) plus any triggers discovered in the
  data (sorted alphabetically). Unknown triggers fall back to a
  generic bell icon + the raw trigger string via a triggerMeta helper.
  byTrigger is now SvelteMap<string, Item[]>; the narrow Trigger type
  still gates the create form.
- playbooks page: the filter dropdowns for trigger and scope now
  expose the union of the hardcoded list and any distinct values
  found on loaded playbooks. Create form still uses the narrow list.

The broader "derive options from collection schema so the CREATE
forms also follow the workspace's trigger vocabulary" is tracked
as IDEA-619 for a follow-up PR.

* fix(templates): ship explicit prefixes for hiring collections

Per Codex P2 on PR #146. The seeded candidate's content referenced
--parent REQ-1, but the default DerivePrefix turns "Requisitions"
into "REQUI" (strips trailing S, caps at 5), so the example
wouldn't resolve. Similar problems for Candidates (CANDI) and
Feedback (FEEDB).

- Add optional Prefix string field to DefaultCollection so templates
  can override the derived prefix. Empty (the default) preserves
  today's auto-derivation for every existing template.
- Thread DefaultCollection.Prefix through SeedCollectionsFromTemplate
  to the CollectionCreate call — the CreateCollection API already
  supported a Prefix field.
- Hiring template sets explicit prefixes: Requisitions → REQ,
  Candidates → CAND, Interview Loops → LOOP, Feedback → FB. The
  seeded onboarding text's --parent REQ-1 reference now resolves.
- Test asserts the expected prefixes land on the created collections.

* fix: add 'offers' to hiring playbook scopes + tolerate custom scopes in web UI

Per Codex review iteration 3 on PR #146.

- HiringPlaybookScopes now includes 'offers', matching
  HiringConventionScopes. The hiring pipeline has a distinct offer
  stage and playbook workflows tied to offer management were
  previously uncovered in the schema.
- web/conventions page: the scope filter dropdown now exposes the
  union of SURFACES (original order) plus any scopes discovered on
  loaded conventions, via a new allSurfaces $derived. Same pattern
  as the earlier triggers fix. Non-software scopes (sourcing,
  screening, interviewing, offers) now show up for hiring
  workspaces instead of being hidden by the narrow hardcoded list.

The conventions create form still uses the hardcoded SURFACES — that
broader "derive from collection schema" fix is tracked as IDEA-619.

* fix(templates): hiring Feedback BoardGroupBy uses submitted, not recommendation

Per Codex review iteration 4 on PR #146. Feedback items have two
select fields: recommendation (strong-hire/hire/mixed/no-hire/
strong-no, no terminal values) and submitted (pending/submitted,
terminal=submitted). The done-state pipeline prefers
settings.board_group_by when it's a select field, so grouping on
recommendation made terminal detection fall back to checking
recommendation against default done-statuses — never matching,
leaving submitted feedback perpetually 'active' in active-count
views. Group on submitted so completion actually registers.

* fix(templates): Advance-a-Candidate playbook uses valid Feedback fields

Per Codex review iteration 5 on PR #146. The seeded playbook told
agents to create Feedback items with recommendation=pending, but
recommendation's allowed values are only the concrete verdicts
(strong-hire, hire, mixed, no-hire, strong-no) — 'pending' would be
rejected at field validation. Updated the step to use
submitted=pending (which IS in the allowed options) and call out
that recommendation should stay blank until the interviewer
actually records a verdict.
2026-04-18 05:38:33 -04:00
xarmian 115b33849e feat(templates): software starter pack + idempotent seeding (TASK-612) (#144)
* feat(templates): software starter pack + idempotent seeding (TASK-612)

Ship the software templates (startup, scrum, product) with a curated
starter pack of conventions + playbooks so new workspaces feel
"batteries included" rather than empty shells. The pack is a safe,
small subset drawn from the existing convention/playbook library —
the library itself remains the full catalog for interactive onboarding.

Starter pack contents
---------------------
Conventions (4):
- Conventional commit format (on-commit, should)
- Never push directly to main (on-commit, must)
- Run tests before completing tasks (on-task-complete, must)
- Review your own changes before PR (on-pr-create, should)

Playbooks (2):
- Implementation Workflow (on-implement)
- Code Review Process (on-review)

The pack is materialized by looking up library items by title and
converting them to SeedConvention / SeedPlaybook via json.Marshal of
the expected field shape. When the library's wording changes, the
template's seed content changes automatically.

Store-side changes
------------------
SeedCollectionsFromTemplate is now idempotent with respect to seed
items: items are only created in collections that were freshly
created during the current call (tracked via a freshlyCreated set).
That's the invariant that lets the server's startup auto-upgrade
safely re-run on every boot without duplicating items across every
workspace in the DB.

Empty template name preserves the old behavior (default collections,
no starter pack) — this keeps backward compatibility for callers that
don't pass a template, including the server-startup auto-upgrade path
and all existing server tests. Explicit "startup" / "scrum" / "product"
now gets the starter pack.

Tests
-----
- TestSoftwareStarterPacksPopulated — guards against library-title drift
- TestSoftwareTemplatesShipStarterPacks — each software template ships a pack
- TestSeedCollectionsFromTemplateSeedsStarterPack — end-to-end seeding works
- TestSeedCollectionsFromTemplateIdempotentWithSeedItems — re-seed doesn't duplicate

Parent: PLAN-609.

* fix(cli): default pad init to startup template when --template is omitted

Per Codex review on PR #144. Without this, `pad workspace init` without
`--template` no longer seeded the starter pack, even though startup is
documented as the default. The fix lives in ensureWorkspace (shared by
both init.go and the workspace creation command in main.go) — empty
flag is rewritten to "startup" there. Tests and other direct API
callers that want an empty workspace still pass Template="" through.

* fix(cloud): auto-create workspace passes startup template for starter pack

Per Codex review iteration 2 on PR #144. The auto-create cloud-signup
flow calls SeedCollectionsFromTemplate with an empty template, which
after this PR's semantics meant new cloud workspaces got no starter
conventions/playbooks. Pass "startup" explicitly to match the CLI
init behavior.

* fix(store): propagate collection lookup errors during seeding

Per Codex review iteration 3 on PR #144. seedItem previously treated
any error from GetCollectionBySlug as a silent no-op, which hid real
DB lookup failures — a transient error during workspace creation would
make seeding appear successful while conventions/playbooks were in
fact missing. Now we distinguish the two cases:

  - err != nil  → propagate so callers can detect partial init
  - coll == nil → benign (template references a slug not in its
                   collections list; template-author bug, no-op)

* fix(store): idempotent seeding by item title (partial-init recovery)

Per Codex review iteration 4 on PR #144. The previous design gated
item seeding on collections being freshly-created-in-this-call, which
trapped partially-initialized workspaces: if a DB error fired between
collection creation and item seeding, a retry would see the
collections already existed and skip every remaining seed item.

Switch to title-based idempotency. Before inserting a seed item we
list the target collection's existing items (once per collection, via
a small cache) and skip any whose title already exists. That makes
seeding:

- Idempotent: re-running a template doesn't duplicate items
- Recoverable: retrying fills in missing items after partial init
- Retry-safe: the auto-upgrade path can re-run safely on every boot

New test TestSeedCollectionsFromTemplateRecoversPartialInit exercises
the recovery path explicitly.
2026-04-18 01:22:08 -04:00