Commit Graph

1724 Commits

Author SHA1 Message Date
xarmian 987fc79fde feat(server): refuse unresolvable relation values at the four write doors (TASK-2878)
PLAN-2857 U1, second slice: the doors that take CALLER-SUPPLIED field
values now refuse a relation value that does not name a live item in the
declared target collection — create, update (full fields), update
(fields_patch), and bulk update.

The server half adds the one thing the store resolver deliberately does
not: visibility. It folds into the SAME `not_found` reason rather than
getting its own, because "that item exists but you may not see it" is an
existence oracle, and this codebase has a standing rule against handing
one out.

Ordering at every door is after the shape check and after coercion, so one
bad value produces one error rather than two describing it differently,
and so the value is in its final form when it is resolved.

`fields_patch` examines only the keys the patch carries — the resolver
skips absent keys — so an unresolvable value already stored on an item is
not re-litigated by an update that does not touch it. That mirrors the
undeclared-key rule immediately above it, and it is what stops this
turning every edit of a legacy item into a failure.

Refusals use the ORDINARY `validation_error` shape with no new details
key. The MCP stdio transport classifies errors by matching CLI stderr
prose, so a structured field it cannot see would help nobody there, and a
new error shape is a contract change for every client.

Existing suites unchanged: internal/server ok (224.5s), internal/store ok
(258.0s), internal/items ok. Nothing in the tree was writing a bogus
relation value through these doors, which is what made this slice safe to
land before the per-door pins.
2026-09-04 15:40:55 +00:00
xarmian 977132387d feat(store): referent resolution for relation values (TASK-2878)
PLAN-2857 U1, first slice: the rule itself, with no door wired to it yet.

`ResolveRelationReferents` canonicalises every `relation` value in a field
map to the target item's ID and reports the ones that cannot be resolved —
same workspace, and the collection the field DECLARES.

WHERE IT LIVES was forced, not chosen. `internal/items` is DB-free by
construction and keeps the shape check only. `internal/server` cannot own
it either: six of the eight coercion doors live there, but the eighth is
`store.migrateFieldsForCopy`, and `store` does not import `server`. Putting
it here is what lets the cross-workspace copy door and the preflight door
reach the SAME function instead of two implementations of one rule — those
two already carry a comment saying they sit in different packages and that
is how they drift unnoticed.

VISIBILITY IS NOT HERE, deliberately. "Can this requester see that item" is
request-scoped and needs the user, role and auth mode; the server layer
adds it via `checkItemVisible`, which already exists as the context-free
predicate for exactly this reason.

NO SLUG FALLBACK, which is a deliberate divergence from `ResolveItem`
(UUID, then ref, then slug). Found by a test failing rather than by
reading: "red" resolved, because it is the slug of the live Red colour. A
relation field's contract is that it stores an item ID; a slug is neither
an ID nor stable, so the same stored value could point elsewhere tomorrow.
Worse, "red" is exactly the free-text value the pre-U2 editor wrote into
these fields, so accepting it makes the corruption this unit exists to
stop indistinguishable from a legitimate write. The client refuses the
same match for the same reason (TASK-2868). Exact-TITLE resolution is U6.

Issues are reported in SCHEMA order, not map order, because the copy
preflight is one of the callers and is specified to be safe to call
repeatedly and return identical results.

Unresolvable values are left EXACTLY as supplied: the caller quotes them
back, and a half-canonicalised map would make a drop report lie about what
the source held.

