mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
c4d429d14c94de941ac761333cdac0a33895a05f
886 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c4d429d14c |
fix: three codex round-6 findings, one premise corrected (TASK-2878)
Round 6 confirmed round 5 and found three. All three are real; one arrived
with an account of its own cause that the test refuted, and the corrected
route is narrower than the report.
## A NON-STRING RELATION DEFAULT, AND WHERE IT ACTUALLY GETS IN
The finding: injected defaults are never type-checked, so `42` or `[]` can
persist in a relation field. True, but not by the route described.
`MigrateFields` injects destination defaults ITSELF, so in the ordinary case
the key is present when `ValidateFieldsDetailed` runs and its type IS
checked — a numeric default lands in needs_value with "must be a string",
which is correct behaviour. My first test asserted the wrong thing and
FAILED against the fixed build, which is how I found this out.
The unchecked route is narrower: a NULL OVERRIDE deletes the key after
MigrateFields filled it, so validation injects the default itself — and its
own injection branch `continue`s PAST the type check. That is the one way a
non-string reaches a relation field unchallenged. The late-default pass owns
values that arrive from defaults, so it reports this one:
`invalid_shape`, a new reason, because every existing reason describes a
lookup that never happened.
Retargeting the test then exposed a second defect IN MY OWN FIX: the late
pass's `if len(late) == 0 { return nil, nil }` discarded the non-string drops
it had just recorded. The value vanished from all three buckets. Green on the
first route, silent on the second.
THE PARITY GATE FROM AN EARLIER COMMIT CAUGHT THE NEW REASON: adding
`invalid_shape` failed TestCopyPreflightDropReasonsAreRenderedByTheDialog
until the TypeScript union and CopyItemDialog learned it. That gate exists
because `referent_not_portable` shipped unrendered in BUG-2674, and it just
did its job on its author.
## A WHITESPACE-ONLY VALUE IS "NO REFERENCE", NOT A BAD ONE
The store resolver trims and ignores `" "`. The server wrapper checked the
UNTRIMMED string, so the value fell through to the visibility loop — and
since round 1's vanished-target arm turns a missing lookup into a refusal,
`" "` came back as not_found instead of an empty field. A defect my own
round-1 fix introduced: before it, that path did `continue`.
## A MALFORMED CARRIED VALUE IS NOT "NOT PORTABLE"
The cross-workspace branch dropped every carried value without looking,
including non-strings, and labelled them `referent_not_portable` — a false
account of why the value is going. It is not a reference at all. Left in
place now for ValidateFields to reject on shape, which is what the
SAME-workspace branch already did with it: the two modes disagreeing about
one malformed value was the defect.
## Counterfactuals
Skip non-string defaults again -> TestCopyEndpoint_NonStringRelationDefault
DETECTED. Restore the untrimmed skip -> TestRelationDoors_WhitespaceOnly
DETECTED. Both build-checked, and the first form of the second mutant did NOT
build (unused import) — reported as such rather than scored, since a
non-compiling mutant produces no failures and reads as survived.
Gates: internal/server ok 324.8s · internal/store ok 344.5s · internal/mcp ok
16.9s · go vet clean · gofmt clean · make lint 0 issues · npm run check 0
errors. Postgres green on the parent commit (store 596.1s, server 299.4s);
re-running on this tree.
|
||
|
|
ae8551274b |
fix: codex round-5 finding — the other direction of the origin guard (TASK-2878)
One finding, P2, and it is the tail of round 4's fix. Round 4 stopped labelling a null-source key `migrated`, because validation treats null as missing and fills the DESTINATION's default in its place — so `migrated` named the source for a value the destination chose. With NO destination default there is nothing to fill it: the null is what carries, and `default` names a value the schema never declared. One guard, wrong in both directions, and the reason is the same in both: "was the key present in the source" is not "where did the final value come from". The label now follows the value: - source value, non-nil -> migrated - source null, destination default -> default - source null, NO default -> migrated `from` is what a dialog uses to say "this came across" versus "this is the destination's default", so either error is the preview misreporting the thing it exists to report. ## Counterfactual Remove the round-5 branch so a null source always reads as `default` -> TestCopyEndpoint_NullSourceWithoutDefaultIsNotReportedAsDefault DETECTED, build-checked first. The mutant also confirms the key really is in `carried` in that scenario — the test returns without asserting if it is not, so without the mutant its green would have been consistent with a vacuous pass. Gates: internal/server ok 288.8s · internal/store ok 302.4s · internal/mcp ok 13.7s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on the parent commit (store 626.4s, server 311.9s, private container port 5481); re-running on this tree. |
||
|
|
f4d733e57e |
fix: two codex round-4 findings — oracle race, null-source provenance (TASK-2878)
Round 4 confirmed round 3 and dropped from P1 to P2, which is what
convergence looks like. Both still real.
## THE ORACLE COLLAPSE HAD A HOLE, AND MY OWN COMMENT ASSERTED IT DID NOT
Round 3's fix collapses `wrong_collection` to `not_found` when the requester
cannot see the target. It needs the item to ask that, and when the second
lookup came back nil — deleted between the resolver's read and this one — it
did `continue` under a comment reading "vanished since; already the safe
answer".
That comment was wrong. Nothing had rewritten the reason, so the issue still
said `wrong_collection`, whose message tells the caller the value named
something a moment ago. The disclosure round 3 closed, reachable by a race.
Collapsed now.
Worth stating plainly because it is the same defect twice: I wrote a comment
asserting a state ("already safe") that the code did not establish, and I
wrote it in the fix for exactly that disclosure.
## A NULL SOURCE VALUE IS NOT A CARRIED ONE
The preflight marked any key PRESENT in the source's field map as
`from: "migrated"`. A source key holding `null` is present and carries
nothing: MigrateFields keeps the key, the relation pass skips a nil value,
and validation then treats it as missing and fills the DESTINATION's default
in its place. So the response reported the source as the origin of a value
the destination chose.
`from` is what a dialog uses to say "this came across" versus "this is the
destination's default", so naming the wrong one is the preview lying about
the thing it exists to report. Presence with a VALUE is the test now.
Not relation-specific — any nulled source field with a destination default
had it — so the guard is on the shared origin loop rather than beside the
relation pass.
## Counterfactuals
Remove the null guard from the origin loop ->
TestCopyEndpoint_NullSourceRelationIsNotReportedAsMigrated DETECTED
(build-checked first).
The oracle-race collapse has NO test, for the reason the vanished-target
refusal has none: reproducing it means deleting a row between two lookups
inside one request, and a test that faked the interleaving would pin the
fake. Both are listed in the PR body under "Not tested, and why".
Gates: internal/server ok 343.8s · internal/store ok 349.9s · internal/mcp ok
22.1s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on
the parent commit (store 553.9s, server 297.1s, private container port 5481);
re-running on this tree.
|
||
|
|
8fedc5487c |
fix: three codex round-3 findings — oracle, bulk defaults, required (TASK-2878)
Round 3 confirmed round 2's fixes and found three more. Codex still could not execute anything, so all three were static reads I verified myself. ## `wrong_collection` DISCLOSED EXISTENCE The store emits `wrong_collection` when a value names a LIVE item outside the field's declared target, and the server's visibility layer skipped any key that already carried an issue. So the message — "is not an item in collection X" — told a caller the value EXISTS, distinguishably from the `not_found` a nonexistent value gets. An existence oracle for anyone who cannot see that item, and the exact shape the `not_found` collapse exists to prevent. Collapsed to `not_found` when the requester cannot see the target, and KEPT otherwise: "you linked a task where a person belongs" is the useful half of this reason, and blanket-collapsing would have passed the security leg while destroying it. Both legs are in the test for that reason. Needed one new store export, `ResolveRelationTarget`: deciding what to disclose about a live item requires the item, and the resolver is the only thing that maps a value to one under the no-slug rule. ## BULK STATUS / PRIORITY BYPASSED RELATION DEFAULTS `bulkFieldUpdate` resolves only the keys `changes` names — correctly, since re-litigating stored values would freeze legacy items — but `ValidateFields` runs BEFORE it and INJECTS schema defaults. A defaulted relation was persisted raw: never canonicalised, never checked against its collection. The same late-arrival the migrate doors hit, reached by a different route, and the narrow pass built for them covers it unchanged. `dropped_fields` now rides every bulk op's activity row, not just the move branch: a status or priority change can discard a relation default too, and a drop nobody records is the defect BUG-2674 closed. ## A REQUIRED RELATION COULD END UP ABSENT AND REPORTED VALID The late pass deletes a key AFTER validation passed, so nothing re-checked required-ness: a required relation whose default did not resolve left the item with the field absent, and the preflight reported `valid: true`. Re-running validation is not the fix — it would re-inject the same broken default. There is no valid value for that field, so the doors refuse: `missing_required_fields` on move and bulk move, a FieldValidationError on the copy, and on the preflight a `needs_value` row with `valid: false`. That split is the one this pair has everywhere else — the preview says what is wrong, the copy refuses. ## Counterfactuals Remove the oracle collapse -> the visibility leg DETECTED. Remove the bulk-update late pass -> DETECTED. Remove the required-relation refusal at the copy door -> DETECTED. All build-checked first. Gates: internal/server ok 352.1s · internal/store ok 358.7s · internal/items ok · internal/mcp ok 21.0s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on the parent commit (store 596.7s, server 294.7s, private container port 5481); re-running on this tree. |
||
|
|
2e9df62c80 |
fix: two codex round-2 findings — late defaults, UUID disclosure (TASK-2878)
Round 2 confirmed the three round-1 fixes and found two more. Both real,
both verified in the code before acceptance; codex still could not run
anything ("the read-only filesystem prevented Go cache/temp creation").
## THE DESTINATION-DEFAULT FIX ONLY COVERED HALF THE WAYS IN
The migrate doors resolve BEFORE they validate, and that order is
load-bearing: the required-field check has to see a value referent
resolution dropped, or a dropped value in a REQUIRED relation field would
store the item with the field absent instead of refusing. But
`ValidateFields` INJECTS schema defaults, so a default can land after the
resolver has finished. Two ways in, both now closed:
- a NULL OVERRIDE deletes the key and the default fills the hole, arriving
uncanonicalised — a `PEOP-1` default reached the row as the literal
string;
- a default the resolver DELETED as unresolvable is put straight back by
validation, and `StillDropped` then suppresses the warning about it.
Dropped, reported, restored, and reported as not-dropped.
`ResolveLateRelationDefaults` is a narrow second pass over exactly the keys
validation added. Reordering the two would have traded this defect for the
required-field one; a second pass costs one lookup in the rare case a
relation field declares a default and nothing otherwise. A default is
asserted by nobody, so an unresolvable one is DROPPED and reported, never
refused — the same disposition the main pass gives
RelationOriginDestinationDefault, which is what this is: the same origin,
arriving late.
THE SNAPSHOT IS TAKEN AFTER THE MAIN PASS, NOT BEFORE, and getting that
wrong is what the second sub-case caught. Snapshotting before the pass
treats a key the pass DELETED as already examined, so the late pass skips
the very value validation just put back — the arrangement that hid it. My
first version did exactly this and the second sub-case failed on it.
## A REFUSAL FOR AN INVISIBLE ITEM DISCLOSED ITS UUID
The store resolver rewrites a supplied ref into its target's id before the
visibility check runs, so a message built from the resolved value handed
back the canonical UUID of an item the requester may not see — confirming
both its existence and its identity. That is the existence oracle the
`not_found` collapse exists to prevent, reopened by the message. Every issue
this function raises now quotes what the CALLER sent.
## Prior findings, per codex
Override visibility: fixed. Bulk move dropped list: fixed. Nil-target race:
no dereference. Destination defaults: this commit.
## Counterfactuals
Neuter the late-default pass -> TestCopyEndpoint_LateInjectedRelationDefault
DETECTED on both sub-cases. Restore the canonical UUID in the message ->
TestCopyEndpoint_InvisibleRelationOverrideIsRefused DETECTED. Both mutants
build-checked first.
Gates: internal/server ok 346.3s · internal/store ok 351.3s · internal/items
ok · internal/mcp ok 22.9s · go vet clean · gofmt clean · make lint 0 issues.
Postgres green on the parent commit (store 534.5s, server 268.5s, private
container port 5481); re-running on this tree.
|
||
|
|
ed03d488da |
fix: four codex round-1 findings — origin, visibility, bulk reporting (TASK-2878)
Codex round 1 named four; three were real P1s and I verified each in the
code before accepting it. Codex could not run anything ("Go could not
create its build cache because the workspace is read-only"), so every
finding here is a static read that I confirmed and pinned.
## A THIRD ORIGIN, not two
`items.MigrateFields` injects the DESTINATION schema's defaults for keys the
source item has nothing for. My classifier split on `supplied` versus
everything-else, so a destination default was filed as CARRIED — and on a
cross-workspace copy every carried relation drops without a lookup. The
destination's own default was discarded and reported `referent_not_portable`,
which is flatly false about a value the destination chose.
There are three origins: SUPPLIED (refuse on failure), CARRIED from the
source item (cross-workspace: drop as not-portable), and DESTINATION DEFAULT
(resolve against the destination in BOTH modes; drop with the resolver's own
reason on failure, because nobody in this request typed it). Telling the last
two apart needs the source field map, which all four migrate doors have as
`currentFields`, so it is now a parameter.
Empty values are skipped at every origin. An empty relation is a cleared
field, not a referent, and reporting it as dropped tells a user they lost
something they never had.
WHAT THE FIRST VERSION OF THIS TEST PROVED: nothing. The mutant that reverts
the classifier SURVIVED it. `ValidateFields` re-injects the default after my
resolver deleted the key, so the value comes back either way and
`StillDropped` filters the false report out — the end state is identical
unless the default is a REF. A UUID default is already its own canonical
form, so "resolved" and "dropped then re-injected raw" produce the same
bytes. With `PEOP-1` as the default the mutant is DETECTED, because only a
resolved default lands as the id.
## SUPPLIED OVERRIDES AT THE MIGRATE DOORS SKIPPED THE VISIBILITY CHECK
The four write doors go through `s.resolveRelationReferents`, which adds
`checkItemVisible` on top of the store resolver. The migrate doors called
`store.MigrateRelationReferents` directly — it is a store function and cannot
answer a request-scoped question — so their SUPPLIED half, which this unit's
own rule calls an ordinary write, resolved against the database alone. A
caller able to edit both collections could point a relation at an item they
cannot see.
The ROLE is the part worth getting right. For `move` it is `workspaceRole(r)`.
For copy and preflight it is the caller's role in the DESTINATION, and
`CrossWorkspaceAccess.Role` is exactly that — its own doc says never to
substitute `workspaceRole(r)`. So `resolveRelationReferents` now takes the
role explicitly (`resolveRelationReferentsAs`), and the new
`refuseInvisibleRelationOverrides` runs at all three doors with the right one.
At the copy it runs in the HANDLER, before the store call: a pre-write
refusal must not open a transaction to roll it back, and the preflight runs
the identical check — DR-6's "the preview IS the copy" only holds if both
doors refuse the same request.
## BULK COLLECTION MOVE DISCARDED FIELDS SILENTLY
`bulkMoveCollection` has populated `result.Dropped` since MigrateFields
existed and NOTHING read it — the only reference in the file was my own
append. BUG-2674 fixed the single-item door and left this one, so a bulk move
discarded values with no record anywhere. Pre-existing, and routing relation
drops into the same dead list is what made it mine to fix.
Reported on the activity row, same key, same joined-string shape and the same
BUG-2628 reason as `handleMoveItem`, filtered against the final map so the
report is true when written. Threaded as an out-parameter, deliberately: only
this branch produces drops, the caller needs them for ONE activity row per
item, and a third return value would put `nil` in fourteen unrelated returns.
## THE P2, AND WHAT IT IS NOT PINNED BY
`resolveRelationReferents` did `if item == nil { continue }` after the
visibility read — "treat a race as someone else's 404". It now refuses with
the same `not_found` the resolver would have given moments later. This whole
unit exists to keep a dangling referent out of the blob, and a target that
vanished mid-request is the one case where waving it through would have been
deliberate.
NO TEST. Reproducing it means deleting a row between two reads inside one
request, and a test that faked that would pin the fake. Stated here rather
than left to look covered.
## Counterfactuals
Every fix has a mutant that its own test detects, each build-checked first:
classifier reverted -> DETECTED (destination default); empty-skip removed ->
DETECTED; visibility helper neutered -> DETECTED at both the move door and
the copy/preflight pair; bulk-move report removed -> DETECTED.
Gates: internal/server ok 224.6s · internal/store ok 261.5s · internal/items
ok · internal/mcp ok 15.1s · go vet clean · gofmt clean · make lint 0 issues.
|
||
|
|
34861f8658 |
feat(mcp): ToolSurfaceVersion 0.29, and the drop-reason renderer it exposed (TASK-2878)
PLAN-2857 U1. The bump, its documentation sweep, and the consumer this
change turned from a rare wart into a routine one.
THE BUMP, at 0.29 rather than 0.28. Rebasing onto main found
|
||
|
|
94eddb9b8a |
test(server): the per-door x per-provenance table, and the defect it found (TASK-2878)
PLAN-2857 U1. `internal/store` already tests the resolver exhaustively, but those tests call it DIRECTLY: they vouch for the component and say nothing about whether any door is bound to it. A door that never calls the resolver passes every one of them. This table is the binding claim — one leg per (door, provenance) pair, driven through the handler a client reaches. PROVENANCE IS THE SECOND AXIS BECAUSE THE ANSWER DEPENDS ON IT, not for symmetry. A SUPPLIED value is the caller's assertion and an unresolvable one is refused; a CARRIED value was asserted by nobody, and refusing it makes legacy items un-updatable, un-movable and un-copyable — the failure this unit would otherwise CAUSE while fixing another. Which provenance a door sees is a property OF THE DOOR, and getting it wrong is invisible until a legacy item meets it. THE TABLE FOUND ONE, ON ITS FIRST RUN. `bulkFieldUpdate` merges the item's STORED fields blob with the caller's `changes` before validating, and the resolver was pointed at the MERGED map — so a bulk status move or set-priority re-litigated every stored relation value and REFUSED the item. An item carrying a legacy relation value had its status and priority frozen by a field the operation never mentioned. Fixed the way the fields_patch door already handles it: resolve only the keys the operation CHANGES, read out of the coerced map so the value is final, written back so a supplied ref is still canonicalised. Verified by prediction before the run and by the leg failing against the unfixed code. THE DISPATCH MUTANT, which is what makes the table's coverage a measurement rather than a hope. Wire ONLY the two `extractParentLink` doors (update fields, update fields_patch) and neuter the other six by swapping their field-map argument for an empty one — types unchanged, so the mutant compiles and its verdict means something: build OK · failing legs: create (both), move (both), bulk move, bulk update supplied, copy, preflight. Update and update-patch pass. Exactly the six unwired doors, and only those. Then each door alone, eight runs: every one detected by its own legs and no other door's. That is the claim the table exists to make — each leg reaches its OWN door rather than being satisfied by a neighbour's check. ONE DOOR NEEDS A WEAKER INSTRUMENT, AND THE TEST SAYS SO. No bulk op puts a relation key into `changes` — `op` is a closed list and the only field values any of them set are `status` and `priority` — so that door's SUPPLIED branch is unreachable from outside. The first mutant run proved it: unwiring door 4 alone left every black-box leg passing. It gets a direct-call leg, labelled as vouching for the FUNCTION and not for a binding that does not exist yet, and kept for the same reason `bulkMoveCollection`'s refusal branch is kept: the day the bulk path grows per-field overrides, the branch must already refuse rather than be a silent no-op nobody notices is missing. Every refusing leg has a resolvable counterpart. Without them the table is equally consistent with a build that refuses every relation value. Gates: internal/server ok 156.1s · go vet clean · gofmt clean · make lint 0 issues. |
||
|
|
ee607047dd |
feat(copy): the two cross-workspace doors resolve referents, with their pin (TASK-2878)
PLAN-2857 U1, doors seven and eight. `migrateCopyFields` and `handleCopyItemPreflight` now take their relation decision from `store.MigrateRelationReferents` — the same function the four write doors and the two move doors already call, which is the entire reason it exists. The defect this closes: MigrateFields matches on key and TYPE, so a same-named `relation` field carried a SOURCE-workspace item id across the boundary and the preflight reported it as a clean carry. What landed in workspace B was a value naming a row in workspace A — unrenderable, and indistinguishable on read from a legitimate reference. Provenance decides, as at the move doors. A CARRIED value on a cross-workspace copy is dropped without a lookup (no id from A can mean anything in B) and reported through the `dropped_fields` channel BUG-2674 established; a SUPPLIED override is an ordinary write and an unresolvable one is refused, 400 validation_error on both doors, rendered by the same `store.RelationIssuesMessage` so one refusal cannot acquire two phrasings. `internal/server`'s `relationIssuesMessage` now delegates to it: the eighth door refuses from inside `store`, so the sentence had to be reachable there. Two things are threaded rather than re-derived, and both are load-bearing: - The TRANSACTION, not the pool. `migrateCopyFields` becomes a method taking a Queryer, and `copyItemAcrossWorkspacesTx` passes its `tx`. That function has held a transaction since its second statement, so a pool read from inside it can wait for a free connection while every pooled connection is blocked on this transaction's locks — the starvation shape BUG-2409 fixed for the attachment planner and this repo keeps a deterministic test for. This is what the day-70 handoff named as the reason these two doors were not wired with the other six. - The MODE comes from the `scope` MigrateFields was already given, not from a second boundary test. Two independent answers to "is this crossing a workspace" is how one request gets migrated one way and validated the other, and this path also serves a copy whose target IS the source workspace, where relations resolve and survive exactly as on a move. The destination workspace id is the resolution scope: a supplied override is a write into B and must name something that exists there. THE PIN, and why it is not a per-door table. These two doors sit in different PACKAGES and the code at both sites says so is how they drift unnoticed. A table with a row per door can be fully green while the two disagree about one request, which is the defect rather than a gap in coverage of it. So every case sends ONE body to BOTH endpoints: - carried relation — must drop on both, and the preflight must say referent_not_portable rather than the generic no_target_field, which is false here (the destination DOES declare the key, so that answer sends the reader to fix a schema that is fine); - supplied + unresolvable — both refuse, same status, same code, both name the offending field and value, and nothing is written; - supplied + resolvable — the positive control, supplied as a REF so resolution is visible in the result. Without it the first two legs are equally consistent with "relations always fail". Negative controls run, all three mutants BUILD-CHECKED first (a non-compiling mutant produces no `--- FAIL` lines and reads as survived): both doors unwired = DETECTED; preflight unwired alone = DETECTED; store unwired alone = DETECTED. Each single-door mutant failing is the pin's whole claim — neither door can be wired without the other. CONVE-23 sweep: the preflight's LIMITATION comment said this gap belonged in MigrateFields "for both callers at once". That is now false in its prescription as well as its premise — `internal/items` is DB-free by construction and cannot ask whether a string names a live item — so the comment records where the fix actually went and what of it remains open (`computed`, `terminal_options`, `unique_scope`). Gates: internal/server ok 170.0s · internal/store ok 296.1s · internal/items ok · go vet clean · gofmt clean · make lint 0 issues. |
||
|
|
f7caba5657 |
refactor(store): thread a Queryer through referent resolution (TASK-2878)
Preparation for the two cross-workspace copy doors, landed on its own because it is independently correct and the doors are not. `migrateCopyFields` runs inside `copyItemAcrossWorkspacesTx`, which opens a transaction as its second statement. A resolver reading from the POOL there would issue pool reads while holding a tx — the deadlock this repo keeps a deterministic test for. So `ResolveRelationReferentsQ` and `MigrateRelationReferentsQ` take the executor, following the store's own convention (`GetItemQ`, `uniqueSlugQ`, `getCollectionInWorkspaceTx`); the pool-backed names stay as one-line shims for the six wired doors. Two small read helpers come with it. `collectionIDBySlugQ` returns the ID only — the referent check compares `item.CollectionID`, and the full model would pull in per-collection counts nothing here uses. `itemByRefQ` keeps `GetItemByRef`'s fallback to a bare item-number lookup, because a relation written as COLO-3 must keep resolving after its target collection is renamed, which is exactly what BUG-2873 made possible. internal/store ok (341.7s), vet and gofmt clean. WHY THE COPY DOORS ARE NOT IN THIS COMMIT. They were written and building, and I reverted them. Team CONVE-29 and the lead's condition both say the copy pair lands WITH its pin — one case driving BOTH doors, asserting identical drop-and-report for a carried relation and refusal for a supplied override — and I measured 58.9% context against a 65% ceiling, which is not enough for that pin plus the 270s server suite plus the commit. Landing the behaviour change unpinned would have been worse than landing nothing: the preflight and the store copy are the pair the code already warns will drift unnoticed, so they are the last place to accept an untested agreement. The design is complete and on the trail: derive the carry mode from the existing `items.MigrateScope` rather than a second flag, pass `tx` on the store side and the pool on the preflight side, refusals through the copy's existing validation-error channel, drops appended to `migrated.Dropped`. |
||
|
|
f6b1dd96cc |
feat(server): the two same-workspace migrate doors resolve and report (TASK-2878)
PLAN-2857 U1: `handleMoveItem` and `bulkMoveCollection` now take their relation decision from `store.MigrateRelationReferents` — the same function the two copy doors will call, which is the point of it existing. Within a workspace the targets are still present, so a correctly-related item KEEPS its relation across a move; only an unresolvable value is dropped, and it joins the `dropped_fields` report BUG-2674 established rather than failing the move. Refusing carried values here would make every legacy item permanently unmovable, and `internal/items` has accepted any string for a relation all along, so "legacy" is most of them. The bulk path carries no per-field overrides — only `status` — so every relation value reaching it is CARRIED and nothing there can refuse. It passes nil for `supplied` to say so, and keeps the refusal branch: it is unreachable today and stops being a silent no-op the day that path grows overrides. internal/server ok (270.8s), internal/store ok. KNOWN GAP, recorded rather than half-built: the two CROSS-workspace doors are not wired yet, and the reason is a real constraint rather than running out of road. `migrateCopyFields` is called from `copyItemAcrossWorkspacesTx` with a transaction already open (`s.db.Begin()` at the top of that function), so resolving a SUPPLIED override there would issue POOL reads while holding a tx — the deadlock shape this repo keeps a deterministic test for. The carried half needs no lookup at all and is safe; the supplied half needs `GetCollectionBySlugQ` / `GetItemByRefQ` so the resolver can run on the tx's connection, which is exactly the `...Q` convention the store already uses (`GetItemQ`, `getCollectionInWorkspaceTx`, `uniqueSlugQ`). Adding those two is the remaining work, and it is what makes one function genuinely serve all four doors. |
||
|
|
9b19a78459 |
feat(store): one migrate decision for all four carrying doors (TASK-2878)
PLAN-2857 U1, third slice, on the lead's refined ruling: PROVENANCE
decides, not which door you came through.
* SUPPLIED (an explicit `--field` override on a move or copy) is a write
like any other, so an unresolvable value REFUSES.
* CARRIED (everything the source item already held) was asserted by
nobody. `internal/items` has accepted any string for a relation all
along, so most stored values are legacy — refusing them would make
those items unmovable and uncopyable. Dropped and REPORTED instead.
And carried values are not all alike, which is the refinement that keeps
this from being one rule wearing four coats:
* WITHIN a workspace (move, bulk move) the targets are still here, so a
valid relation SURVIVES the move and only an unresolvable one is
dropped, through the `dropped_fields` channel BUG-2674 established.
* ACROSS workspaces (copy, and its preflight) every carried relation is
dropped WITHOUT a lookup: the value names a source-workspace row and
v1 excludes cross-workspace targets, so no amount of resolving in the
destination changes what it means. Reported as `referent_not_portable`
— the same reason `github_pr` uses, because it is the same fact about
the same kind of value.
`MigrateRelationReferents` is one function because the four doors sharing
it is the point, not tidiness: the preflight lives in `internal/server`
and the copy in `internal/store`, and the code already carries a comment
saying those two sit in different packages and that is how they drift
unnoticed. A preflight that says "carried" while the copy drops is one
request answered two ways.
Tests drive both provenances against both modes, because the same bad
value must be a drop when carried and a refusal when supplied — a suite
that only drove carried values would pass against a build that never
refuses anything.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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). |
||
|
|
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
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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') |
||
|
|
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')
|
||
|
|
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) |
||
|
|
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) |
||
|
|
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)
|
||
|
|
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 |
||
|
|
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)
|
||
|
|
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)
|
||
|
|
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)
|
||
|
|
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)
|
||
|
|
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)
|
||
|
|
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)
|
||
|
|
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) |
||
|
|
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)
|
||
|
|
13892fecf7 |
fix(mcp): close two codex round-7 findings on the same seam (BUG-2850)
Both are consequences of round 6's own fixes, which is the tell that the
seam — a guard written for one key shape, and the keys that do not take
that path — is still the thing to keep hitting.
[P1] The alias guard did not fire without a `fields` object.
Round 6 put it inside reshapeItemFields' per-key loop, and
reshapeItemFields returns early when `fields` is absent — so
`field:["parent=A","plan=B"]` walked straight past it and
extractParentLink's no-early-exit loop applied `plan` while the caller
had every reason to believe `parent` was what they set. A guard a caller
can step around by moving the same two values into a different param is
not a guard. checkHierarchyAliasAmbiguity now runs on create and update
regardless of `fields`, over the merged input.
SCOPE, stated rather than smuggled: the pure-`field` form was accepted
before BUG-2850 too, so this closes a pre-existing silent mis-write, not
a regression. Fixed here rather than filed because shipping round 6's
guard without it would advertise a refusal that any caller bypasses in
one edit. Deliberately NOT extended to same-name duplicates (a `parent`
param plus `field:["parent=B"]`) — those resolve last-write-wins
identically on both doors, which is documented behaviour, and widening
the refusal to cover them is a policy change rather than a defect fix.
[P2] A padded equal duplicate was retained raw. Round 6 normalized the
conflict INDEX so ` effort=l` matches `fields:{"effort":"l"}` — correct,
and it closed the padded-key bypass — but the raw entry stayed in
`field`, and the CLI door does not trim. Over stdio that stored an
undeclared `" effort"` key and left `effort` untouched: the
normalization that made the duplicate visible is what made the retained
entry wrong, so the fix belongs at the same place. The entry is now
re-emitted canonically, and only when the raw form actually differs, so
a well-formed array keeps its contents and its order.
Mutation matrix, run this turn, each mutant from a file backup:
unwire checkHierarchyAliasAmbiguity -> only the round-7 alias test fails (4/4 subtests)
revert the canonical re-emission -> only PaddedEqualDuplicateIsCanonicalized fails
Neither mutant touches round 6's in-loop alias test, which is right: that
one enters through the `fields` object and is a different path — the
distinction this finding exists about.
Control legs: a lone hierarchy key through the array still dispatches,
an already-canonical duplicate is left byte-identical and unreordered,
and the padded-entry case asserts exactly one --field is emitted.
gofmt clean · go vet clean · go test ./... green (29 packages) · 18 files
|
||
|
|
dfee13896d |
fix(mcp): close four codex round-6 findings in the fields-object merge (BUG-2850)
All four sit on the same seam this unit keeps failing at: a guard written
for one key shape, and a class of keys that does not take that path.
[P1] parent/plan alias conflicts bypassed every guard. extractParentLink
resolves the hierarchy link with `for _, key := range {"parent","plan"}`
and no early exit, so when both arrive the LATER key wins — but every
check in reshapeItemFields matched on the SAME key name. So
`fields:{"parent":"PLAN-12"}` with `field:["plan=PLAN-9"]` passed and
relinked the item to PLAN-9 while reporting PLAN-12. The same alias
bypass BUG-2078's round-1 review found on clear_parent, reached through
a different door. Refused now in both directions and against the
top-level param — and refused even when the two values are EQUAL, which
is what v0.19 already does for parent + clear_parent "including via the
plan alias".
[P1] The conflict index was not normalized the way the door normalizes.
ingestFieldKVP TrimSpaces both halves of a `key=value` entry;
parseFieldArray indexed the raw halves, so `field:[" status=cancelled"]`
sat under " status", missed the guard against `fields:{"status":…}` and
then silently overrode it. Trimming the value fixes the mirror-image
false refusal (`status= done` vs `done`). ONLY the index is normalized —
`entries` stay verbatim, because the CLI door does not trim and must
keep receiving exactly what the caller sent.
[P1] A non-string promoted value silently no-op'd on remote update.
reshapeItemFields promotes `fields:{"priority":3}` with its type intact,
but hasFieldChanges and the patch loop both read `.(string)` — so the
dispatcher skipped the fields_patch branch entirely and answered SUCCESS
having sent no PATCH. A silent no-op reintroduced by the fix for silent
no-ops, and asymmetric with create, which has always passed non-strings
through. promotedParamValue now accepts any scalar; empty string still
means "not supplied".
[P2] Equal promoted duplicates did not collapse. `fields:{"role":"x"}`
plus `field:["role=x"]` resolved the role to agent_role_id AND wrote a
literal `role` key into the fields blob that no schema declares — one
value, two writes, one of them an undeclared field with a warning
naming it. The array entry is now dropped so the value applies once
through its dedicated param.
Mutation matrix, run this turn, each mutant applied and reverted from a
file backup (never `git checkout` — the tests were uncommitted):
drop the alias block -> only HierarchyAliasConflictRefused fails (4/4 subtests)
revert index normalization -> only FieldArrayKeysNormalizedForConflicts fails (both legs)
revert the duplicate drop -> only the two EqualDuplicate tests fail
revert promotedParamValue -> only NonStringPromotedValueIsNotDropped fails
No cross-talk: each mutant is the defect at the site its test targets,
and each kills exactly that test. Control legs included on purpose — a
lone hierarchy key is still accepted, padding-only value differences are
not conflicts, an unrelated `field` entry survives the duplicate drop,
and an empty promoted string still produces no fields_patch.
gofmt clean · go vet clean · go test ./... green (29 packages)
|
||
|
|
7f25283a41 |
test(server): pin the last three coercion call sites (BUG-2850)
Five of the eight `CoerceFields` sites had a test that goes red if that site alone is dropped. Move, bulk move and bulk update did not, so the PR's "typed on every door" claim rested on reading the code — CONVE-19 and the shape rounds 2-5 of this unit kept finding. Why the move pins are faithful rather than green-for-free: migrateValue already permits text->number (migrate.go:190), but it returns `value`, the ORIGINAL, not a parsed float. So a text field holding "42" reaches a number-typed destination as the STRING "42", and only CoerceFields at the move site turns it into a number before validation. Assertions are on the STORED NATIVE TYPE, re-read from the item rather than taken from the mutation's own response, so a handler that answered 200 and stored the string is still red. Bulk update merges request STRINGS (status, priority), so it is observable only where the schema declares one of those keys as a non-string type; a collection declaring `priority` as a number is unusual but legal and is the honest way to reach that site. Bulk ops answer 200 with per-item failures in the envelope, so the pins read the envelope too — a status-code-only assertion would pass on a dropped coercion. Per-site mutation matrix, run this turn against these tests, each mutant applied and reverted from a file backup (never `git checkout`, which would have taken the uncommitted tests with it): drop coercion at handlers_items.go:2314 -> only TestItemFieldsCoercedOnMove fails drop coercion at handlers_items_bulk.go:683 -> only TestItemFieldsCoercedOnBulkMove fails drop coercion at handlers_items_bulk.go:499 -> only TestItemFieldsCoercedOnBulkFieldUpdate fails Each mutant is the defect at the site the test targets — not a call-site patch next to a still-correct function (CONVE-28) — and each kills exactly one test, which is the per-site discrimination the PR claims. Files restored and verified identical after the matrix; suite green. |
||
|
|
baaa236d83 |
fix(mcp): apply the field-array conflict guard to promoted keys too (BUG-2850)
Codex round 5, one P1. The `fields` object vs `field: ["k=v"]` conflict
guard lived on the generic path, which a promoted key never reaches:
`status`, `priority`, `category`, `parent`, `role`, `assign` and `tags`
all return from the promoted branch above it. So the one ambiguity this
function did not refuse was the one on the keys that matter most.
It did not fail closed either. The promoted branch writes the top-level
param (`out["status"]`) while the array entry stays in `out["field"]`,
and both the HTTP mappers and the CLI overlay `--field` entries AFTER
the named flags — so the array silently won. `fields:{"status":"done"}`
with `field:["status=cancelled"]` cancels the item. The same shape on
`parent` relinks or detaches it.
The guard now runs first in the promoted branch, before the existing
top-level-param check, with the generic path's semantics: differing
values refuse, an equal duplicate falls through and still promotes, and
a structure against a string entry (the `tags` case) refuses as
"one key cannot be both".
This is the fourth consecutive round where the defect was a guard
written on the generic path only, and round 4's test is why: it pins
`effort`, a key that takes the generic path, so it vouched for the path
the guard is on rather than for the class of keys that skips it
(CONVE-19). The new test drives all four promoted shapes plus an
equal-duplicate control leg.
Negative control: all four conflict cases fail on the unfixed tree
(run before the fix, not against a synthetic mutant); the
equal-duplicate leg passes both before and after, so it discriminates
refuse-on-ambiguity from refuse-on-agreement.
gofmt clean · go vet clean · go test ./internal/mcp/ ok
|
||
|
|
b23c0dbc6f |
fix(mcp): apply the null and hierarchy guards to promoted keys too (BUG-2850)
Codex round 4, and both findings are the same defect in my own round-3 fix:
ORDERING. The null guard and the hierarchy-key guard sat BELOW the branch
that promotes status/priority/category/parent/role/assign/tags onto dedicated
params, so every promoted key walked around both.
- `fields: {"tags": null}` reached the promoted branch and became a silent
no-op, instead of the refusal the null rule documents.
- `fields: {"parent": 42}` was accepted here and dropped later by the
handler — the same silent-drop shape this whole bug is about, reintroduced
by a guard I added to prevent a different instance of it.
Both guards now run before that branch, so they apply to every key. A guard a
whole class of keys bypasses is not a guard.
Pinned separately from the generic-path cases, because the generic-path tests
passed throughout: they never exercised a promoted key, which is exactly why
the hole survived round 3. Reverting the hoist fails the new test. Well-formed
promoted keys still promote — tested, so the hoist did not break promotion
while closing the bypass.
Gates: gofmt clean, go vet clean, go test ./... 29 packages ok.
Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
|
||
|
|
f15f60831e |
fix(mcp): guard hierarchy pseudo-keys and correct the fields description (BUG-2850)
Codex round 3.
1. [P1] A structured `plan` could silently DETACH an item. `plan` is not in
padItemPromotedFieldKeys, so it fell to the generic path — and once this
branch stopped refusing structures, fields:{"plan":{…}} reached the server
natively. There extractParentLink reads any PRESENT non-string plan/parent
as a hierarchy directive, drops the key, and on update clears the existing
parent link. Lifting the nested refusal quietly opened a path where a
malformed value detaches an item from its parent.
Guarded specifically: these keys take a string ref and nothing else. Both
`parent` and `plan` are listed, so the guard does not depend on which
other set a key happens to belong to. A string ref still works — tested,
so the fix did not re-refuse the normal case while closing the hole.
2. [P2] The MCP `fields` param description still told agents that
multi_select and json non-scalars are "refused, not written". This diff
makes that false, and a schema an agent reads is the artifact that decides
what it attempts — the reporter's agent rewrote seven playbooks after
believing exactly this kind of line. Rewritten to state what is true now,
including the two remaining refusals (null, structured parent/plan) and
that structured values need the remote transport.
Gates: gofmt clean, go vet clean, go test ./... 29 packages ok. Removing the
hierarchy guard fails its test.
Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
|
||
|
|
2cf9f0035a |
fix(mcp,server,cli): three codex round-2 findings (BUG-2850)
1. [P1] The structured-value refusal was in the wrong place and killed the fix. It went into BuildCLIArgs, which env.Dispatch runs for BOTH transports before handing off to whichever Dispatcher is configured — so it blocked the remote /mcp door too, and the native-field handling that is the whole point of this change was never reached. Moved into ExecDispatcher, which IS the stdio door. My own test could not see this: it called mapItemCreate directly, so it vouched for the mapper and not for the path that reaches it — CONVE-19's exact shape, in a unit where I had already written binding tests for the other half. The tests are now split along the two claims the first version conflated: nested values REACH the dispatcher (the remote door is unblocked), and refuseStructuredFieldsOverCLI refuses them at the CLI door naming the transport. 2. [P2] The CLI warning sat after the `--format json` early return, so the caller most likely to have sent a mistyped key — one piping stdout into a parser — was the one caller who never saw it. Moved above the return, and out of the `ref != ""` branch it was also trapped in. Still stderr. 3. [P2] A nil value in fields_patch DELETES the key (store/items.go), so reporting it as an undeclared field told the caller a field was stored that the same request removed. Filtered at the patch site, not inside UndeclaredFieldKeys, because nil means "store JSON null" on the full-fields path where reporting it is correct. Gates: gofmt clean, go vet clean, go test ./... 29 packages ok. Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo |
||
|
|
dc3fc2d50e |
feat(server,cli): name undeclared field keys on the write response (BUG-2850)
Undeclared keys are ACCEPTED — the census found 168 live values under 14 such
keys, and refusing them would break read-modify-write on items nobody edited
wrongly. But once stored, a typo and a deliberate extra field are
indistinguishable, so the write now says which keys it did not recognize.
- models.Item gains `Warnings *ItemWriteWarnings` with `undeclared_fields`,
omitempty and additive. NEW API SURFACE: item write responses carried no
warnings element before. Wrapping the response as {item, warnings} was the
alternative and would have broken every existing parser; a clean write is
byte-identical to before.
- items.UndeclaredFieldKeys consults models.IsReservedItemField rather than
re-listing the reserved set — that set exists so callers ask, and its doc
comment records what re-listing cost last time. So a write carrying
implementation_notes or github_pr reports nothing.
- fields_patch reports only the PATCHED keys. A stray key already on the item
is not something this write introduced, and naming it on every touch would
train the reader to ignore the field.
- The CLI prints one line to STDERR. Never stdout: `--format json` output is
piped into scripts, and a warning there would corrupt the JSON they parse.
- CLAUDE.md documents the element as new surface.
Controls: never attaching the warnings fails the pin; reverting the HTTP
mapper's native overlay fails the remote-door type test; dropping the
reserved-key exclusion fails its own test.
Two coverage gaps the controls FOUND rather than confirmed, both now closed:
the remote door's native overlay was covered by no MCP test at all (a revert
left the package green), and the reserved-key exclusion had no test either.
Both were written after the control survived, which is the only reason they
exist.
Gates: gofmt clean, go vet clean, go test ./... 29 packages ok.
Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
|
||
|
|
dd919dd0a9 |
feat(mcp): carry the fields object with its JSON types intact (BUG-2850)
Second half of BUG-2850, ruled after a census: undeclared keys stay accepted
on every door, and a value's native JSON type is preserved wherever the
encoding carries one.
Only two doors carry a type at all. The remote /mcp `fields` OBJECT param
does — and the catalog was destroying it, flattening every value into
`field: ["key=value"]` before dispatch. The direct HTTP API does. The other
three (remote `field:[…]`, CLI `--field`, stdio MCP, which dispatches through
the CLI) are string-by-construction: `key=value` carries no type to preserve,
and a string is the correct and complete representation of `cost=42` typed at
a shell. So this does not "make every door preserve types" — it stops
destroying the types the object form already had.
- The catalog merge now emits the native map alongside the string entries, so
each transport takes what it can use. Both forms describe the same input;
they differ only in fidelity.
- The HTTP create and update mappers overlay the native map LAST, so it wins
over the stringified copy of itself.
- hasFieldChanges consults the native map. Without that a NESTED-ONLY update
emits no `field` entry at all, so it reported success and wrote nothing —
the silent-drop shape this bug is about, arriving through the fix for it.
PR #1159's blanket refusal of nested values is LIFTED, as ruled: its
precondition (server-side coercion) landed in
|
||
|
|
b451fb50de |
test(server): bind the coercion to its call sites, and enforce copy/preflight agreement (BUG-2850)
CONVE-19: wiring is a claim. The previous commit threaded CoerceFields through eight validate sites; a test at the items package vouches for the function, not for any of those bindings. - Three HTTP-door tests (create, update, fields_patch) assert the stored NATIVE TYPE, not that the request returned 201 — a test that only checked the status passes on an implementation that stores the string, which is the shape the reporter described. - A text field holding "42" must stay a string in every one of them. Fixing this bug by coercing anything that parses would retype real data. - An un-coercible value must still be REFUSED with the validator's existing message, so coercion is not quietly widening what the server accepts. - TestCopyAndPreflightCoerceIdentically makes the cross-package invariant real. The preflight validates in internal/server and the copy in internal/store; both files carry a comment saying they must match, and a comment protects nobody. The assertion is agreement FIRST — whatever they do, they must do the same thing — and only then that both accept and the copy stores a number. Controls, each run against the mutated tree: - CoerceFields reduced to the identity function (the unfixed build) fails the two door tests and the items typing test. - Dropping the call at CREATE alone fails only the create test; dropping it at FIELDS_PATCH alone fails only that one. The bindings are individually covered, not covered in aggregate. - Coercing in the copy but not the preflight FAILS, and so does the reverse. Both drift directions are caught. BOUNDARY, stated rather than implied: four of the eight sites — move, bulk update, bulk move, and the migrated-schema paths they share — are wired identically but have no test that fails if that specific wiring is dropped. They are covered by the existing suites for their own behaviour, not for coercion. A follow-up should extend the door tests to them. Gates: gofmt clean, go vet clean, go test ./... 29 packages ok. Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo |
||
|
|
ae793e6fa6 |
fix(server,store,items): coerce field values to their declared types server-side (BUG-2850)
The write doors disagreed about what `key=value` means. The CLI has coerced by schema type since BUG-1125, and local stdio MCP inherits that by shelling out to the binary — but the remote /mcp transport builds its field map in ingestFieldKVP with `dst[key] = val`, so every value arrives as a string. validateFieldType then correctly refuses a string for a declared number or json field, and the net effect was that an MCP agent on that transport could not write those fields AT ALL: every attempt a 400, not a mis-typed value. Measured before writing anything (repro table on BUG-2850's trail): CLI and stdio MCP store 42 and an array; the HTTP door 400s on both; an UNDECLARED key is stored as a string on every door. items.CoerceFields(fields, schema) converts strings to the declared type — number via ParseFloat (NaN/±Inf refused, because json.Marshal cannot encode them and the ignored downstream error would silently drop the whole payload), json/multi_select via Unmarshal, checkbox via ParseBool — and is applied immediately before every Validate* call. Three deliberate non-behaviours, each with a test: - A value that will not parse is left as the string for the validator, so the existing "must be a number" error still fires. Coercion invents no error path, and cannot turn a currently-PASSING write into a failure. - Non-string values pass through untouched; an int stays an int. - Text-typed fields holding "42" stay strings. Coercing anything that parses would retype real data while fixing the bug. Not folded into ValidateFields, though that would be the single call site: a function named Validate that mutates its input is a trap, and two callers re-marshal the map they pass. THE POPULATION IS 8 CALL SITES, and finding them took two sweeps. The first was scoped to internal/server and found 7; the copy path validates in internal/store (items_cross_workspace_copy.go), which only a repo-wide sweep sees. The preflight and the store-side copy now carry cross-references to each other: the preflight exists to PREDICT the copy, they live in different packages, and that is exactly how they would drift unnoticed. The undeclared-key half of BUG-2850 is untouched and marked as a decision point in CoerceFields — refuse/warn/keep is with Dave. A test pins today's keep behaviour so the ruling lands as a deliberate change. The CLI's parseFieldFlag deliberately STAYS: it is why two of four doors are correct today, and removing it alongside its replacement would put all four at risk of one mistake. Retiring it is a follow-up. Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo |
||
|
|
e89c8c8ab6 |
fix(cli): two ways the preflight and its remedy disagreed with the migration (BUG-2810)
Codex round 9, both confirmed against the code rather than reasoned about. **PAD_DATABASE_URL was treated as proof of a PostgreSQL deployment**, so the flow this unit prescribes broke on itself. cmd_server.go opens PostgreSQL only when PAD_DB_DRIVER=postgres; PAD_DATABASE_URL is ALSO migrate-to-pg's target, and its default. An operator who follows the preflight — refused, told to run `pad db repair-nul`, with the target URL still exported in their shell — got "This deployment is PostgreSQL ... Nothing to scan or repair" and exit 0. The remedy the refusal names did nothing, which is the failure mode this unit has now produced three separate ways. PAD_DB_DRIVER alone decides. Verified by running the real command with the target exported. **The preflight refused on tables the migration does not copy.** ExportWorkspace / ImportWorkspace read six tables, and migrate-to-pg's own help says users, platform settings and auth data are not migrated — so a NUL in users.name blocked a copy that would never touch it, demanding the operator rewrite content unrelated to the migration they asked for. Refusal is now filtered to store.MigratedTables(). Those rows are still REPORTED: `pad db scan-nul` lists them, they are real, and going quiet about a broken row because this command does not care about it would be the information-discarding the preflight was already corrected for once. The table set is pinned by REFLECTION over models.WorkspaceExport's shape, not by a regex over ExportWorkspace's SQL — TASK-2825 already established that multi-line and Sprintf-composed SQL are invisible to any source-level instrument. It fails in both directions: a new export section with no entry (a miss, ending in a half-finished migration) and a spurious entry (an over-refusal). One residual, stated rather than hidden: the export also skips SOFT-DELETED collections and items, and this filter is per-table. A NUL in a soft-deleted item still blocks. Narrowing it needs a per-row deleted_at check at every candidate, which costs more than the remaining over-refusal — the operator's way out is the same single command either way. |