Verified rather than asserted: both lookups exclude soft-deleted rows
(`ResolveItem` by contrast with `ResolveItemIncludeDeleted`; `GetItem` via
`getItemScanQ`, which appends `AND i.deleted_at IS NULL`). That is what
keeps "target was deleted" distinguishable from "never resolved" — the
read half U2 shipped.
2026-09-04 15:40:55 +00:00
xarmian b437cc582d feat: item reminders — the fire-at-an-instant primitive, and one overdue rule for all four surfaces (IDEA-2641, closes #1010) (#1244)
* feat(store): item reminders — the fire-at-an-instant primitive (IDEA-2641)

Adds the storage, the scheduler tick, and the canonical event for one-shot
item reminders (GitHub #1010). Nothing in Pad acted at a target time before
this: a due_date makes an item show up as overdue once somebody asks the
dashboard, so "revisit TASK-X on the 1st" had to live in an external cron.

A TABLE, NOT A SCHEMA-FIELD ANNOTATION. The design sketch proposed marking
schema date fields with a `reminds: true` key on models.FieldDef; recon
overturned it. Such a key does not survive an ordinary collection edit, two
independent ways: the web editor destructures each field into an EditableField
and rebuilds a fresh definition key-by-key on save, so unknown keys are
dropped (`pattern` and `unique_scope` survive only because two lines were
hand-added for them), and models.CollectionSchema has fixed fields with no
catch-all, so any Go unmarshal+marshal round-trip strips unknown properties —
the hazard retargetRelationFieldsTx mutates raw JSON to avoid. Both failures
are silent and both disarm a whole collection's reminders at once. It is the
same defect class that moved traits out of the schema column in TASK-2657.

The table also gives the lifecycle a home. A reminder is armed, then fired,
then acknowledged, and a re-arm returns it to armed — per-reminder state a
field definition has nowhere to keep.

remind_at is an RFC3339 UTC instant, deliberately not a `date` schema value:
those admit both YYYY-MM-DD and full RFC3339 and are compared against the
SERVER'S LOCAL calendar day. A fire-at time cannot carry that ambiguity. The
remaining timezone question for due_date is filed separately.

Firing is one transaction per reminder carrying BOTH the fired_at write and
the outbox insert. That pairing is the point: a fired_at committed without its
event is a reminder that silently notifies nobody and can never be retried,
because the row has left the armed set; an event without fired_at fires every
tick forever. The UPDATE's own `fired_at IS NULL` predicate is the arbiter, so
two instances ticking at once produce exactly one winner.

item.reminder_due is admitted to the closed events/1 set as v1.2, with a new
PayloadReminder family and no SSE name. The subject is the REMINDER, not the
item: two reminders can be armed on one item, so an item-subject event could
not say which fired, and the reminder id is what an acknowledgement addresses.
A new payload family rather than reusing the item snapshot for the same
reason — a snapshot would validate and still not answer the only question the
event exists to answer. No SSE name in v1 because the poll surface is the
contract; adding one later is additive, removing one is not.

Ack is explicit and nothing else acks. An item reaching a terminal status
deliberately does NOT ack: that would make every status write a reminder
mutation, and it would silently consume a reminder set to fire after the work
was done.

* feat(server): reminder surfaces, and one shared overdue rule for all four

Second half of IDEA-2641: the HTTP surface, the scheduler tick's wiring, and
the fix for the finding that justified the unit — `ready` / `next` did no date
handling at all.

OVERDUE NOW HAS ONE IMPLEMENTATION. It used to live inline in the dashboard's
attention loop, which meant `pad project stale` inherited it (it filters that
very list) and the recommendation surface never saw it. So a deadline reached
the two surfaces that REPORT on work and never the one an agent PULLS from.
overdue.go is now the only place that decides, and all four call it.

Two behaviour changes fall out, both deliberate:

  - An overdue item bypasses the orphan branch's high/critical priority gate.
    That gate was where a deadline quietly stopped: a low-priority item three
    weeks late was reported by `stale` and never suggested by `next`.

  - Overdue sorts above in-progress. The list is capped at three, so a rank
    below in-progress would not merely order the deadline lower — on any
    workspace with three things in flight it would keep an overdue item off
    the surface entirely, which is indistinguishable from not shipping this.

The server-local-today comparison is UNCHANGED and known to be wrong for
multi-timezone deployments; it is filed as its own item with the cloud case
stated. Changing what "overdue" means on every existing instance inside a
change about where the rule LIVES is the kind of behaviour change nobody
reviews.

Fired reminders reach `next` / `ready` two ways, from one filtered list:
PendingReminders is the addressable form (it carries the id an ack needs), and
a prepended suggestion is the rendered form. They are prepended AFTER the cap
rather than entered as ranking candidates — a reminder is not a task competing
on priority, and whether it appeared should not depend on how busy the
workspace is.

Terminal-item reminders are FILTERED from the surface, never acked. Acking on
terminal status would couple every status write to reminder state and would
consume a reminder armed to fire after the work was done. The row stays
exactly as the user left it; the distinction is observable, and asserted.

Three guard tests caught this change and each was answered rather than
silenced:

  - The request-body reader guard was right: the handlers now go through
    decodeJSON, inheriting the NUL refusal and the size cap.

  - The canonical-events guard was right: item.reminder_due is admitted to the
    duplicated contract table as SPEC-3 v1.7, with the reminder subject kind
    and the new payload family. SPEC-3's own text owes the same amendment.

  - The NUL census asked for a decision on eight new columns. None carries
    caller text: ids and FKs are server-generated, four are the server clock,
    and remind_at is now re-parsed and re-formatted in the STORE as well as at
    the edge — so the stored value is always machine-produced from a parsed
    time and no caller bytes reach the column. The doc comment that used to
    say "the caller normalizes" protected nothing.

    Regenerating the baseline also found that GEN_NUL_BASELINE=1, which the
    test's own instructions name, was never implemented — the flag did
    nothing, so the documented path was hand-editing the file. Implemented, so
    the next reader gets the mechanism the instructions promise.

* test(reminders): the lifecycle, the four surfaces, and 22 killed mutants

Every test here was designed against a specific mutation and the mutation was
RUN. A green suite proves nothing about a suite nobody tried to break, and
three of the mutants I first wrote were not experiments at all.

Store (10 mutants, all killed): candidate predicate <= flipped to >=; the
event emission lifted out of the fire transaction; the fire UPDATE's
`fired_at IS NULL` arbiter removed; the RowsAffected check ignored; re-arm
clearing fired_at but not acked_at; ack losing `fired_at IS NOT NULL`; the
poll surface losing `acked_at IS NULL`; normalizeRemindAt no longer refusing;
it dropping .UTC(); GetReminder losing its workspace scope.

Surfaces (12, all killed): the priority gate no longer bypassing on overdue;
the sort no longer ranking overdue first; attention leaving the shared helper;
the reason losing its OVERDUE prefix; the comparison flipped to >; terminal
items no longer skipped; terminal reminders no longer filtered; the filter
ACKING instead of hiding; reminders appended instead of prepended; the tick
running on a far-future clock; ack answering 200 for an unfired reminder;
parseRemindAt accepting a bare date.

THREE MUTANTS DID NOT COUNT ON THE FIRST PASS and were rewritten. Two failed
to compile (`if false` orphaned a variable; deleting a parse orphaned an
import) and one had an anchor matching two call sites. A non-compiling mutant
emits zero FAIL lines and reads exactly like a surviving one — it invents a
hole that is not there — so the harness reports BUILD-FAIL and ANCHOR-BAD as
outcomes distinct from SURVIVED. It also restores files from an in-memory copy
rather than `git checkout`, which would delete uncommitted work in the tree.

ONE MUTANT GENUINELY SURVIVED and the test was at fault, not the mutant:
appending rather than prepending reminder suggestions was undetectable because
the fixture had a single item, so the reminder sat at index 0 either way. The
fixture now fills the three-item cap with in-progress work, where an appended
reminder lands fourth and vanishes. Faithful mutant, weak test — checked in
that order.

The same lesson shapes the four-surface fixture: it is a LOW-priority open
orphan, because that is the case the old code handled worst. A high-priority
task would have made the ready/next leg pass against the unfixed tree, which
is a green that measures nothing.

Negative controls throughout: a future deadline is not overdue and does not
reach the gate bypass; a tick with nothing due fires nothing; a completed item
is neither overdue nor suggested. Without them a helper that reported every
date, or a tick that fired everything, would satisfy every positive leg.

The lead's pin is asserted in both directions: a fired reminder on a done item
is ABSENT from the surface and PRESENT and still unacknowledged in the table.
Asserting only the absence would pass against an implementation that consumed
the row, which is the behaviour the pin exists to forbid.

* feat(mcp): pad_item.remind + ack-reminder, ToolSurfaceVersion 0.28

An agent that can RECEIVE a reminder but not set one has half the primitive.
The poll surface is pad_project.next / ready, both long exposed, so reminders
already reached agents — what was missing is the other half: deferring a piece
of work is exactly the moment an agent knows when it wants to be asked again,
and it had no way to say so.

Two additive actions, two optional params. Nothing existing moved, so a v0.27
consumer enumerating neither is unaffected — the v0.13 / v0.11 / v0.8
disposition, which likewise wired existing CLI verbs onto the catalog.

remind_at REFUSES a bare date rather than reading it as midnight. Worth
stating because the `date` schema type accepts YYYY-MM-DD and a caller will
reasonably try it here: a bare date names a 24-hour span, and choosing an hour
inside it would fire at a time nobody picked.

Re-arm and disarm stay CLI-only. Both address a reminder by an id the agent
would have to list first, and no listing action exists on this surface — a
door with no handle. Adding them later is additive.

Five guards had to be taught, and each was answered on its merits rather than
excluded: the HTTP parity test (route mappers added, so the actions work on
the remote transport rather than being advertised and unrouted), the
read-only catalog's cmdhelp fixture and expected cmdPath map, the field-
conflict classifier (remind_at / reminder_id are NOT field writers — a
reminder is a row in its own table addressed by its own id, so listing them
as classified sources would have pointed detectFieldConflicts at something
that is not a field source), and the instructions.md / README action tables.

That machinery is why the version bump is safe to make now, and it earned its
keep on this change: every one of the five failed on the first build after the
catalog entry landed.

CONVE-23 sweep for prose this falsifies:

  - SPEC-3 (DOC-2653) amended to v1.7 in the room, recording item.reminder_due
    with its new subject kind and payload family — the first canonical event
    with no user mutation behind it, since a scheduler tick produces it.
  - CLAUDE.md gains the reminder routes, the CLI verbs, and the v0.28 entry.
    It was also stale at 0.26 with NO v0.27 entry at all: the 0.27 unit swept
    instructions.md and README.md and missed this file. Both added.
  - skills/pad/SKILL.md gains the verbs and a routing entry, including the two
    things an agent will get wrong — the time is an instant, so ask for a time
    of day rather than picking one, and finishing the item does not
    acknowledge the reminder.

* fix(reminders): codex round 1 — four findings, all real, all with a pin

Round 1 found four defects and refuted none of them. Each fix carries a test
that fails against the code as it was, and each of those was mutation-checked.

**P1 — pending reminders bypassed item-level visibility.** Every other
dashboard section reads `allItems`, which the store already scoped to the
caller's collections AND their granted item ids. The pending-reminder list is
a direct workspace-wide query and inherited none of that, so a guest holding a
grant on ONE item could read the refs and titles of every other item in the
collection through its reminders — an item-level leak wearing a
notification's clothes. Now filtered with the same `isItemVisibleToGuest` call
the sibling sections use. The test's two items share a COLLECTION on purpose:
a collection-level filter was already applied, so separate collections would
have made it pass against the unfixed code.

**P1 — soft-deleted items could starve the queue permanently.** Candidate
selection ignored `deleted_at`, and `fireOneReminder` rolls back when it finds
the item gone — which leaves the reminder ARMED and therefore a candidate
again on the next pass. Candidates are ordered oldest-first and bounded by a
limit, so enough archived reminders fill every batch and no live reminder ever
fires. Silent, too: the tick reports zero fired and looks idle. Excluded in
the candidate query rather than skipped downstream, so those rows never occupy
a slot; the reminders themselves are kept, so restoring an item restores its
reminder with it — asserted, because a fix that reaped them would pass the
starvation test alone.

**P2 — the pass stopped at the first failing reminder.** The per-reminder
transaction exists precisely so one unfireable row cannot hold back the rest,
and `return fired, err` made that comment false — with candidates
oldest-first, one persistently broken old reminder blocks every newer one
forever. Now continues and joins the errors, so a pass that fired seven and
failed three reports both halves rather than reading as clean. The loop is
split behind an injected seam because a real mid-transaction failure is not
reachable from outside: the database refuses the corrupt rows that would cause
one (verified — invalid JSON in items.fields is rejected by the schema).

**P2 — suggestions dropped the reminder id.** The docs tell an agent to
acknowledge what it sees in next/ready, and the payload carried no handle: a
stateless poller could read the reminder and had no way to retire it, so it
would be shown the same item forever. `DashboardSuggestion` now carries
`reminder_id` (omitempty), `pad project next` prints the exact ack command,
and the test acks with the id the surface handed out rather than merely
checking the field is populated — a wrong-but-present id satisfies equality
with itself.

Four mutants, four killed; one was rewritten first because its anchor matched
two call sites and was therefore not an experiment.

* fix(reminders): codex round 2 — four findings, all real

**`--rearm` was unusable.** `ExactArgs(1)` forced an item ref that the rearm
branch then ignored, so the flag could not be reached without supplying a ref
that was silently discarded. Now `MaximumNArgs(1)`, with each mode checked
explicitly: a ref is required to arm, and a ref supplied ALONGSIDE `--rearm`
is refused rather than ignored — it names an item the reminder may not even
belong to, and quietly dropping it is how a user learns nothing about the
reminder they just moved.

**`unremind --format json` emitted plain text**, breaking the parseable-output
contract every sibling command honours.

**The MCP `ref` param did not list `remind`.** Agents read that flat
description to decide what to send, so an action missing from it is an
invalid call waiting to happen. It now also says what `ack-reminder` takes
instead, and why: a reminder is addressed by its own id because an item can
carry several.

**Fractional seconds fired early.** `time.Parse` accepts `09:00:00.900Z` and
`Format(RFC3339)` drops the fraction, so it was stored as `09:00:00Z` and
fired 900ms BEFORE the moment the caller named — silently, having rewritten
their value on the way in. Seconds are genuinely the stored resolution (the
column is compared as a string against a whole-second clock, and the tick runs
every 30s), so the only question was which way to resolve it, and truncation
resolved it the wrong way. `NormalizeInstant` now rounds UP: at most a second
of lateness, in exchange for a guarantee that can be stated — a reminder never
fires before the instant it was set for. Late is a reminder; early is a wrong
answer. Whole seconds round-trip exactly, which is asserted, because an
implementation that added a second unconditionally would otherwise pass.

Three mutants for this round, three killed (round-up→truncate,
round-up→unconditional-add, MaximumNArgs→ExactArgs). Thirty across the unit.

Two fixes carry no dedicated test and it is worth being explicit rather than
implying coverage: the `--format json` branch on `unremind` is a one-line
output change with no server-free way to drive it, and the MCP `ref`
description is prose the drift tests do not read — they assert an action is
DOCUMENTED, not that a param's sentence lists it.

* docs(reminders): the ack id is on the surface an agent polls, not only on the arm response

CONVE-23 follow-through on the round-1 fix. Both agent-facing docs told a
caller to acknowledge a reminder with the id "returned when you armed it" —
true, and useless to the caller that matters: a poller reading next/ready
never armed anything. The suggestion now carries reminder_id and `pad project
next` prints the exact ack command, so the docs say that instead.

The prose was written before the fix existed, which is exactly the case
CONVE-23 is about: a change that makes an instruction stale without touching
the file the instruction lives in.

* test(reminders): bind the tick LOOP to the work, not just the pass (CONVE-19)

Every other test in this file calls runReminderTick directly. That vouches for
the component and says nothing about whether anything ever calls it — a tick
that is never started is indistinguishable, from those tests, from one that
is. It is the convention's exact case, and the failure I recorded on my own
identity doc three times in one unit: I test the component and not the
binding.

Driven through the injectable tick channel so the assertion pins a SPECIFIC
pass instead of racing a 30-second ticker, and polled to a bounded deadline so
a loop that never runs FAILS rather than hanging the suite.

Mutant: drop `s.runReminderTick()` from the select and this goes red while
every direct-call test stays green. Killed.

The idempotence leg exists because a second Start spawning a second loop would
leave one running after Stop, making the BUG-842 drain invariant false for
this sweeper specifically — the one property a copied lifecycle is most likely
to get right by accident and least likely to be checked.

The cmd/pad call site (cmd_server.go, alongside StartTokenReaper) stays
verified by inspection: a source-scanning guard for it would be an instrument
asserting facts about source, which is code with an adversary and not worth it
for one line that sits in the middle of five identical neighbours.

* fix(reminders): codex round 3 — a deferred reminder fired anyway, and the poll surface was unbounded

**A re-arm mid-pass did not stop the fire.** The candidate scan selects an id;
before the UPDATE runs, a `--rearm` can move that reminder into the future.
Re-arm clears `fired_at`, so a predicate checking only `fired_at IS NULL`
still matched — the pass fired a reminder the user had just deferred and
emitted its event. The re-arm cannot undo that: it can clear the mark, but the
event is already on the outbox and at-least-once means a consumer has seen it.

The fire UPDATE now revalidates `remind_at <= nowTS` against the SAME nowTS the
candidate scan used. Same-value deliberately: the arbiter and the scan must
agree about when this pass is, or a reminder could pass one and fail the other
for no reason but clock drift inside a single pass.

**The poll surface was unbounded.** Every fired-and-unacknowledged reminder was
loaded and turned into a suggestion prepended to a list that is otherwise
capped at three, so a workspace with five hundred unacknowledged reminders
returned five hundred suggestions — in the dashboard response, the hottest read
in the product, growing until somebody acknowledged them.

Two bounds, because they are two different guarantees: the query takes a window
(default 50, oldest-fired first, so it holds what has waited longest), and the
prepended suggestions are capped at 5 so `suggested_next` stays a
recommendation rather than a second inbox. The full set stays addressable in
`pending_reminders`.

Truncation is REPORTED as a boolean, not a count. A count would have to be
post-visibility-filter to be true for the caller reading it, and the store
cannot compute that — the filter runs per item, above. "There are more than you
can see here" is the strongest claim the data supports, so it is the one made.

Four mutants; two killed outright, two survived and were run down under
CONVE-28:

- **Uncapped suggestions survived because the fixture had ONE reminder** —
  capped and uncapped are the same list at n=1. That is the SECOND time a
  single-item fixture hid a count-or-order property in this file. Fixture now
  arms eight; it also asserts all eight remain in `pending_reminders`, so the
  cap is pinned to the recommendation and not to the data.

- **Removing the SQL LIMIT survived, correctly, and the test comment now says
  so.** The Go slice cap bounds the PAYLOAD; the SQL LIMIT bounds the
  DATABASE'S work. Only the first is observable at this level — with the LIMIT
  gone the response is still bounded, while the query silently goes back to
  materialising every pending row before discarding most of them. That is a
  memory and I/O property with no assertion available here, so it is stated as
  a coverage boundary rather than papered over with a green that would not have
  measured it.

* docs(reminders): the fire predicate arbitrates against two actors, not one

CONVE-23 inside the file the round-3 fix touched. The comment described the
UPDATE as an arbiter for concurrent TICKS, which is what it was written for
and is why I did not re-read it when asked whether a user edit could race the
pass. It now says what it actually defends against, and names the general
shape: an arbiter is only an arbiter with respect to the writers it can see.

* fix(reminders): codex round 4 — the round-3 bound recreated the round-1 starvation

Round 3 bounded the poll surface. Round 4 caught what that bound did: the
query took the first N rows and the dashboard then discarded the ones it could
not show — hidden items, unauthorised items, completed items — so N such rows
hide a visible reminder behind them indefinitely, with no continuation to
reach it.

That is the SAME defect I had removed from the fire path one round earlier,
reintroduced in the read path within the hour. The general form is worth
stating because I clearly did not hold it: **a bounded window is only safe
when the discarding happens BEFORE the bound.** Filtering above a limit is a
starvation every time, and it does not matter what the filter is for.

Two halves, because the two filters are not the same kind of thing:

**Visibility is now scoped IN SQL**, using the same collection-id / item-id
sets every other dashboard section gets through `allItems` — the same
three-way shape as ItemListParams, where holding both collection grants and
item grants is an OR. Invisible rows no longer occupy the window at all, which
is strictly better than filtering them out afterwards and is what the sibling
sections have always done.

**Terminality is paged**, because SQL cannot evaluate it — a collection's
schema defines which statuses are terminal. The collector refills from the
next page when a page comes back short, bounded by a max scan so a workspace
full of completed items cannot turn a dashboard read into a table scan. The
bound is 10x the window: the common shape fills on the first page, and the
pathological shape terminates in a fixed number of indexed reads. Stopping at
the scan bound reports truncation, which is honest — there may be more, and we
did not look.

The empty-scope case is a THIRD state that reads like the second: nil
CollectionIDs means unrestricted, a non-nil EMPTY slice means this caller sees
no collections. Without an explicit guard they collapse, because the switch
matches none of its cases at length zero and adds no clause at all — so
"nothing visible" would return the whole workspace.

Three mutants, one survived: the empty-scope guard, because no dashboard-level
test produces that state (callers that would are refused earlier by workspace
access). Faithful mutant, missing test — it now has a direct one, with a
sanity leg so a build returning nothing cannot pass it by accident. A guard for
a state nothing exercises is exactly the one that rots.

* fix(reminders): codex round 5 — the MCP action I shipped did not work over stdio

**P1: local stdio MCP `remind` was unusable.** cmdhelp derives positionals by
regex from a command's `Use` string, and `<instant>` inside
`remind <ref> --remind-at <instant>` matched — it became a second REQUIRED
positional, so dispatch failed with `missing required argument "instant"`. The
action was advertised on a transport where it could not run.

**The MCP catalog's own tests did not catch it, and the reason is the finding.**
That suite builds its cmdhelp document BY HAND: I wrote `Args: mkArgs("ref")`
in it, so the fixture agreed with what I meant rather than with what the CLI
says. Five parity and drift tests passed against a document I authored to
match my own intention — the "a test that agrees with whatever the table says
is not a test of the table" shape, which the canonical-events test warns about
in its own comment two packages away. The new test reads the REAL command tree
via cmdhelp.Build, which is the only thing in this repo that can disagree with
me about what the CLI declares.

**P2: `pad project ready` withheld the ack handle** that `next` prints.
Showing a fired reminder on the surface an agent polls while withholding the
id it needs to retire it means the same entry comes back on every poll,
forever.

**P2: suggestions asserted a collection they did not have.** The orphan branch
admits ANY collection — its own comment claimed it gated on tasks "mirroring
the active-plan branch", and that comment was simply false — while the output
hardcoded `Collection: "tasks"` and the reason said "Open task". Pre-existing
for high-priority items since BUG-1082; my overdue bypass widened it to any
overdue item, which is how it surfaced.

Fixed by carrying the item's REAL collection rather than by narrowing the
branch: narrowing would silently drop the non-task items this has surfaced for
a year, and the defect is the mislabelling, not the inclusion. The false
comment is replaced with what the code actually does.

The first version of that test used an overdue IDEA and SKIPPED — ideas use
`new`, and the branch requires `open` or an active status, so it never became
a candidate. A test that cannot fire is a failed reconstruction, not a pass;
the fixture is now a bug-like collection whose vocabulary contains `open`,
which is the population the defect can actually reach.

Three mutants, three killed. Forty-one across the unit.

* fix(reminders): codex round 6 — reminders fired from soft-deleted workspaces

**P1, and the only defect in this unit whose consequence leaves the process.**
Workspace soft-delete deliberately keeps items for the 30-day restore window,
so the candidate query's filter on the ITEM's deleted_at found nothing wrong —
and the tick kept firing, emitting outbound webhook events for a workspace
whose owner had deleted it, possibly while deleting their account.

Both queries now join workspaces and require `w.deleted_at IS NULL`. Nothing
is destroyed: a restored workspace resumes firing, which the test asserts,
because "stops firing" and "is destroyed" are very different answers to
someone who restores a workspace and only one of them is right.

That test first failed for the WRONG REASON and the fixture was at fault: it
counted every outbox row in the workspace, and item creation writes its own,
so the assertion was satisfiable by the fixture itself and discriminated
nothing. Scoped to the reminder event type.

**`Use: "remind <ref>"` declared a requirement the command contradicts.**
cmdhelp derives the machine-readable arg spec from that string, and `--rearm`
takes no ref — so the published contract said "required" for something
optional. The requirement is CONDITIONAL, which cmdhelp cannot express, so the
honest declaration is `[ref]` plus the explicit check that names both call
shapes. The round-5 test grew a `required` column, which is what makes this
observable at all: asserting only the arg NAMES would have passed.

**The pad_item tool description omitted both new actions.** The params were
declared and the actions dispatched, but the prose an agent reads to decide
what a tool can do did not mention them — discoverable only by someone who
already knew to look. It now describes both, including the two things an agent
gets wrong: remind_at is an instant, and nothing but an explicit ack retires a
fired reminder.

Three mutants, three killed. Forty-four across the unit.

* fix(reminders): codex round 7 — one predicate for the scan and the arbiter

Third instance of one class, so this fixes the SHAPE rather than the instance.

The class: the candidate scan filters on something the fire transaction does
not revalidate, so a change committed between them fires a reminder that no
longer qualifies. Round 3 was a re-armed instant. Round 1's soft-deleted item
was the same thing caught from the other side. Round 7 is a workspace deleted
between the scan and the fire — the round-6 fix added the condition to the
SCAN only, and the arbiter went on not knowing about it.

Fixing those one at a time is what let the third happen. `reminderFireable` is
now a single string that both sites reference: the scan asks it and the fire
UPDATE re-asks it, so they cannot disagree, and a fourth condition is one edit
in one place rather than two edits someone has to remember are paired.

Written as a correlated EXISTS on item_reminders.item_id rather than a JOIN
precisely so the identical text is valid in both a SELECT and an UPDATE, and
the scan drops its table alias so the two uses are the same characters.

What deliberately stays outside it: `fired_at IS NULL` and `remind_at <= ?`
live on the reminder row itself, are already spelled identically at both
sites, and folding them in would need a parameter order the shared form cannot
express. Said in the comment so the omission reads as a decision.

Both directions are now tested at the arbiter — a workspace deleted mid-pass
and an item deleted mid-pass — because the item case previously relied on the
item load coming back nil, and someone simplifying the EXISTS down to the
workspace check alone would otherwise still see green.

Three mutants, three killed: the arbiter dropping the shared predicate, and
the predicate dropping each of its two halves. Forty-seven across the unit.

* fix(reminders): codex round 8 — workspace export silently dropped every reminder

WorkspaceExport is a hand-maintained field list, so a new table joins it only
if someone remembers. Reminders did not: a backup/restore, or a
SQLite→Postgres migration via `pad db migrate-to-pg`, dropped every pending
reminder with nothing in the destination to show anything had gone.

The line that list has always drawn is item-scoped workspace CONTENT
(comments, links, versions — exported) versus per-user state (stars, watches —
not). A reminder has no user column and hangs off an item, which puts it on
the exported side. Stating the rule rather than just adding the field, because
the next person adding a table needs to know which side they are on.

LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged reminder is
still owed to whoever armed it, so it arrives pending; an armed one whose
instant has passed fires once on the destination's first tick, which is what
would have happened had the workspace never moved. Re-arming everything on
import would invent a schedule the user did not set. NULL rather than empty
string for the unset marks — the lifecycle is defined by NULL-ness, and ""
would make a never-fired reminder read as fired at "".

TestMigratedTablesCoversTheExport caught the second half, which I would have
missed: `pad db migrate-to-pg`'s NUL preflight decides what to REFUSE on from
MigratedTables, so a table the migration copies and the preflight does not
know about is a gap in exactly the guard that exists to prevent one. Added
there too, with the reason it can never actually fire — every column is
machine-produced, so it is listed for coverage rather than expectation — and
the "six tables" prose it falsified is now seven.

Two mutants, two killed: export dropping the block, and import discarding the
marks. Forty-nine across the unit.

* test(reminders): state the fire-path invariant and pin it from the invariant

The lead's read on why rounds 4 and 7 were the same class: the fire path had
no stated invariant, so each fix defended an instance. This states it, and
derives the pin from the paragraph rather than from the bug history.

THE INVARIANT: the candidate scan is a hint and may be assumed to prove
nothing. Every condition that made a row a candidate is re-asserted inside the
transaction that marks it fired, in the same statement that does the marking,
so checking and writing are one atomic act.

Worded as "the scan proves nothing" rather than as a list on purpose — a list
invites the next person to add a condition to the scan and stop, which is
exactly what happened four times here.

TestFirePathInvariant is the pin: one table, one row per scan-side condition,
each invalidating that condition in the window between the scan and the fire
and asserting the same three things — nothing fires, no event leaves, the
reminder is not consumed. The earlier per-defect tests are folded in as rows;
they said the same thing one instance at a time, which is how four of these
shipped. Adding a fifth condition to the scan without a row here should feel
like an omission. It carries a positive control, because four cases that all
assert nothing happens would pass against a build that never fires at all.

The matrix immediately falsified a claim in the paragraph I had just written.
I wrote that the item load inside the transaction is "for the payload, not for
the check"; removing the item half of reminderFireable alone changes no
observable behaviour, because the load then returns nil and the deferred
rollback undoes the write. Item liveness is defended TWICE and a single-mutant
experiment cannot say which guard is carrying it — removing both is what kills
the test. Both are kept, the predicate is named as primary (the row never
matches, so no write happens at all), and the asymmetry is stated: workspace
liveness has no second line, which is why dropping ITS half does fail the pin.

Six mutants: five singles plus the pair. Five killed alone; the item single
survives by design and is documented as such rather than left as an unexplained
green. Fifty-five across the unit.

* fix(reminders): codex round 9 — one legacy row could hide every reminder

**P1: items.item_number is NULLABLE and I scanned it into an int.** Migration
006 added the column to existing rows, so a pre-numbering item still carries
NULL — and scanning NULL into an int fails the Scan, which fails the QUERY,
which degrades the whole pending-reminder section. One old row, and the
feature is dark for everyone in that workspace.

ListWatchesForUser, which this query was modelled on, uses sql.NullInt64 for
exactly this column. I copied its shape and dropped the part that handles the
column's actual nullability — the same way of being wrong as the round-5
cmdhelp fixture: borrowing a form without borrowing what it knows. The legacy
row now carries no ref rather than a fabricated "PREFIX-0", which would name a
different item.

**P1: export shipped reminders that import could only discard.** The items
section filters on deleted_at IS NULL, so a soft-deleted item is not in the
bundle and its reminder can never be reunited with it. My comment claimed the
item_links rationale — round-trip the raw graph so a restore reunites them —
which is true for links and false here, because links keep soft-deleted
endpoints in the bundle and items do not. A link is a row ABOUT two items; a
reminder whose item is absent is a dangling schedule.

**P2: import wrote remind_at raw.** Import is a writer, and a bundle is not
necessarily one this server produced — hand-edited, or from another instance.
A local offset or a bare date would land in the one column every comparison
downstream treats as a UTC instant, firing early, late, or never. It now
normalizes like every other door. An unparseable value is SKIPPED with a
warning rather than failing the restore, matching the lenient import-side
precedent already in this file, and the raw value's LENGTH is logged rather
than its content.

Three mutants, three killed; two needed rewriting because the single-line form
did not compile — reverting the nullable scan also requires reverting the
render, and dropping the normalization orphans a variable.

PROCESS FAULT, recorded because it makes this round's findings weaker than
they look: I edited the tree while this review was reading it — committed the
invariant work and ran five mutation experiments, which write and restore
source, over the same files. A review binds to the tree it read and I moved it
underneath. Every finding above was re-verified against the current tree
before being acted on, and the next round runs with no concurrent edits.

* fix(reminders): codex round 10 — one orphaned item aborted a whole restore

An ORPHANED item — one whose collection is missing from the bundle — still
gets an itemMap entry. It has to: the entry is written before the skip because
parent resolution inside the same loop reads the map for items it has not
reached yet. So `itemMap[x] != ""` is satisfied by an id that names no row,
and inserting a foreign key to it fails (SQLite enforces FKs here via the
DSN's `_pragma=foreign_keys(on)`; Postgres always does).

The pre-existing mapping is the sharp edge. The aggravating half was mine:
this loop treated a failed reminder insert as FATAL, where item_links and
item_versions both skip, so one orphaned item carrying a reminder rolled back
an entire 900-item workspace restore. A reminder is the least critical thing
in a bundle and it had the strictest failure handling in the file.

Both halves fixed: the loop gates on items that actually landed, and a failed
insert warns and skips like its siblings.

TWO GUARDS THAT ONLY DIE TOGETHER, and this is measured rather than assumed.
Reverting either alone leaves the test green — with the map gate restored the
skip survives the FK failure, and with the fatal return restored the gate
means the insert never fails. Removing both is what fails it. They are kept as
a pair because they defend the same failure at different depths (prevent the
bad write / survive a bad write arriving some other way), and the pair is
recorded in the code so a future reader does not delete one as dead after
watching its mutant survive. Second time this shape appeared today; the first
was item liveness on the fire path.

The bundle in the test is hand-built, because ExportWorkspace cannot produce
an orphan — which is the reason it needed a test. That shape only arrives from
a hand-edited or foreign bundle, and surviving those is what import is for.

Three mutants: two singles that survive by design, plus the pair that kills.
Sixty-one across the unit.

* fix(reminders): codex round 11 — four contract slips, one of them another unit's

**suggested_next returned up to eight entries against a cap of three.** Round 3
prepended reminders PAST the list's own cap, reasoning they should not compete
for slots. Every consumer — the web dashboard, `pad project next`, `pad project
ready` — is written for three.

Worse, it silently falsified a decision recorded elsewhere: BootstrapDashboard
deliberately has no suggested_next_overflow_count BECAUSE this list is capped
at three upstream, and its comment names raising that cap as the moment to add
one. My change made another unit's reasoning wrong in a file I never opened.
The combined list is now trimmed back to three, reminders still leading — a
reminder can push a task suggestion out, which is the right way round, and the
full set stays addressable in pending_reminders.

My first version of that trim used `limit`, which is REASSIGNED above to
len(candidates) — so on a workspace whose only entries are reminders it would
have truncated to zero, killing precisely the case the surface exists for.
Caught by reading the surrounding lines before running anything; it has its own
test now.

**pending_reminders was uncapped in the bootstrap projection.**
BootstrapDashboard embeds *DashboardResponse, so every new field joins the boot
payload automatically — here, a window of up to 50, which is the budget
PLAN-1410 spent a unit trimming. Capped at 5 with an overflow count, under its
own constant rather than borrowing bootstrapAttentionCap: they answer different
questions and a future change to one must not silently move the other.

**Truncation was reported from the wrong question.** The collector used the
store's `more` flag, which answers "is there another PAGE", not "did I read all
of THIS one" — so a window filling part way through the final page reported
that the caller had seen everything while unread rows sat behind the fill
point. The paging bounds are now injectable so the case is testable at all:
building it with a window of 50 needs ~75 rows in a specific pattern, with a
window of 3 it is four.

**Import accepted acked-without-fired**, which is not one of the lifecycle's
three states. Such a row fires, is excluded from the pending surface because it
is already acked, and can never be acknowledged because AckReminder requires
acked_at IS NULL — an event emitted into permanent invisibility. The
acknowledgement is dropped and the schedule kept, since an ack of something
that never fired means nothing.

Five mutants, five killed (one rewritten — removing the flag orphans a
variable). Sixty-six across the unit.

* fix(reminders): codex round 12 — a read is not a hold; scope the arm; ack from the ack

Four P2s from round 12 (two independent runs, both landing on the same
line of the fire path), each closed at the layer where it lives:

- fireOneReminder pins the item and workspace rows FOR NO KEY UPDATE on
  Postgres before the arbiter UPDATE. reminderFireable re-asserted
  liveness at the predicate's instant and nothing held it to the commit
  instant; under READ COMMITTED an archival could commit in between and
  the event left the process about a deleted resource. Same idiom and
  same lock strength as CreateAttachmentForLiveItem; SQLite is excluded
  by its BEGIN IMMEDIATE, not skipped for convenience. Two PG-only pins
  verify "blocked" in pg_stat_activity, not by elapsed time; the
  pin-removed mutant fails both.
- CreateReminder asserts "live item of THIS workspace" in the INSERT's
  own SELECT and returns ErrReminderItemGone otherwise. The table had an
  FK and no same-workspace constraint; a mismatched pair fed another
  workspace's title to this one's dashboard and webhooks. Handler maps
  it to 404.
- AckReminder matches every fired row (COALESCE keeps the first ack,
  updated_at moves only when acked_at does), so a no-match means exactly
  "not fired at the instant of the ack". The handler no longer decides
  409-vs-200 from the row it read before the UPDATE.
- The invariant paragraph gains its missing sentence: "at that instant"
  means the commit instant, and the pin is what makes the predicate's
  instant and the commit instant the same one.

Round-12 caveat carried: both runs were static reads (sandbox blocked
Go's build cache), so "four" is a floor, not a measurement.

Refs IDEA-2641

* fix(reminders): codex round 13 — a reminder's workspace must agree with its item's, at every read

Every reader scoped by r.workspace_id and then joined the item without
asserting the two agree. No door writes a disagreeing row today
(CreateReminder derives the pair from the item; import maps within the
workspace), and the table has nothing that forbids one — so a hand-edited
bundle, a future move door, or a direct write would carry one
workspace's item into another's dashboard, export, and webhooks.

The identity goes into reminderFireable (scan + arbiter), the Postgres
row pin, ListPendingReminders and the export query. One test writes the
row raw — the only way one can exist — and asserts it is inert at each
site; the predicate-removed mutant scans and fires it.

Refs IDEA-2641

* fix(reminders): codex round 14 — the by-id and by-item reads assert the same identity as every other read

GetReminder scoped by the row's own workspace_id and ListRemindersForItem
by item_id alone, so a row whose two columns disagree — the class rounds
12 and 13 closed at the scan, the arbiter, the pin, the pending surface
and the export — was still readable through the two reads that reach a
single row. reminderOwned is that identity on its own, without the
liveness half those two reads must not have (a fired reminder on an
archived item is history worth showing). The write paths reach a row
only through GetReminder, so scoping it scopes them; a row no door can
write needs no door to delete it. ListRemindersForItem now takes the
workspace its caller already resolved the item in.

The raw-row test asserts both reads refuse the row from both sides; the
reminderOwned-removed mutant surfaces it through GetReminder.

Refs IDEA-2641

* fix(reminders): codex round 16 — an archived item's reminders are readable, and its verbs say "archived"

The doors resolved the item live. Listing an archived item's reminders
answered 409 from a GET, and ack/re-arm/delete answered a bare 404 for a
reminder that exists on an item that exists — while the store, since
round 14, deliberately keeps that history readable. The API already has a
posture for archived items: GET reads them, mutations answer 409
"archived … restore it before editing" (writeItemResolveError). The list
now follows handleGetItem; the lifecycle verbs load the item
include-deleted, run the visibility check first, and then answer the same
409 every other item mutation does. One test walks archive → list 200 /
ack 409 / arm 409 → restore → ack 200 on the same rows.

Refs IDEA-2641

* fix(reminders): codex round 17 — one suggestion per item, the archived 409 by slug, and the door courtesy named

Three findings on the server pass. (1) An item that was both a fired
reminder and an ordinary candidate appeared in suggested_next twice; the
ordinary entry is dropped, the reminder entry (which carries the ack id)
stays, and two reminders on one item remain two entries. (2) Round 16's
409 for an archived item's reminder was written by re-resolving item.Ref,
which is derived and empty for a legacy item with no item_number — so the
class most likely to be legacy fell through to a bare 404. The slug is
handed over instead. (3) The archived check in resolveReminderForWrite is
check-then-write, and an archive landing in between lets the verb through:
accepted and documented — it is the posture of every item mutation here
(UpdateItem's UPDATE has no liveness clause), the outcome is benign, and
putting liveness in AckReminder's WHERE would re-create the no-match
ambiguity round 12 removed.

Refs IDEA-2641
2026-09-04 11:36:11 -04:00
xarmian a1716d8170 ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) (#1247)
* ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881)

`npm audit` exits non-zero identically for "a HIGH/CRITICAL advisory
exists" and "the advisory service was unreachable". The Web job ran it
before Build / Type check / vitest under `bash -e`, so a registry
timeout (main, 03:50Z) and a 503 (#1246, 04:33Z) on 2026-09-04 each
produced a red row with every frontend verification step SKIPPED — a
lane that read like a failure and had asked nothing.

scripts/ci-audit.mjs runs the audit in --json mode and decides from the
report: metadata.vulnerabilities present → fail iff high+critical > 0,
naming the advisories; an error envelope or unparseable output → a
GitHub warning annotation saying the gate did not run, exit 0. The step
moves to the end of the job so the frontend's own verdict always exists
whatever the audit does.

Verified locally against five report shapes (transport timeout envelope,
E503 envelope, one high advisory, clean, garbage) and two live runs (the
real registry: clean; a dead registry: warning, exit 0). `--input <file>`
is the seam those checks use.

Fixes BUG-2881

* ci(web): the audit gate fails closed — retry an unreachable advisory service, then fail under its own title

Codex round 1 on #1247: the first draft warned and exited 0 when the
advisory service could not be asked, which made the only supply-chain
gate pass exactly when it had not run. A gate that passes when it cannot
run is not a gate.

Now: up to three attempts with backoff (registry blips are usually
seconds long), then `::error title=npm audit did not run` and exit 1.
The title is distinct from `::error title=npm audit` (a real advisory)
so the checks tab tells the two apart without opening the log; re-running
is the remedy for the first and never for the second. Because the step
runs last, Build / Type check / vitest have already produced their result
either way — the original blindness is gone regardless of which way this
step fails.

Verified against the same five saved shapes (transport and E503 envelopes
and garbage now exit 1 under the did-not-run title; a high advisory exits
1 under the advisory title; clean exits 0) and two live runs (real
registry: clean; dead registry: three attempts logged, exit 1).

Refs BUG-2881

* ci(web): the audit gate refuses counts it cannot read, and refuses bad tuning without crashing

Codex round 2 on #1247. (1) metadata.vulnerabilities was checked for
presence, not for shape: Number("x") + Number(null) > 0 is false, so a
malformed count read as a clean audit — a second fail-open, one layer
deeper than round 1's. high/critical must now be non-negative integers
or the report is unreadable, which is the fail-closed path. (2) The two
env knobs are operator-set, but CI_AUDIT_ATTEMPTS=NaN left the retry loop
unexecuted and threw a TypeError, and CI_AUDIT_BACKOFF_MS=Infinity parked
Atomics.wait forever; both now fall back to the default with a line
saying so.

Refs BUG-2881

* build: the local preflight runs the same audit gate CI does, and runs it last

Codex round 3 on #1247 (blast radius): `make web-check` still chained
bare `npm audit && npm run check`, so a registry blip stopped svelte-check
locally exactly as it had in CI, and CONTRIBUTING documented the bare
command as the way to reproduce the gate. New `web-audit` target runs
`npm run audit:ci`; `check` runs it after web-check and web-test, mirroring
the Web job's order. CONTRIBUTING and docs/architecture.md say so.

Refs BUG-2881

* build: web-audit stands alone — no `web` prerequisite, so `check` runs npm ci once and no new target reaches it

Codex round 4 on #1247: `web-audit: web` made `check` run `npm ci` twice
(`web` is .PHONY) and added a target CLAUDE.md's worktree rule did not
list as reaching `npm ci`. `npm audit` reads the lockfile and needs
neither node_modules nor a build — verified by running it with
node_modules removed — so the prerequisite goes; CLAUDE.md's safe list
gains `web-audit`.

Refs BUG-2881
2026-09-04 10:45:21 -04:00
xarmian a15b4951ae Merge pull request #1245 from PerpetualSoftware/feat/task-2877-inline-create
feat(web): inline create from the relation picker (TASK-2877)
2026-09-03 23:43:03 -04:00
xarmian 552230bbea fix(web): a cleared picker owes a refresh even on an unchanged scope (TASK-2877)
Codex review round 13, and it collapses round 12's fix into a simpler one.

`lastScope` means "the scope the rows on screen answer for". The not-ready
branch REMOVES those rows, so afterwards they answer for nothing — which
is what null says, and the next run therefore owes a refresh whether or
not the scope itself moved. Leaving the old value there meant rehydrating
on the SAME workspace and collection compared equal, so a server-sourced
picker took the early return and sat empty permanently: its rows were
cleared and nothing was left to re-query it.

Round 12 deferred the COMMIT past the early return to keep a
cold-window scope change from being forgotten. With this invalidation in
place that deferral changed no outcome — its mutant could not be killed —
so it went and the commit moved back to where the value is computed. One
rule stated once, rather than two mechanisms aimed at two halves of it.

Also hardened the mutation harness, after it bit: a harness timeout kills
the runner with SIGTERM, which does not run `finally`, so an earlier
killed run left the working tree MUTATED. I then read a pre-existing test
"failing" in that tree and had a plausible defect and a fix half-written
before checking the file — the failure was M31's mutant, not my change.
The runner now restores from its backups on SIGTERM/SIGINT/SIGHUP. Cheap,
and the alternative is reasoning about code nobody wrote.

Matrix: 35 mutants, all killed; baseline and restore both 97/97.
2026-09-04 02:30:10 +00:00
xarmian 60fe815300 fix(web): a scope refresh stays owed until a run serves it (TASK-2877)
Codex review round 12, and the tail of round 11's fix.

`lastScope` was committed as soon as the effect computed it, before the
not-ready branch — which clears the picker and gives up WITHOUT serving
the scope. So a scope change arriving while the workspace state is dropped
was recorded as handled by the run that handled nothing: at hydration
`scopeChanged` read false, a server-sourced picker took the early return,
and it sat empty until the user retyped or it remounted.

Committed now only by a run that is actually going to serve the scope.
Leaving it stale is what keeps the refresh owed.

Matrix: 35 mutants, all killed; baseline and restore both 96/96. The new
one — committing `lastScope` early again — dies on the added leg.
2026-09-04 01:39:38 +00:00
xarmian 268594e57e fix(web): a scope change re-queries a server-sourced picker too (TASK-2877)
Codex review round 11 — the tail of round 10's fix, and mine.

The refresh effect now tracks the scope, but it returns early for
server-sourced non-empty queries. That early return is right for an index
DELTA — the index is not that caller's source of truth, and a request per
delta is the rate-limiter pressure the debounce exists to avoid — and
wrong for a scope CHANGE, where the rows on screen are answers to a
different question and stay selectable under the new scope. A scope change
happens when a schema is edited or a pane is retargeted, not per delta, so
the rate-limiter argument does not reach it.

Two lines that looked like guards went, both measured rather than argued:

  * `void collection` — the scope pair reads `collection` to build itself,
    which IS the subscription, so the separate read added nothing and its
    mutant could not be killed.
  * the `lastScope !== null` first-run guard — at mount the query box is
    empty, and the only reader of `scopeChanged` needs a non-empty query,
    so the first run cannot change an outcome either way.

`lastScope` starts null rather than seeded from the props: seeding
captured their mount-time values outside a reactive scope, which
svelte-check flagged (`state_referenced_locally`) — two warnings this
branch introduced and has now removed. svelte-check is back to the six
pre-existing warnings in files this branch does not touch.

Matrix: 34 mutants, all killed; baseline and restore both 95/95.
2026-09-04 01:08:28 +00:00
xarmian 74d6564c4e fix(web): the picker's collection scope is a tracked input (TASK-2877)
Codex review round 10. The refresh effect read `collection` inside
`untrack`, so a relation field whose declared target CHANGES under an open
picker — a schema edit, or an SSE-driven collection refresh; `ItemDetail`
does not remount the picker for either — kept listing rows from the
collection it used to point at, still selectable under the new scope.

Everything else in that effect is untracked to keep it off the keystroke
path, and the scope was swept up in that. But `collection` is not a
per-keystroke value: it is the question the results answer.

Predates this unit — it arrived with the U3 extraction (TASK-2862) — and
is fixed here rather than filed because U8 makes `collection` load-bearing
in a new way: it is now the destination an inline create writes to, so a
stale scope means rows from one collection listed beside a create row
aimed at another.

The test drives the change through a NEW single-prop setter on
`ItemPickerProbe`, not through `rerender`. That distinction is the whole
reason the probe exists, and its own header says so: `rerender` replaces
the entire props object and re-runs the effect whether or not it tracks
the prop under test, so a rerender-driven version of this test passes
against the untracked build. Verified rather than assumed — the mutant
that restores `untrack` dies against the setter version.

Matrix: 32 mutants, all killed; baseline and restore both 94/94.
2026-09-04 00:10:58 +00:00
xarmian ded64ce232 docs(web): record why three races are deliberately not fenced (TASK-2877)
Codex review round 9, two P1s, both declined — and the reasoning goes
beside the fences rather than into a commit message, which is the lesson
round 7 taught when a round-3 decline was re-raised because a reviewer
reading the diff had no way to see it.

A CONCURRENT FIELD CHANGE (SSE, another tab) landing mid-POST is ordinary
last-write-wins on a field the user is actively editing, and it is what
every other type in this component already does — a text field blurred
after a remote change overwrites it too. The race is adjudicated at the
server: `ItemDetail.updateField` sends `expected_updated_at` and
refetch-retries a 409 (BUG-2273 / IDEA-1480). Fencing it here would make
relation fields alone behave differently from every other field, on a rule
the item's own optimistic-concurrency check already enforces.

A LOST RESPONSE on a create that committed is real and is not fixable
here. `item create` has no idempotency key and titles are not unique
(colliding slugs get `-2` suffixes, `store.uniqueSlug`). Nothing
auto-retries — a retry is a person clicking Create again with the picker's
state in front of them — and the repo's standing rule for the identical
shape is exactly that ("Never retry it automatically" for `item copy`).
Filed as IDEA-2880. Deliberately NOT patched client-side: checking for a
same-title item before retrying would rest on the same ranked, paged,
possibly-stale evidence the create row itself rests on, and would look
like a guarantee the client cannot make.

No behaviour change; gates re-run rather than assumed — 2104 web tests,
svelte-check 0 errors.
2026-09-03 23:42:11 +00:00
xarmian c516871328 fix(web): read page completeness from the page, not from total (TASK-2877)
Codex review round 8, one P1, and the mechanism checks out in the server
source rather than only in the abstract.

Round 7 gated the cold answer on `(res.total ?? rows.length) <= rows.length`.
`store.search` makes that unreliable in exactly the case it was guarding:
when the count query errors it sets `total = -1`, floors it to 0, and then
floors it again to `len(results)` — "Ensure total is never less than actual
results", `internal/store/search.go:604-608`. So a broken count is
indistinguishable on the wire from an exact-fit page, and the check calls
it complete. The `?? rows.length` fallback was the same mistake a second
time: unknown read as fine, which is the polarity error rounds 3 and 5
already went around on `coldFailed`.

Completeness now comes from the PAGE: a page SHORTER than the limit the
server echoes back is proof there is no next page, and that holds whatever
the count did. A full page is not proof either way, so it does not count
as an answer. No `total` in the decision at all.

The U8 fixtures now carry the real response shape. `total`, `limit` and
`offset` are non-optional on `SearchResponse` and the Go handler always
sends them, so `{ results: [] }` was not a smaller version of a real
response — it was one that cannot occur, and it was quietly deciding the
very question these tests are about.

Matrix: 31 mutants, all killed; baseline and restore both 93/93. M28b —
the previous `total`-based implementation — SURVIVED at first, and the
fixture was why: it asserted against `total: 84, limit: 2`, which both
implementations reject. The leg that discriminates is the floored one
(`total: 2` on a full page of 2 with 84 really matching), i.e. the shape
the server actually emits when the count fails. A mutant that survives
because the fixture never reproduces the real failure is a fixture
finding, not a code finding.
2026-09-03 23:31:56 +00:00
xarmian ff44e917ae fix(web): count the 401 drop; a truncated page is not an answer (TASK-2877)
Codex review round 7. Two taken, one answered in the code.

P1 — `resetGenerationFor` counted `reset()` and missed the OTHER drop.
`bootstrap()`'s unauthorized/forbidden branch clears `state.items`, resets
the MiniSearch index and wipes the persisted cache without going through
`reset()`, so the fence added in round 6 did not see the revocation case
it exists for. Both droppers now call one `markWorkspaceDropped(ws)`
helper. Two call sites, because deleting the state entry and clearing rows
in place are genuinely different operations; the helper is what makes the
pairing greppable, and a test fails if a third site starts clearing rows
without it.

That test is STRUCTURAL, and deliberately so. Reaching the 401 branch
through the front door needs a warm cache plus a pending resync plus a 401
from /items-changes — a fixture larger than the invariant it would check,
and I tried it first. The invariant that actually has to hold is "clearing
rows and counting the drop travel together". The site-count assertion is
what keeps it honest: a NEW clear site fails loudly rather than going
silently unexamined, which is how this kind of instrument usually rots.
Its own mutant (the 401 branch stops counting) dies.

P2 — a TRUNCATED cold page is not an answer to "does this exact title
exist"; the row may be on a page nobody fetched. `SearchResponse` carries
`total`, so `coldAnswered` now requires a complete page. Same defect as
trusting the local ranker's window, arriving from the server side — the
third variant of one mistake, which is why the rule is now stated once and
asked everywhere: offer only where something authoritative has answered.

P2 (query change mid-create) was raised for the second time, having been
declined in round 3 with reasons that lived only in a commit message —
which a reviewer reading the diff never sees. The reasoning is now a
comment beside the fences: the three that exist each stand for an act
meaning "not this one" (escaping out, choosing another row, landing on a
different item or workspace); typing is mid-thought, the user did ask for
the item being created, and cancelling would orphan that row with the
field still empty. A decision worth keeping is worth putting where the
next reader is looking.

Matrix: 29 mutants, all killed; baseline and restore both 93/93.
2026-09-03 22:54:38 +00:00
xarmian 7b32e57cc9 fix(web): a dropped workspace needs an identity signal, not an epoch (TASK-2877)
Codex review round 6, and it corrects the reasoning round 5 shipped.

Round 5 fenced the create on `scopeEpochFor(ws) === epoch` and recorded
the residual as needing a coincidence — a purge plus resyncs landing back
on the captured number. That was wrong, and wrong in the direction that
matters: `reset()` deletes the state and the replacement starts at
`scopeEpoch` 0, which is ALSO the value whenever no projection resync has
ever run. That is the ordinary case, so the equality check passed
trivially across exactly the event it was added to catch. A residual I
called exotic was the default path.

The fix is the signal the store did not expose: `resetGenerationFor(ws)`,
a monotonic per-workspace count of drops, deliberately kept OUTSIDE the
`workspaces` map because `reset()` deletes that entry. Both existing
counters — `scopeEpoch` and the internal `generation` — live on the state
object and restart with its replacement; they are safe only because their
readers hold a REFERENCE to the object, which a caller outside the module
cannot. It is bumped even when the reset found no state to drop, so a
purge racing a first bootstrap does not read as no purge.

`createRelationTarget` now asks two questions rather than one:

  * `indexStillOurs()` — is this the index the request was authorized
    against? It gates the UPSERT, which was previously unconditional on
    the argument that a real row belongs in the index. That argument does
    not survive a purge: a brand-new id was never in `upsert`'s fenced
    set (nothing to fence — the row did not exist when the purge ran), so
    the write lands and is persisted to IDB, resurrecting a row into a
    workspace the user may have just lost access to. This is the gap
    BUG-2098's own comment describes.
  * `stillWaiting()` — is the user still waiting on THIS create? It gates
    the link and the toast, and it is now ONE predicate rather than two
    hand-copied condition lists. The failure path had drifted from the
    success path by exactly the reset half (round 6 P2); sharing the
    predicate is what stops that recurring.

Matrix: 27 mutants, all killed; baseline and restore both 91/91. New
store surface carries its own suite, including a CONTROL asserting that
`scopeEpochFor` genuinely cannot answer this question — if that ever stops
holding, the cheaper round-5 fence was sufficient after all and this
accessor should go.
2026-09-03 22:36:10 +00:00
xarmian f7bb735771 fix(web): state the cold rule positively; catch the epoch reset (TASK-2877)
Codex review round 5, two P1s, both about `localIndex.reset()` — the
sign-out / 403-purge / deleted-workspace path.

THE FLAG WAS THE WRONG WAY ROUND. `coldFailed` asked "did the last search
fail", and that was false in three states that are not answers at all:
before the first request, after a failure, and after a reset drops every
row while the query sits in the box. Each one read as "fine" and put a
create row on screen backed by nothing. Inverted to `coldAnswered` — set
in exactly one place, by the event that earns it, and cleared wherever the
answer stops describing what is in the box. A flag that must be cleared
everywhere is one that will be missed somewhere; this is the same defect
arriving twice (round 3 caught the failure case, round 5 the reset case)
because the polarity made silence indistinguishable from success.

THE EPOCH FENCE HAD TO BE TWO-SIDED. `upsert`'s own guard refuses a
captured epoch BELOW the current one, which catches a resync. But
`reset()` DELETES the workspace state and the next bootstrap starts a
fresh one at `scopeEpoch` 0 — so a captured 7 is not below 0, sails
through, and links a row minted under an identity that no longer holds.
`createRelationTarget` now requires equality. The residual is in the code
comment rather than papered over: a reset plus resyncs landing back on
exactly the captured number would compare equal, which an exposed reset
generation would catch and this does not.

Also dropped the `loading` term from `showCreate`. It and the per-query
`coldAnswered` reset were a redundant PAIR — each survived removal while
the other stood, which is one guard and one line that looks like a guard,
not defence in depth (this repo has a note about exactly that shape).
`coldAnswered` is the one kept: it states the rule (something
authoritative has answered FOR THIS QUERY) where `loading` is a UI state
that correlates with it.

Matrix: 24 mutants, all killed; baseline and restore both 85/85. Killing
the per-query reset needed `aria-expanded`, not the row's absence — with
`loading` still gating the MARKUP, `.picker-create` is missing either way
and asserting on it measures the branch instead of the rule. Third time
this suite has been fooled by that same separation.

Re-verified end to end in a real browser on this exact build: create row
offered for a non-matching query and keyboard-reachable; Enter created
COLO-6 "Chartreuse" in COLORS (colors 2 -> 3, cars unchanged) with
`status: approved` — the schema's declared default, which the "+ New"
`options[0]` heuristic would have gotten wrong; the car's field holds that
id; a second pass at the same text offers the existing row and no create;
Escape leaves the value untouched; no bare UUID anywhere on the page.
2026-09-03 22:14:47 +00:00
xarmian 6a335dd120 fix(web): absence is only evidence from a settled index; fence the error toast (TASK-2877)
Codex review round 4. Two taken, one declined.

P1 — `bootstrapState === 'ready'` was the wrong authority for the create
row. It coexists with `pendingResync`: `localIndex` hydrates from the IDB
cache and serves those rows while delta-sync catches up, so during that
window an item that EXISTS can be missing from the snapshot. The create
row is derived from ABSENCE, and a cache snapshot cannot support that
inference — presence still can, since the row was real when it was cached.
`indexCanProveAbsence()` is asked ONLY by `showCreate`; search and listing
keep using `isWarm`, because showing cached rows during a resync is right
and it is only the "therefore no such item exists" step the cache cannot
bear. The window is seconds and a duplicate outlives it.

That leaves one rule across the whole unit, applied in four places now:
offer only where something authoritative has answered. Cold is authorized
by `/search` (the server answered); a settled index is authorized by the
in-RAM collection; a resyncing index and a failed search authorize
nothing.

P2 — the failure path was unfenced while the success path was not, so a
create the user escaped out of, or one belonging to a workspace they have
since left, still threw its error over whatever they were looking at.
Same three conditions, same reasoning: the difference between reporting
and not is whether they are still waiting on it.

DECLINED, with reasons, so it is not re-flagged: the "A->B->A gap" in the
workspace fence. The classic gap bites when an identifier can be REBOUND
to a different object between capture and compare. Here the pair
(workspace slug, item slug) is what the fence compares, and the parent
subtree is keyed on the item slug, so returning to the same pair returns
to the SAME item — applying the create there is correct, not stale. Item
refs are sequential and never reused, so the identifier cannot be rebound
within a workspace.

Matrix: 22 mutants, all killed; baseline and restore both 82/82. Four
anchors went stale this round because the fence now appears on two paths
and matched twice — the harness refused to score them rather than
silently mutating the wrong copy, which is the reason it checks.
2026-09-03 21:45:26 +00:00
xarmian 761e6e2453 fix(web): a failed cold search is not evidence that nothing matched (TASK-2877)
Codex review round 3 P2. `coldSearch`'s catch leaves exactly the state a
successful empty answer leaves — no rows, not loading — and the result
list is right to render both as "No results". The create row is not: an
empty answer is evidence that no such item exists; a failed one is no
evidence at all, and offering to create on no evidence is how a duplicate
gets minted while the index is cold and the network is unhappy. Same rule
the permission gate already follows — no answer must not read as
permission.

A `coldFailed` flag now separates the two, and where it is CLEARED was
settled by the matrix rather than by symmetry. Three reset sites looked
obviously needed and three mutants removing them survived:

  * the cold branch of `runQuery` — `loading` is true for that entire
    window and already suppresses the row, and both `coldSearch` branches
    assign the flag outright when the request settles;
  * the empty-query branch — covered twice over, since an empty query
    offers no create row at all;
  * the workspace-reset effect — same as the first.

All three are gone rather than carrying a comment claiming a protection
they do not provide, which is the disposition this plan's own U3 note
records for an unkillable guard. The ONE reachable reset is the warm
branch: it is the only path that produces a fresh verdict without going
through `coldSearch`, so without it a single network blip suppresses the
affordance for the rest of the session even once the authoritative in-RAM
answer is available. That one has a test, and its mutant dies.

Round 3 also raised a P1 I am NOT taking: typing a new query while a
create is in flight does not cancel it. The three fences that exist —
escape, picking another row, retargeting — each stand for an act that
means "not this one". Typing is not such an act; it is mid-thought, and
the user did explicitly ask for the item that is being created. Treating
it as a cancel would leave the created row orphaned and the field unset,
which is a worse outcome than a field that ends up holding exactly what
was asked for. Told to Codex in the next round rather than left to be
re-flagged.

Matrix: 20 mutants, all killed; baseline and restore both 80/80.
2026-09-03 21:45:26 +00:00
xarmian 5dffd734c1 fix(web): fence the create against cancel and against a workspace switch (TASK-2877)
Codex review round 2, two P1s, both confirmed at the lines they name.

CANCEL. `oncancel` only closed the picker, so backing out did not
supersede an in-flight create — the pending promise then resolved and
selected an item the user had just declined. Backing out is as explicit a
choice as picking a different row, and now bumps the same counter.

WORKSPACE SWITCH. `ItemDetail` keys its fields subtree on `itemSlug`
ALONE, so switching workspaces to an item carrying the SAME ref — and
every workspace has a TASK-5 — reuses this component rather than
remounting it, and `destroyed` never fires. The completion then wrote an
item ID from the previous workspace into the new workspace's item.
`createRelationTarget` already captured `ws` and `collSlug` before the
request; it now compares them to the live props before applying, which is
the DR-6b shape `ChildItems.submitCreate` uses for the same reason.

The `localIndex.upsert` still runs ahead of all three fences and still
uses the CAPTURED workspace: the item genuinely exists in the workspace it
was created in, and the fences are about where the VALUE is written, not
about hiding a real row.

Matrix now 17 mutants, all killed; baseline and restore both 78/78. The
two added here — cancel not bumping the counter, and the ws/collection
comparison removed — are what stand in for having seen these two tests red
before the fix, since pin and fix landed in one edit.
2026-09-03 21:45:26 +00:00
xarmian 83e4abc966 fix(web): fence the in-flight create; ask the index, not the ranking (TASK-2877)
Codex review round 1, three findings, all confirmed by reading the code
they name rather than taken on the report.

P1 — the create completion had no fence, and there are two ways past it.
`ItemDetail` wraps its fields section in `{#key itemSlug}`, so an item
switch DESTROYS this component; the promise survives, and `onchange` calls
into the persistent parent, whose `updateField` builds its PATCH against
whatever item is current at CALL time. A create started on car A therefore
wrote its colour onto car B. Separately the picker stays open across the
round trip, so the user can settle on another row (or clear the field)
before it lands — and last-write-wins is the wrong rule there, because the
later write is an explicit choice and the earlier one is a promise they
have moved past. A `destroyed` flag and a supersede counter, checked
together, close both. The `localIndex.upsert` deliberately runs BEFORE the
fences: the row exists on the server whatever happened locally, and
withholding it would leave a picker offering to create it a second time.

P2 — `targetCollection` read the global collection list with no freshness
gate, so during a workspace switch a slug match against the PREVIOUS
workspace's rows yielded a foreign collection ID, and `canEditCollection`
answered about that. Same gate `knownCollectionSlugs` already had, which
this derivation was missing.

P2 — the exact-title suppression was asking the RANKING. `warmSearch`
requests `limit + excluded.size` hits, so an exact row the ranker placed
outside that window is simply absent from `rawResults` and the picker
offers a duplicate. The question has an authoritative answer in
`localIndex`, already in RAM, so the warm path now scans the collection
directly. The `rawResults` check stays and is NOT redundant: while the
index is cold there is nothing to scan, and the server's rows are the only
evidence the row exists — pinned by its own leg, which is what killed the
mutant that removed it.

Mutation matrix now 15 mutants, all killed; baseline and restore both
76/76. Two rounds of it earned their keep beyond the fixes: M3 SURVIVED
once the index scan landed, and the mutant was faithful — the suite had no
cold-path exact-match leg, so the surviving mutant found a real hole in my
tests rather than a redundant line in the code.
2026-09-03 21:45:26 +00:00
xarmian e331342450 feat(web): relation fields create their target inline, permission-gated (TASK-2877)
PLAN-2857 U8, caller half. `FieldEditor` hands the picker an `oncreate`
only when the viewer may create in the field's DECLARED TARGET, so it
decides both of the unit's gates by deciding whether to pass one.

The gate is `canEditCollection` on the target collection — the same
predicate behind the collection page's "+ New" — asked about where the
item would LAND, not about where the user is standing. It needs the
collection's ID, which only the loaded collection list carries; a target
the list does not know yields no create row, because "no answer" must not
read as "allowed".

NO FIELD VALUES ARE SENT, and that is a decision with a receipt. The
server fills every missing key that declares a `Default` and stores the
defaulted map (`items.ValidateFields`, then "Marshal validated/defaulted
fields back" in `createItemChecked`), so the schema's own answer is
already the right one. The collection page's "+ New" guesses
`status.options[0]` instead; driven live against a Colors collection whose
status options are [draft, approved] with `default: approved`, the created
row came back `{"status":"approved"}` — the declared default, which that
heuristic would have gotten wrong. The cost is that a target carrying a
REQUIRED field with no default refuses the create; that surfaces as a
toast naming the field, which is the honest outcome for a row this picker
cannot fill in.

The new item is upserted into `localIndex` under the epoch captured BEFORE
the request (BUG-2098 — a projection resync landing mid-flight means the
response was authorized under a scope that no longer applies). That upsert
is what makes the picker's exact-title suppression true on the very next
keystroke; without it the same text offers to create a second item.

Mutation matrix, all killed: creating in a collection other than the
declared target, the permission gate removed, the upsert removed, and the
epoch read after the request rather than before.
2026-09-03 21:45:26 +00:00
xarmian 322b461606 feat(web): the scoped picker offers an inline create row (TASK-2877)
PLAN-2857 U8, picker half. When a scoped picker's query matches nothing —
or nothing EXACTLY — it offers a trailing "Create "<query>" in <collection>"
row, keyboard-reachable like any other row.

The affordance is opt-in at the call site: it appears only when the host
passes `oncreate`, which is how both of U8's scope rules are expressed
without this component knowing either. "Relation fields only" is the
Relationships tab passing nothing; the permission gate is the caller's,
because "may this user create in the target collection" is the
collection-level `canEditCollection` cascade that lives in the workspace
store.

Result rows and the create row become ONE `options` list, in render and
keyboard order, so arrowing onto the create row needs no special case and
cannot fall out of step with what is on screen. `activeId` already
addressed rows by identity; the create row takes a NUL-prefixed sentinel
id in the same namespace, which no UUID can collide with.

Two suppressions carry weight and both are pinned:

* EXACT-TITLE. Tested against `rawResults` — the source's answer before
  exclusion and the row bound — because an exact match pushed past `limit`
  or excluded by the caller would otherwise read as "no such item" and
  offer to mint a duplicate of a row that exists. This IS the no-duplicate
  half of the unit's proving test: there is no create-time uniqueness
  check anywhere, because the second pass at the same text never reaches a
  create.
* LOADING. Mid-flight, "nothing matched" is not yet known. The one
  assertion that can fail here is `aria-expanded`, not the row's absence:
  the markup renders the loading branch INSTEAD of the listbox, so a build
  that offered the row mid-flight would still show no `.picker-create` and
  merely leak a combobox announcing itself expanded over no listbox. That
  is trap #1 from this plan's false-green note, met in my own diff.

Re-entrant creates are dropped while one is in flight, so two Enters
inside a single round trip cannot mint two items — a duplicate the
exact-title check cannot catch, since no row exists yet to match.

Mutation matrix, all killed: exact-title suppression removed (3 tests),
re-entrancy guard removed, `loading` term removed, `collection` term
removed, Enter dispatching over `results` (the pre-U8 line), create row
prepended rather than trailing.
2026-09-03 21:45:26 +00:00
xarmian e94e9afbea Merge pull request #1240 from PerpetualSoftware/fix/bug-2850-field-coercion
fix(server,mcp,cli): type field values server-side; carry the fields object natively (BUG-2850)
2026-09-03 17:21:57 -04:00
xarmian 56c46ae0ea fix(store): propagate a collection rename into relation fields that target it (BUG-2873) (#1243)
fix(store): migrate relation fields when their target collection is renamed (BUG-2873)
2026-09-03 16:51:42 -04:00
xarmian 80be76a3ce docs(mcp): the detectFieldConflicts header stated the pre-round-14 reach (BUG-2850)
Comment-only. The lead caught it in the package review.

The "SCOPE:" paragraph still said the pass runs only when a `fields`
object is present, and that a top-level-vs-`field:[]` collision without
one is outside it. Round 14 falsified both halves — the pass runs from
both action entry points on every call — and the body's own comment said
so while the header contradicted it. On the one boundary this loop spent
eighteen rounds on, the header is what a future reader trusts.

CONVE-23 is exactly this and I missed it: the round-14 commit swept the
version.go prose and the test comments, and left the header of the
function it had just changed.

The paragraph now states the actual reach: both entry points regardless
of `fields`; alias collisions adjudicated always and refused even on
equal values; same-name collisions adjudicated only when the `fields`
object carries THAT key, which is a per-key question; the round-7
exemption as the sole carve-out, itself narrowed to keys the CLI can
express (the compat IDs refuse, but only when a top-level compat value
is present), with padded entries outside it.

It closes with the instruction the loop earned: say a new condition's
QUANTIFIER out loud before writing it. Rounds 15, 16, 17, 19, 20 and 21
were each that question answered by assumption.

gofmt clean · go vet clean · go test ./internal/mcp/ ./cmd/pad/ green
2026-09-03 20:44:01 +00:00
xarmian 9ecc59af1e fix(store): refuse a schema with trailing content instead of truncating it (BUG-2873)
Codex round 3, one P2. `json.Decoder.Decode` stops at the end of the FIRST value
and ignores whatever follows, where `json.Unmarshal` refuses it — so a stored
schema with junk after the object would be silently truncated by the rewrite.
It is now treated as unparseable and left alone, the same posture as any other
schema this migration cannot faithfully reproduce.

**The Postgres gate then failed the new test, and the failure is the finding.**
It failed at the SEED, not the assertion: `ERROR: invalid input syntax for type
json (SQLSTATE 22P02)`. `collections.schema` is TEXT on SQLite
(`005_collections.sql:10`) and JSONB on Postgres
(`pgmigrations/001_initial.sql:114`), so a value with trailing content cannot be
STORED on Postgres at all. The state this guard defends against is reachable on
one dialect and forbidden by the column type on the other.

So the test skips on Postgres with that reason recorded. Asserting there would
be asserting about a state that cannot exist — and reading WHICH LINE failed is
what separated "my test is not portable" from "the product is broken on PG".

**Second instance of the mutation harness reporting a false survivor**, same
cause as the last: deleting the guard leaves `io` unused, the mutant fails to
compile, and counting `--- FAIL` lines sees zero. With `_ = err` in place of the
return it dies immediately. Twice in one unit makes it a harness defect, not bad
luck: a runner that counts test failures must check the BUILD separately, or
every non-compiling mutant reads as a hole in the tests.

Mutation matrix 7 of 7 killed.

Gates: `gofmt` clean, `go vet ./...` ok, full `go test ./...` green on SQLite,
full `internal/store` green on Postgres (502.2s, private container at
127.0.0.1:5473, detached with a sentinel).
2026-09-03 20:08:53 +00:00
xarmian a1b8e63a29 fix(mcp): a nil top-level value is absence, for every key (BUG-2850)
Codex round 21, one P2 and no P1 — a false refusal, and the finding
named a strict subset of it.

topLevelValueProvided returned true unconditionally for the compat IDs,
and fell through to true for everything else, so a nil counted as a
supplied value and refused against a `fields` entry for the same key.
Nothing writes a nil: the HTTP mapper's `.(string)` assertion drops it
and BuildCLIArgs has no flag value to emit, so both doors resolve to the
`fields` value.

The finding named `assigned_user_id` / `agent_role_id`. Probing the
population first — the habit this unit has been beating into me — showed
all five top-level keys behaving identically, because the non-compat
path fell through to `return true` as well. Fixing the named pair alone
would have left `status: null` refusing.

Mutation matrix, three directions:

  remove the nil check          -> all five key legs fail
  fix ONLY the named compat pair -> status / priority / parent fail
  treat the empty compat clear
    as absence too              -> both clear-semantics tests fail

The middle mutant is the population-versus-instance distinction made
executable: a fix that satisfies the reviewer's example and nothing else
is red, by name, in three legs.

Gates: gofmt clean · go vet clean · go test ./... green (29 packages) ·
contract-drift gate green
2026-09-03 19:59:16 +00:00
xarmian 499387e99d fix(store): never regress a migrated token; keep large integers intact (BUG-2873)
Codex round 2: four findings, two fixed here and two filed as their own items.

**Migrated siblings' OCC tokens could REGRESS.** A sibling updated between this
rename's timestamp and the scan already holds a newer `updated_at`; stamping the
rename's value on it moved the token BACKWARDS — breaking the strictly-increasing
invariant the transaction above exists to maintain, and re-validating a token the
client should have lost. Each rewritten row now takes `max(current + 1ns,
renameToken)`, computed in Go from the value read under the row lock rather than
by comparing timestamp TEXT, which the existing comment warns is never safe.

**Large integers in unknown properties were corrupted.** Round 1 fixed the typed
round-trip dropping unknown keys, but decoding into `interface{}` turns every
JSON number into float64, so `9007199254740993` came back CHANGED. A rename would
silently damage a property it exists only to carry through. `UseNumber` keeps the
literal text.

## Filed, not absorbed — both because their dependents are not the relation feature

- **BUG-2875** — a collection CREATED during a rename escapes the scan's
  `FOR UPDATE` and keeps a relation aimed at the old slug. Closing it means
  `CreateCollection` takes the workspace lock, which changes the concurrency
  behaviour of every collection creation on the instance. Same reasoning that
  split IDEA-2874 out; this unit's reviewability rests on affecting zero live rows.
- **IDEA-2876** — migrated siblings emit no `collection_updated` event, so an open
  page keeps the pre-rename schema until reload. Handler/event layer; the store
  publishes nothing.

## The mutation harness was reporting a false survivor

Counting `--- FAIL` lines treats a mutant that FAILS TO COMPILE as one that
survived — zero failures either way. Removing the token guard leaves
`rowUpdatedAt` and `renameToken` unused, so that is exactly what happened, and it
read as "the guard is untested". With a compiling mutant (`_ = rowUpdatedAt`) it
dies immediately. Worth stating because the failure mode is silent and points the
wrong way: it invents doubt about code that is fine, and would equally hide a
real survivor behind an unrelated build break.

Mutation matrix 6 of 6 killed.

Gates: `gofmt` clean, `go vet ./...` ok, full `go test ./...` green on SQLite,
full `internal/store` green on Postgres — 460.9s, private container at
127.0.0.1:5473, run detached with a sentinel.
2026-09-03 19:43:36 +00:00
xarmian a9f2405903 fix(mcp): the compat exception turns on a top-level value, not on the key (BUG-2850)
Codex round 20, one P2 and no P1 — another false refusal from a reason
of mine applied past the source it was verified on.

Round 15's reason was specific: a TOP-LEVEL compat param has no CLI
flag, so BuildCLIArgs drops it while HTTP reads it, and the doors
receive different writes. I then keyed the exception on the KEY being a
compat one, which caught `field:["assigned_user_id=A",
"assigned_user_id=B"]` — two array entries, no top-level value, no
asymmetry: both doors keep the last and lift the same column. Refused a
call that resolves deterministically.

The gate now asks whether a top-level compat value is actually present,
which is the condition the reason describes.

Mutation matrix, both directions:

  broaden it back to any compat-keyed contribution -> only the two-entry legs fail
  drop the exception entirely                      -> only the top-level legs fail
                                                      (round 15's defect returns)

Round 15's own case is a leg of the new test deliberately: without it
this pin would pass on a build that dropped the compat exception
altogether, which is the defect round 15 existed to fix.

Gates: gofmt clean · go vet clean · go test ./... green (29 packages) ·
contract-drift gate green
2026-09-03 19:27:32 +00:00
xarmian 6428e7db31 fix(store): lock, stamp and preserve on the relation retarget (BUG-2873)
Codex round 1: four findings, three P1, all real.

**The migrated siblings' concurrency token was not advanced.**
`collections.updated_at` doubles as the OCC token (BUG-2265), so rewriting a
sibling's schema without touching it left a client holding the PRE-rename schema
— and a token that still matched — able to write it straight back and undo the
migration. Every rewritten row now takes the rename's own token, so the whole
rename shares one instant. Pinned by asserting the stale token now 409s.

**The scan did not lock the rows it rewrites.** A concurrent schema update to a
sibling could commit between the SELECT and the UPDATE, and this transaction
would then overwrite the newer schema with its stale copy. `FOR UPDATE` on
Postgres, ordered by id so the multi-row acquisition is deterministic; SQLite is
covered by its BEGIN IMMEDIATE write lock.

**The old slug came from the pre-transaction snapshot.** Two tokenless
concurrent renames of the same collection both read the ORIGINAL slug outside
the lock; the loser would migrate `original -> its own new slug` while the
relations already said the WINNER's, matching nothing and stranding them at a
name no collection holds. The slug is now re-read alongside the token under the
row lock. This is the READ — the ALLOCATION of the new slug is still outside the
transaction and still IDEA-2874's, deliberately.

**Re-marshaling through `models.CollectionSchema` dropped unknown properties.**
That struct has fixed fields, so unmarshal+marshal silently erased anything it
does not declare — a rename would quietly strip forward-compatible metadata from
every relation-bearing schema in the workspace. It now edits the raw decoded
JSON, touching only `fields[i].collection`.

## Two instruments that were not instruments

Both found by mutation, not by reading:

- **The deadlock test passed against its own mutant in 0.44s.** Two
  unsynchronised goroutines never collided. With a start barrier and 40 rounds
  it now fails in 1.4s with `ERROR: deadlock detected (SQLSTATE 40P01)` — so
  Rook's hazard was reproducible, not theoretical.
- **The pre-tx-slug mutant survived the first matrix**, because nothing forced
  the interleaving. Rather than call it untestable, it is pinned by an end-state
  invariant that holds under ANY interleaving — whatever slug the collection ends
  up with, every relation aimed at it points there — over 40 concurrent rounds.
  It fails at round 1 under the mutant.

Mutation matrix 4 of 4 killed.

Gates: `gofmt` clean, `go vet ./...` ok, full `go test ./...` green on SQLite,
and the full `internal/store` suite green on **Postgres** — 448.6s on a private
container at 127.0.0.1:5473, never the shared 5445 a sibling seat may tear down.
Run detached with a sentinel after the first attempt was killed at a turn
boundary; the harness kills backgrounded tasks, it does not kill disowned ones.
2026-09-03 19:22:36 +00:00
xarmian 052850f613 fix(mcp): both gates ask the per-key question; compare like with like (BUG-2850)
Codex round 19, two P2 and no P1. Both were FALSE REFUSALS my own fixes
introduced — the first is round 17's mistake in the sibling gate.

[P2] THE SAME-NAME GATE WAS STILL PER-REQUEST. Round 17 made the padded
gate per-key and left this one asking whether the request has any
`fields` object. With `fields:{"other":"x"}` and
`field:["effort=l","effort=s"]`, `effort` is not in the object, nothing
arbitrates it but the doors themselves, and both keep the last entry —
so a call that resolves deterministically was refused. The predicate is
now `canonicalized`, the same per-key question the other gate asks.

`fieldsPresent` no longer exists anywhere in the pass, and its absence
is commented as the fix's shape: a future gate reaching for "does the
request have a fields object" is almost certainly this mistake a third
time.

[P2] TRIMMED AND UNTRIMMED VALUES WERE COMPARED. Entry values are
trimmed for comparison because ingestFieldKVP trims them; the `fields`
object's value was compared raw. `fields:{"note":" x "}` with
`field:["note= x "]` read as " x " vs "x" and refused, though both doors
write " x ". Only the COMPARISON key is trimmed now — `raw` and the
re-emitted wire value keep the caller's whitespace.

Mutation matrix:

  same-name gate back to per-request -> only the unrelated-key leg fails
  stop trimming for comparison       -> only the whitespace-equal leg fails
  trim the EMITTED value too         -> only the re-emission leg fails

THE THIRD MUTANT SURVIVED AT FIRST, and it was unreachable rather than
unobserved: the whitespace test's entry is already canonical, so the
re-emission path never ran and a mutant trimming the emitted value
changed nothing it could see. Added a leg whose KEY is padded, which
forces the re-emission, and asserted the emitted value still carries the
caller's whitespace. Third time this loop that asking "is the mutant
faithful" before "is the test weak" found a real hole (CONVE-28).

Control legs, both directions: a `fields` object that DOES carry the key
still refuses differing values, and genuinely different values are still
refused however they are padded.

Gates: gofmt clean · go vet clean · go test ./... green (29 packages) ·
contract-drift gate green
2026-09-03 19:17:31 +00:00
xarmian 4687c46f94 fix(store): migrate relation fields when their target collection is renamed (BUG-2873)
`models.FieldDef.Collection` holds the target's SLUG, and it is the ONLY pointer
a relation field carries — there is no id beside it to fall back on.
`UpdateCollection` re-slugifies on rename and nothing migrated the definitions
aimed at the renamed collection, so every relation field pointing at it was
stranded: the picker filters on a slug that resolves to nothing and the field
silently stops being fillable.

`retargetRelationFieldsTx` re-points them in the SAME transaction as the rename,
for the reason the field-value migrations already run there: a failure must roll
the rename back rather than commit collections pointing at a slug that no longer
exists.

**It parses instead of string-replacing.** A schema's JSON contains the old slug
in places that must not move — a text field's `default`, a select's `options`, a
label. Only `FieldDef.Collection` on a `relation` field is a reference. Export's
`remapFieldIDs` gets away with a blind replace because it substitutes UUIDs,
which cannot collide with prose; a slug is a word. A control test pins that.

**The renamed collection is included deliberately** — a relation targeting ITSELF
needs the same rewrite — and the rewrite lands after the caller's own `schema`
write in the transaction, so a simultaneous schema edit composes rather than
being reverted. Both have tests.

## The deadlock hazard, and why the existing comment does not cover it

The lock-order comment above this transaction is a Codex P1 fix that orders the
workspace lock against ONE collection row lock, because until now nothing took
more than one. This change writes SIBLING collection rows, so two concurrent
renames of mutually-referencing collections take those locks in opposite orders.

**Reproduced, not theorised:** with the serialization removed, the test fails in
1.4s with `ERROR: deadlock detected (SQLSTATE 40P01)` on Postgres. Renames now
take the workspace lock — previously acquired only when `len(input.Migrations) > 0`
— BEFORE the row lock, which closes it without inventing a second ordering rule
to keep in sync with the first.

**The first version of that test was not an instrument.** Two unsynchronised
goroutines passed against the same mutant in 0.44s, having simply never
collided. It takes a start barrier and 40 rounds to be evidence.

## Scope

The out-of-tx slug allocation (`uniqueSlugExcluding(s.db, …)` at :420, before
`s.db.Begin()` at :503) is deliberately NOT touched — filed as IDEA-2874. Its
dependents are every collection rename in every workspace, not the relation
feature, so it does not belong in a change whose reviewability rests on
affecting zero live rows. `UNIQUE(workspace_id, slug)` makes today's behaviour
loud rather than lossy, so it can wait.

A census found ZERO relation fields across all 11 accessible workspaces on this
instance, and no shipped template declares one — this repairs the rename path
before PLAN-2857 creates the population, which is why a migration is not needed.

Gates: `gofmt` clean, `go vet ./...` ok, `go build ./...` ok, full `go test ./...`
green on SQLite, and the full `internal/store` suite green on **Postgres**
(private container on 127.0.0.1:5473, never the shared 5445 a sibling seat may
tear down). Pin written and run BEFORE the fix per team CONVE-29: 2 propagation
tests failed, the control passed.
2026-09-03 18:56:38 +00:00
xarmian 21a3057389 fix(mcp): keep per-entry multiplicity in the conflict pass (BUG-2850)
Codex round 18, one P2 and no P1 — and the fix is upstream of the rules
rather than another rule.

parseFieldArray indexes by NORMALIZED key, so two entries naming one key
collapsed into a single index slot, and this pass walked that index. Its
own input was lossy: `field:["effort=l", " effort=l"]` arrived as ONE
contribution, fell under the len < 2 early exit, and passed unchecked —
HTTP trims both to `effort` while stdio writes `effort` AND a junk
`" effort"`. The pass claims to adjudicate one canonical key offered by
multiple sources; two array entries ARE multiple sources, and it could
not see them.

It now walks the raw entries, so multiplicity survives and the existing
rules apply unchanged — no new branch. The index is deliberately
discarded here and the discard is commented, because reaching for it is
the natural thing to do next.

I NEARLY CHANGED THE CODE TO SATISFY A WRONG TEST. The third leg was
first written asserting that two canonical entries with DIFFERING values
are refused. The code disagreed, and the code was right: ingestFieldKVP
(HTTP) and the --field loop in cmd_item.go (CLI) both do
`map[key] = val` in entry order, so each door keeps the LAST entry and
they agree. That is the round-7 boundary exactly — a visible duplicate
with a resolution the caller can predict — and refusing it would have
contradicted the boundary the lead confirmed, on two doors pinned to
agree. Verified by reading both loops before touching anything.

The leg is kept, inverted, because it is the one that stops a future
"refuse every repeated key" simplification from looking correct.

Mutation: restoring the collapse (walk one contribution per key) fails
exactly the padded-twin leg, and leaves the two control legs green.

Gates: gofmt clean · go vet clean · go test ./... green (29 packages) ·
contract-drift gate green
(go test ./internal/mcp/ -run 'CoversEveryCatalogAction|VersionMatchesToolSurface')
2026-09-03 18:55:15 +00:00
xarmian 130c854052 fix(mcp): canonicalization is a per-KEY property, not a per-request one (BUG-2850)
Codex round 17, one P1, and it is the third consecutive round where my
own fix generalized a property verified on one subset to the whole.

Round 16 gated the padded-entry refusal on "no `fields` object",
reasoning that a `fields` object makes reshapeItemFields re-emit the
entry canonically. That holds for keys IN that object. With `fields:{}`,
or a `fields` carrying some OTHER key, nothing canonicalizes
`field:["status = done"]` and it reaches the doors padded exactly as it
does with no `fields` at all — HTTP writes `status`, the CLI writes a
junk `"status "` beside it.

The predicate is now the actual question: will anything canonicalize
THIS key.

The pattern is worth naming because it is now a habit rather than an
accident:

  round 15 — a premise true of schema-declared params, applied to the
             compat IDs, which are undeclared precisely so it cannot hold
  round 16 — the check placed below the exemption, so it covered one key
             class (caught by my own test's control leg)
  round 17 — a per-key property read as per-request

Each time the fix was correct for the case in front of me and wrong for
its siblings, which is the same shape as the defects this unit started
with. The canonical restructure removed it from the CODE; it evidently
did not remove it from how I reason about the code.

Mutation matrix, both directions:

  revert to the per-request gate     -> only the two uncovered-key legs fail
  refuse even when canonicalized     -> only the canonicalized control fails

The third leg of the new test is the control that makes the distinction
real: with the key present in `fields` the entry IS canonicalized, so
the call must still succeed. A fix that refused whenever anything was
padded passes the first two legs and fails this one.

Gates: gofmt clean · go vet clean · go test ./... green (29 packages) ·
the contract-drift gate ran green
(go test ./internal/mcp/ -run 'CoversEveryCatalogAction|VersionMatchesToolSurface')
2026-09-03 18:41:00 +00:00
xarmian e64a1eb76b Merge pull request #1242 from PerpetualSoftware/feat/task-2868-relation-field
feat(web): relation fields — linked chip + picker (TASK-2868)
2026-09-03 14:32:49 -04:00
xarmian 3696686377 fix(mcp): a padded entry colliding with a param is not an equal duplicate (BUG-2850)
Codex round 16, one P1, and its placement is the whole lesson.

The conflict index is normalized — that is what lets a padded entry be
recognized as a collision at all — so `field:["k = A"]` compared EQUAL to
a top-level `k:"A"` and the pair was accepted while the entry stayed
padded on the wire. HTTP trims it and writes `k`; the CLI does not, and
writes a junk `"k "` key instead. The normalization that makes the
collision VISIBLE is exactly what made accepting it wrong: equality on
the normalized form licensed a collapse of the RAW forms, which are not
equal at all.

So equality only licenses a collapse when both doors receive the same
write, and this check now runs BEFORE the same-name exemption.

MY FIRST DRAFT PUT IT AFTER, which is round 15's mistake repeated one
round later. Round 15 was a premise verified for declared params and
generalized to two keys it did not hold for; placing this below the
exemption meant only the compat IDs were checked, when padding breaks
the exemption's own premise — both doors resolve the duplicate
identically — for EVERY key class. The declared-param leg of the new
test caught it, and the mutation matrix pins the placement rather than
just the behaviour.

Scope held: a padded entry standing ALONE, with no colliding param, is
untouched. That is BUG-2870, ruled out of this PR, and fixing it changes
what every CLI caller receives rather than only callers who supplied one
key twice. There is an executable test for that boundary, so growing
into BUG-2870's territory without a ruling goes red.

Mutation matrix, three directions:

  remove the check              -> both padded legs fail
  restrict it to compat IDs     -> only the declared-param leg fails (the first draft)
  extend it to a lone entry     -> the BUG-2870 scope-boundary test fails

gofmt clean · go vet clean · go test ./... green (29 packages)
context: 56.4% (session-shape)
2026-09-03 18:26:10 +00:00
xarmian dae7bbf233 fix(mcp): the same-name exemption holds only where the doors agree (BUG-2850)
Codex round 15, one P1, and it lands on the boundary I defended at round
7 and the lead confirmed.

The exemption's premise is "both doors resolve a same-name duplicate
identically". That is TRUE for a schema-declared param: the CLI has a
real flag, so stdio receives BOTH forms (`--status open --field
status=done`) and its overlay order resolves them exactly as the HTTP
mapper does. I verified that per door and pinned it.

It is FALSE for the v0.16 compat IDs, and being undeclared is precisely
why. BuildCLIArgs emits the CLI's real flags; there is none behind
`assigned_user_id`, so the top-level value is DROPPED and stdio sees
only the field entry while HTTP reads the param. `assigned_user_id:"A"`
with `field:["assigned_user_id=B"]` assigns two different people
depending on transport — with no `fields` object anywhere, which is why
the canonical pass's `fields`-gated half never saw it.

So I generalised a premise from the params I had verified to the two
whose whole nature is being unverifiable that way. The exemption is now
narrowed to keys the CLI can express; the compat pair refuses.

Swept for the prose this falsifies (CONVE-23): the v0.27 changelog entry
said the no-`fields` same-name case is "NOT refused, deliberately", full
stop. It now states the narrower rule and why the compat IDs are outside
it — the entry is the artifact a consumer reads to decide what this
version does, and leaving it broader than the code would have been worse
than never writing it.

Mutation matrix, both directions:

  restore the blanket exemption -> only the two compat legs fail
  remove the exemption entirely -> only the declared-param control fails

The over-narrow mutant did NOT compile on the first attempt (`declared
and not used: fieldsPresent`), which proves nothing about the tests, so
it was rewritten to compile before being counted. A compiler-killed
mutant is a failed experiment, not a passing one.

The declared-param control is in the same test deliberately: without it
this pin would pass on a build that abandoned the round-7 boundary
outright, which is the opposite defect and just as wrong.

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 18:08:06 +00:00
xarmian 4b6e321924 feat(mcp): bump ToolSurfaceVersion to 0.27 (BUG-2850)
The contract this branch changes is advertised in the handshake under
capabilities.experimental.padToolSurface, and agents branch on it. It
still said 0.26.

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

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

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

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

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

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

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 17:59:40 +00:00
xarmian bb62cfdc95 test(mcp): derive the conflict property's population from the declared schema (BUG-2850)
The lead's finding after round 14, and it is a sharper statement of what
went wrong than mine was.

The property test enumerated its sources by hand, and that hand-written
list came from the same head as detectFieldConflicts. A property whose
input list mirrors the implementation cannot see a source the
implementation forgot — which is exactly how the round-14 defect survived
it: the property SKIPPED param-vs-array pairs as "out of scope", which
was the implementation's assumption restated as a test assumption.

So the population now comes from the DOOR'S DECLARED CONTRACT — the live
pad_item ToolDef's parameter list, which is what agents read — and every
declared param must be either classified by the conflict machinery or
explicitly excluded with a reason. The two lists have genuinely different
origins (the tool schema vs the four key sets), which is the whole point:
a test that derives its expectations from the thing it checks cannot
fail.

It earned its place immediately by naming ten declared params I had not
classified. Each is now excluded WITH its reason, because a bare list
would let a future field-writing param be silenced by adding one word to
it — the round-14 mistake in miniature.

The interesting group is summary/details/decision/rationale. Those DO
change item state, so excluding them is a real claim rather than a shrug:
they write implementation_notes / decision_log through their own actions,
and those exact keys are REFUSED through `field` and `fields`
(BUG-2627 / BUG-2675), so they cannot reach one key by two routes — which
is the only thing this pass adjudicates.

The reverse direction is checked too: every key the machinery classifies
must be reachable through the declared schema, or be a documented
undeclared form (the v0.16 compat IDs, and `plan`, a fields_patch
pseudo-key with no top-level param). That fails if a key set goes stale
against the schema.

gofmt clean · go test ./internal/mcp/ green
2026-09-03 17:55:51 +00:00
xarmian eb37c0e53c fix(mcp): give the canonical pass full reach; equal structures collapse (BUG-2850)
Codex round 14, two P1 — both in the round-13 restructure, and the first
is the restructure repeating the mistake it was built to end.

[P1] THE CANONICAL PASS DID NOT REACH THE NO-`fields` CASE. It ran from
inside reshapeItemFields, which returns early without a `fields` object,
so an ALIAS pair arriving through the top level and the `field` array
alone slipped past: `assigned_user_id:"B"` with `field:["assign=dave"]`
applies the compat ID over HTTP while stdio drops it and sends only the
generic field. Two different people assigned, from one call.

The tell is that round 7 had ALREADY built an always-run alias guard —
for the hierarchy pair only. So the restructure meant to end guard
accretion had itself left two alias mechanisms with different reach, and
the pair the older one covered is exactly the pair that kept working.
detectFieldConflicts now parses its own inputs and runs from both action
entry points regardless of `fields`; checkHierarchyAliasAmbiguity is
deleted as subsumed. One mechanism.

The alias half is ungated; the SAME-NAME half stays gated on `fields`,
which is the round-7 boundary the lead confirmed and is unchanged.
Last-write-wins is defensible when both sources name one key and
indefensible when two names address one target through different
vocabularies — a slug and a UUID cannot be compared, so co-occurrence is
ambiguous however it arrives.

[P1] EQUAL STRUCTURES WERE REFUSED. `tags:["a"]` plus
`fields:{"tags":["a"]}` is one unambiguous value, and scalarEqual had
always collapsed it; the round-13 pass refused whenever either side was
structured. A regression I introduced, that nothing in the suite caught
because every existing tags test passes a structure on one side only.
Equal structures now collapse, differing ones still refuse.

The property test is widened rather than merely extended: it previously
SKIPPED param-vs-array pairs as out of scope, and that exclusion is
precisely where the round-14 defect lived. It now covers every source
pair — 72 combinations, up from 48.

Mutation matrix:

  re-gate the alias half on `fields`        -> property fails on the param×array legs
  revert equal-structure collapse           -> only EqualStructuredDuplicateCollapses fails
  make the collapse unconditional           -> only DifferingStructuredDuplicateRefused fails

The last two are the both-directions pair: under-apply and over-apply
each fail exactly one leg, so the pins bracket the behaviour instead of
agreeing with it from one side.

Control kept explicit: the round-7 same-name boundary still passes on
BOTH doors (internal/mcp + cmd/pad SameNameDuplicate tests), so widening
alias detection did not quietly swallow the case that resolves.

gofmt clean · go vet clean · go test ./... green (29 packages)
context: 45.1% (session-shape)
2026-09-03 17:52:19 +00:00
xarmian 6f7c09a44a fix(web): judge a relation's collection only when the list and index agree (TASK-2868)
Codex round 2, P1, real — and it is the retag window my round-1 fix left open.

`retagCollection` moves the indexed ROWS onto the new slug immediately;
`collectionStore.loadCollections(ws)` is fired next to it with `void` — not
awaited, and its rejection swallowed. So between those two there is a state
where the collection list still holds the OLD slug (making the declared target
read as 'live') while the row already carries the NEW one. Judging the mismatch
there reported the value as "Unresolved reference" — and because that refetch is
unawaited and its failure unobserved, the state is PERMANENT when it fails, not
a paint-frame flicker.

The fix reframes what the mismatch is evidence OF. A collection mismatch means
the value is wrong only when the collection list and the item index agree about
the world — that is, when the current list knows BOTH the declared target and
the row's own collection. Two ways they disagree, and neither is the value's
fault: the target was renamed away (round 1), or the rename reached the index
before the list (this round). Requiring both slugs to be known collapses both to
"don't judge", while a genuine cross-collection value — target `colors`, row
`tasks`, both live — still resolves to null.

That is also why this is not fixed by invalidating freshness: `collectionsAreFreshFor`
answers "loaded for this workspace", not "current", and teaching it about
pending/failed refreshes is a store-wide change to serve one consumer. The
agreement test needs nothing new.

New control leg alongside it, because two "don't judge" guards in a row are one
edit away from never judging: both slugs live, mismatch, still rejected.

Mutation matrix 17 of 17 killed (N17 new — judge as soon as the target reads
live, i.e. round 1's shape; N11/N14/N15 anchors refreshed).

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2063 tests green. Context 55.8% at this boundary
(`session-shape`, which lives at /home/dave/claude/bin and is not on PATH — my
earlier "not measured" reports read `command -v` failing as the tool not
existing).
2026-09-03 17:46:44 +00:00
xarmian 1de4fd48dc refactor(mcp): one canonical view, one conflict check (BUG-2850)
Codex round 13 found a fourth consecutive defect in a prior round's fix,
which fired the lead's restructure trigger. The finding and the ruling
are the same observation from two directions.

THE FINDING. `assign`/`assigned_user_id` and `role`/`agent_role_id` are
two names for one target, exactly like `parent`/`plan` — and none of the
five guards standing at round 12 compared them. The alias guard knew
only about hierarchy; the compat guard only about same-name collisions.
So `assigned_user_id:"B"` with `fields:{"assign":"A"}` was accepted and
the doors disagreed: resolveAssignName gives the explicit ID precedence
over HTTP, while BuildCLIArgs drops the compat ID and emits `--assign A`.
One call, two different people assigned.

THE RULING. Stop adding guards. Conflict handling had accreted one at
every site that noticed a problem — generic path, promoted block, alias
check, compat block, canonicalization predicate — and each covered only
the sources its author thought about. Round 13's finding is that shape's
signature, not a new one.

WHAT CHANGED. `detectFieldConflicts` resolves every source (the `fields`
object, the `field:[]` entries, the promoted params, the compat IDs) to
a canonical key via `fieldAliasGroups`, then refuses on the resulting
map. Alias collision refuses even when the values match — the names
address one target through different vocabularies (a slug vs a UUID), so
"equal" is not a question this layer can answer. Same name from two
sources keeps the old rule: equal collapses, differing refuses. The five
guards are gone; the per-key branches now do emission only, and they run
knowing the input is unambiguous.

A new alias pair is one line in a map rather than a sixth guard.

SCOPE, unchanged and stated: this runs only when a `fields` object is
present, because reshapeItemFields does. A top-level param colliding
with a `field:[]` entry and no `fields` object keeps its documented
last-write-wins resolution — the round-7 boundary the lead confirmed,
still pinned on both doors.

EVIDENCE. All 30-odd existing case tests pass unchanged against the new
structure; they are the regression net the ruling asked to keep. Added:
the round-13 case tests over both directions of both pairs, and a
PROPERTY test derived from `fieldAliasGroups` itself — for every alias
class, every ordered pair of member names, and every pair of distinct
sources, the call refuses. It covers 48 combinations and a future alias
pair extends it with no edit.

Mutation matrix:

  drop assigned_user_id from the alias map -> property fails
  unwire detectFieldConflicts entirely     -> 54 tests fail
  remove the alias-collision branch        -> property fails (equal-value legs)

The third mutant SURVIVED at first, and the reason is the useful part:
my property used differing values, so the ordinary same-canonical-key
comparison refused anyway and a build with alias detection wholly
removed still passed. The equal-value leg is the one only alias
detection catches — exactly the semantics the parent/plan ruling
established — so the property now drives both value shapes. CONVE-28
again: on SURVIVED, the mutant was faithful and the test was weak.

gofmt clean · go vet clean · go test ./... green (29 packages)
context: 45.1% (session-shape)
2026-09-03 17:42:42 +00:00
xarmian 09f80b3d4f fix(web): a renamed target collection must not read as lost data (TASK-2868)
Codex round 1 on this unit, P1, correct — and it is a defect the PREVIOUS
commit introduced.

`models.FieldDef.Collection` holds the target's SLUG (the schema editor binds
`<option value={c.slug}>`), and `store.UpdateCollection` re-slugifies on rename
without migrating the relation definitions that point at it — the string
"relation" does not appear in that file at all. Meanwhile `localIndex.applyRetag`
correctly moves the indexed ROWS onto the new slug. So after a rename the field
and the rows disagree, and the collection check added last commit reported every
stored value as "Unresolved reference": a schema problem presenting to the user
as lost data, on data that is completely fine.

Now three-valued. The collection check applies only while the declared target
still names a LIVE collection; a stale target falls back to id-only resolution
so the chip keeps rendering, and the field goes read-only because a picker aimed
at a renamed collection would list nothing (`getByCollection` and
`localSearch` both filter on that slug). `'unknown'` — the collection list not
yet loaded — is deliberately NOT read as stale: that is absence of evidence, and
treating it as stale would flash every relation field into read-only on first
paint.

Filed **BUG-2873** for the root cause, with both candidate fixes (migrate
dependent schemas on rename, or store the collection ID) and the argument that
the second is what PLAN-2857's own "store the ID, titles change" reasoning
implies for the collection pointer too.

**Two of the three new tests were wrong first, in ways that let a mutant live.**

- The stale-target test set up the STORE's collection list but left the row's
  `collection_slug` matching `field.collection` — so field and row agreed, and
  the mutant making the check unconditional passed. A rename retags the rows;
  modelling only half of it reconstructs a scenario that cannot fail.
- The unknown-vs-stale test asserted the chip renders. A stale target also
  renders the chip, so it could not tell the two apart. It asserts EDITABILITY
  now, which is the only thing that actually differs.

Mutation matrix 16 of 16 killed, including the four new ones (N14 stale target
invalidates values, N15 unknown reads as stale, N16 stale target stays editable,
plus N11 refreshed for the new shape).

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2061 tests green; `go build ./...` ok, gofmt clean.
2026-09-03 17:33:24 +00:00
xarmian af686c350d fix(mcp): a blank top-level param does not block the fields answer (BUG-2850)
Codex round 12, one P1 — and the fix is bigger than the finding, twice
over.

THE FINDING NAMED ONE KEY. `{status: "", fields: {status: "done"}}` was
refused, because the duplicate check treated a present-but-empty
top-level param as a competing value. `""` is "not supplied" everywhere
else on this surface — promotedParamValue treats it as absent, the CLI's
`status != ""` guards do, `assign: ""` is documented inert — so a client
that zero-fills its optional params was refused for asking one question.

Driving the whole class the key belongs to (CONVE-18: the reviewer names
an instance, the fix owes the population) turned `status` into all six
promoted keys, which behave identically. Round 10 fixed exactly this for
the hierarchy keys and I never asked whether the same reasoning covered
their siblings; it did. Third time in this unit that a guard was written
for the path in front of me, and this time the guard was mine.

THE SAME PROBE FOUND THE EXCEPTION, which matters more than the fix. For
`assigned_user_id` / `agent_role_id` an empty string is NOT absence — it
is a CLEAR to NULL, the deliberate v0.16 semantics
(dispatch_http_advanced.go forwards "" verbatim for exactly these two).
Applying the finding uniformly, as its wording invites, would have
discarded a clear in favour of the `fields` value: a spurious refusal
traded for a silent wrong write, which is the worse half. They stay
conflicts, with their own pin.

AND THEN A SURVIVING MUTANT CAUGHT A FALSE COMMENT OF MINE. Deleting the
compat carve-out failed nothing — because the carve-out lived in a
helper that only the PROMOTED block called, while the compat keys were
checked in a separate block that never consulted it. Dead code, and the
comment I had just written called it "the whole reason this is a
function". CONVE-28's rule is what caught it: on SURVIVED, ask whether
the mutant is faithful before blaming the test. The mutant was faithful;
the code was redundant and the prose was wrong. Both call sites now
consult the predicate, so the rule has one home and the carve-out is
live — re-running the same mutant against the corrected code fails
BlankCompatIDIsAClearAndStillConflicts, as it always should have.

Mutation matrix:

  revert the blank-param exclusion -> only BlankTopLevelParamDoesNotBlockFields fails (6/6 subtests)
  delete the compat carve-out      -> only BlankCompatIDIsAClearAndStillConflicts fails (2/2)
                                      [survived before the redundancy was removed — see above]

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 17:27:34 +00:00
xarmian 5079a532c9 fix(web): resolve a relation by id in its own collection; collapse the picker (TASK-2868)
Three defects, all found by driving the Cars/Colors example from IDEA-2856 in a
real browser against a locally built binary. None of them showed up in the
twelve component tests, and two are mine.

**1. A legacy free-text value rendered as a working reference.**
`localIndex.findByIdOrSlug` resolves by id OR SLUG, so the string `"red"` —
exactly what the old text fallback has been writing into these fields — resolved
to the item slugged `red` and rendered as a live chip. The field's contract is
that it stores an item ID; a slug match makes the chip lie about what is stored,
and slugs are mutable, so the same value could point elsewhere tomorrow. Now
resolves by id only, and the browser leg that was meant to prove "a legacy value
reads as unresolved" is the one that caught it.

**2. It could resolve into the WRONG COLLECTION.** That helper is
workspace-wide, so a relation declared against `colors` would render an item
from `tasks` sharing the identifier. This is the same defect PLAN-2857's recon
recorded against the server's `ResolveItem` — I wrote that finding down in the
design doc and then reproduced it in my own client code a few hours later.

**3. The field showed a permanently-open search box.** The first browser pass
rendered the chip, the picker input still holding the query, and the result list
still listing the row just chosen — the same item three times, under every
relation field on the page. A field shows its VALUE; the picker is for changing
it. Now: chip + Change / Clear, picker on demand, closing when a choice is made.

Also **filed BUG-2872** rather than absorbing it: the activity timeline on the
same page renders a relation change as `color: → <uuid>`. The panel now honours
IDEA-2856's "never a bare UUID"; that surface does not. The e2e invariant is
scoped to the field row on purpose, so the gap is recorded rather than hidden by
loosening the assertion to the page body.

Mutation matrix 13 of 13 killed. N13 (emit the value but leave the picker open)
survived the first pass — the test asserted the emit and not the CLOSE, which is
the half the browser pass had rejected. Two mutants that had to die separately
do: N1 (drop the deleted branch) kills leg (b), N2 (unresolved reads as deleted)
kills leg (c), so the two states are genuinely distinguished rather than sharing
a branch.

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2058 tests green.
2026-09-03 17:18:36 +00:00
xarmian c2bb1bad34 fix(mcp,server): close three codex round-11 findings, one of them my own bad refutation (BUG-2850)
Two P1 and one P2. The P2 is the important one, because I had already
dismissed it in round 10 and was wrong.

[P2 — CORRECTION] Artifact imports DO drop undeclared-field warnings,
and the case is reachable. Round 10 refuted this on the grounds that
artifact.Decode populates Fields only from FieldKeysForKind, so no
undeclared key could arrive. That check was real, and it was the WRONG
SIDE of the comparison: UndeclaredFieldKeys compares the field map
against the DESTINATION COLLECTION'S SCHEMA, not against the artifact
format's key list. The destination schema is editable, so a canonical
artifact key can be undeclared THERE while being perfectly legal in the
artifact.

Verified before reinstating, not argued: narrow the conventions
collection's schema to declare only `status`, import an ordinary
convention carrying trigger/scope/priority — the blob stores all three
and UndeclaredFieldKeys names all three. The merge is back, and the
comment now records the correction rather than the refutation, so the
next reader inherits the right reason.

What I got wrong is worth naming exactly: I verified a true fact and
then drew a conclusion one step wider than it supported, because I never
asked what the OTHER operand of the comparison could be. "No key outside
the artifact's list arrives" does not imply "no undeclared key arrives"
unless the destination declares every key on that list — an assumption I
never stated and never checked. The test I wrote at the time could not
pass, and I read that as confirming the refutation instead of as the
setup being wrong.

[P1] An empty hierarchy value inside `fields` was a silent no-op.
`fields:{"parent":""}` promotes onto the top-level `parent`, where both
doors treat empty as NOT PROVIDED — so the call reported success and
detached nothing. Refused now, pointing at clear_parent and the raw
`field:["parent="]` form. Not silently promoted to a clear: that decides
what this door MEANS, and v0.19 already made clear_parent canonical so
the empty string would not have to carry it.

[P1] The v0.16 compat ID params were not conflict-checked against
`fields`. Never schema-declared by design, they are invisible to
padItemPromotedFieldKeys and took the generic path, where the check only
consults the `field` array. `assigned_user_id:"A"` with
`fields:{"assigned_user_id":"B"}` made the doors disagree outright — the
remote mapper reads A, stdio emits only `--field assigned_user_id=B`,
because the top-level form has no CLI flag behind it. One call, two
different people assigned. Conflicting values refuse; equal ones
collapse to one form.

Also corrected, per CONVE-23: the round-10 test asserting that
`fields:{"parent":""}` conflicts with the plan alias now refuses for a
DIFFERENT reason and its stated rationale had become false. The case
moved to the new test with the right reason, and the field-array clear —
which really is an effective directive — stays where it was, with a
control leg proving the new refusal did not swallow it.

Mutation matrix, each mutant from a file backup:

  revert the empty-hierarchy refusal -> only EmptyHierarchyValueInFieldsRefused fails (both subtests)
  revert the compat-ID check         -> only CompatIDConflictRefused fails (both subtests)
  revert the import warning merge    -> only the narrowed-schema import test fails

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 17:12:20 +00:00
xarmian a04233aaa9 feat(web): relation fields render a linked chip and edit through the picker (TASK-2868)
PLAN-2857 U2. Absorbs the relation half of TASK-2216.

Before this, `relation` fell through `fields/FieldEditor.svelte`'s `{:else}`
text fallback: an editable free-text input in edit mode, and `{value ?? '—'}`
in display mode. Since `internal/items/validate.go:275` accepts ANY string for
a relation, that combination did not merely fail to edit — it SAVED. Typing
"red" into a relation field stored the literal string and showed no error, and
the display arm rendered a raw UUID when the value happened to be one. So U2 is
closing a silent corruption hole, not adding an editor to a read-only field.

**Three render states, not two.** A value that resolves to nothing and a value
whose target was deleted are different facts about the item, and the third is
the COMMON case on existing data — arbitrary strings are what the old fallback
has been writing. All three resolve locally: `localIndex` holds soft-deleted
rows alongside live ones (`getByCollection` filters them out rather than
dropping them), so a dangling target is a row carrying `deleted_at`. No fetch,
no loading state. The invariant across every state, asserted in every leg: a
raw item ID never reaches the user.

**The branch is gated on `wsSlug` AND `field.collection`, and the second call
site sits on the far side of that gate deliberately.** `CopyItemDialog` builds
its `FieldDef` from a preflight row whose shape carries no `collection`
(`ItemCopyPreflightNeedsValue`), and it copies ACROSS workspaces — so an
unscoped picker there would offer SOURCE-workspace items as the value for a
DESTINATION-workspace field, and look authoritative doing it. A free-text box
at least looks like something the user owns. Read-only is the honest state
until TASK-2869 (U2b) extends the preflight contract; U1 makes the garbage
write a 400 in the meantime.

That gate is a fact about the CALLERS, invisible from the component's own
render tests, so both sides are asserted at the call sites
(`fieldEditorRelationCallers.test.ts`) — including that `toFieldDef` still
builds from a shape with no `collection`, which fails loudly when U2b lands
rather than letting the gate drift.

Pin first, per team CONVE-29: the test file was written and run BEFORE the
branch existed — 4 failed / 2 passed, the two passers being the gate legs,
which pass vacuously while no picker exists anywhere. That is why they ship
with a control leg that mounts one.

One pin leg was STRENGTHENED rather than relaxed when it failed against the new
code: leg (a) asserted "some anchor exists" and failed because the test passed
no `username`, which is what builds the href. The fix was to give it one and
assert the exact href, plus a new leg (a2) for the resolved-but-no-route case —
where the chip degrades to a non-link and must still name the item rather than
degrade to the raw value, which is precisely what the old arm did.

Gates: `npm run check` 1092 files 0 errors, 6 pre-existing warnings; full web
suite 122 files / 2048 tests green.
2026-09-03 17:07:36 +00:00
xarmian 7fde7a3cbc Merge pull request #1241 from PerpetualSoftware/feat/task-2862-relation-picker
feat(web): shared ItemPicker — extract the add-relationship search (TASK-2862)
2026-09-03 13:00:58 -04:00
xarmian 0a71ad3aa9 fix(mcp): an empty parent param is not a hierarchy directive (BUG-2850)
Codex round 10: three P2, no P1. One fixed, one already filed, one
refuted and reverted.

[FIXED] An empty top-level `parent` was counted as an alias directive,
so `parent: ""` with `fields:{"plan":"X"}` refused a perfectly good
call. Every declared string param on this tool treats "" as NOT
PROVIDED — it is why promotedParamValue does, and why `assign: ""` is
deliberately inert — so a client that fills declared optional params
with their zero value rather than omitting them got a refusal for
asking one hierarchy question. My own round-9 snapshot carried this
forward from the out[]-based check it replaced.

Deliberately NOT applied to the other empty forms: `field:["parent="]`
and `fields:{"parent":""}` are the documented CLEAR signal
(BUG-2013 / BUG-2078), so they are semantically effective and still
conflict. One is a param left blank, the other is an instruction that
happens to look like one. Both directions are pinned, and the mutation
matrix drives both:

  remove the empty-param exclusion -> only EmptyParentParamIsNotAnAliasConflict fails
  extend it to the field-array clear -> only EmptyClearFormsStillConflict fails

[ALREADY FILED] The transport-dependent whitespace finding is BUG-2870,
ruled out of this PR's scope. Round 10 did add something the filing
missed and BUG-2870 now records it: the divergence covers VALUES too
(`--field "cost= 3"` stores the number 3 remotely and the string " 3"
over stdio), which is worse than the key half because both doors report
success and only the stored type differs.

[REFUTED, REVERTED] "Artifact imports discard createItemChecked's
undeclared-field warnings." True as a code reading — this handler builds
its own response shape and ignores item.Warnings — but the condition is
unreachable. artifact.Decode populates Fields exclusively from
FieldKeysForKind via a closed switch over a typed frontmatter struct, so
a key outside that per-kind list never enters the map. Verified both
ways before reverting, because the import door takes raw bytes and the
hand-written case is the one that mattered: Encode drops extra keys, and
Decode of a hand-written artifact carrying extra frontmatter keys drops
them too.

I had written the merge and a test for it before checking; the test
could not pass through the public door, which is what exposed the
finding rather than my fix. Reverted to a comment recording the
mechanism, so the next reader — or the next round — does not re-find it.
A branch nothing can enter is not defence in depth, it is a claim that
something is handled when it never happens.

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 16:53:14 +00:00
xarmian 56ee3a7e95 fix(mcp): require strings for fields.assign/role; fix the alias refusal's mechanism (BUG-2850)
Codex round 9: one P1 and one P2. The P1 was REFUTED on inspection and
the P2 confirmed; both produced a change, for different reasons.

[P2, real] `fields.assign` / `fields.role` accepted a number, and the two
doors then disagreed about it. The HTTP dispatcher's
`rawAssign.(string)` turns a float64 into "" and treats it as NOT
PROVIDED, silently dropping the write; stdio emits `--assign 123` and the
CLI fails loudly on the lookup. Same call, one door silent and one red.
Refused now at the door-independent layer, which is what stops them
drifting apart again rather than teaching each dispatcher separately.

Deliberately narrow: this does NOT walk back round 6's decision to accept
non-string promoted values in general. `priority` may legitimately be a
number in a custom schema and create has always passed such values
through — a control leg pins that. `assign` and `role` are references
that NAME something, where a number has no meaning at all.

[P1, refuted] `fields:{"parent":"A","plan":"B"}` was already refused. But
it was refused by ACCIDENT: keys process in sorted order, so `parent` was
promoted into out["parent"] and `plan` collided with it one iteration
later. Right answer, wrong mechanism — the refusal depended on `parent`
sorting before `plan` AND on `parent` being a promoted key, and it told
the caller their value conflicted with "the top-level parent param" when
no such param was passed. The fields-vs-fields case is now checked
against `obj` directly, and a snapshot of the original top-level params
keeps that message honest.

Reported as verified rather than as agreement: the finding's mechanism
was wrong, and shipping "fixed" against a refuted claim would have put a
false statement on the trail.

Mutation matrix, from file backups:

  revert the identity-ref requirement -> only NonStringIdentityRefRefused fails (both subtests)
  revert to the out[]-only alias check -> only BothAliasesInOneFieldsObject fails

Plus a PROBE that is not a mutant of the fix: removing `parent` from
padItemPromotedFieldKeys leaves the alias pair still refused. Under the
old code that mutation made the guard go silent, since nothing would
write out["parent"] — which is the latent coupling this change removes.

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 16:32:51 +00:00
xarmian 49e533d478 test(mcp,cli): pin same-name duplicate precedence on both doors (BUG-2850)
The lead's condition on the round-7 boundary. checkHierarchyAliasAmbiguity
refuses parent+plan — two NAMES for one target, which a caller can
collide without knowing — but deliberately does NOT refuse a same-name
duplicate (`--status A --field status=B`), because those are visibly
duplicates and both doors resolve them identically.

"Both doors resolve them identically" is the load-bearing half of that
argument and nothing enforced it. Two tests now do, one per door,
asserting the SAME outcome: the `field` entry overlays the named param,
because cmd_item.go and dispatch_http_advanced.go both apply named flags
first and overlay --field after.

Per-door mutation matrix, run this turn from file backups:

  make the named param win on the HTTP door -> only the mcp test fails
  make the named flag win on the CLI door   -> only the cmd/pad test fails

Neither mutant reddens the other door's test, which is the property
worth having: the doors cannot drift apart again without exactly one of
these going red and the boundary getting re-examined rather than
silently becoming untrue.

Also filed, per the lead's ruling: BUG-2870, the padded-`field`-key
divergence with NO `fields` object (`--field " effort=l"` stores an
undeclared " effort" key on the CLI door and writes `effort` on the
remote one). Out of scope here — it predates this PR's claim rather than
defending it — and its fix is a policy call on the CLI's input contract,
so it wants a ruling, not a quick patch.

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 16:22:00 +00:00
xarmian 4937fd84f6 fix(mcp): canonicalize when ANY entry for the key is padded (BUG-2850)
Codex round 8, one P2 and no P1 — the first round of this unit that did
not turn up a correctness defect on the fields-vs-field seam.

Round 7's canonicalization asked whether a canonical entry was PRESENT
and left the array alone if one was. So `field:["effort=l", " effort=l"]`
with `fields:{"effort":"l"}` kept the padded twin, and the doors then
disagreed about it: HTTP trims and writes `effort`, the CLI does not and
writes an undeclared `" effort"`. Transport divergence out of a call both
doors accept — the shape this unit exists to remove, reintroduced one
round earlier by the fix for its sibling.

The predicate is now "any entry for this key is non-canonical", so the
key is re-emitted once and cleanly. Collapsing the duplicate pair is not
lossy: parseFieldArray already indexes both to a single value, so two
entries for one key were never two writes.

Mutation: restoring the round-7 predicate verbatim fails only
MixedCanonicalAndPaddedDuplicatesCollapse. Round 7's two pins still pass
under that mutant, which is correct — neither exercises the mixed case,
and that is exactly why the new one was owed.

NOT fixed here, and named so it is not mistaken for an oversight: a
padded entry with NO `fields` object at all (`field:[" effort=l"]` alone)
still reaches the CLI door untrimmed. That predates BUG-2850, is
unrelated to the fields merge, and normalizing every entry
unconditionally changes what the CLI receives for every caller — a
policy change, not a defect fix. Flagged to the lead on the trail.

gofmt clean · go vet clean · go test ./... green (29 packages)
2026-09-03 16:17:49 +00:00