mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
93693dc689
Item numbers are workspace-unique and sequential ACROSS collections, so
`CONVE-1` and `SECRE-1` are never both real. `itemByRefQ` — the relation
rung's ref lookup — missed on prefix and fell through to
`WHERE item_number = ?` with the prefix DISCARDED, and the item it found
was usually inside the field's declared collection, so the
`item.CollectionID != targetID` check above it passed and nothing was
raised at any layer.
Measured on 3cc3ba5e: a caller writing `role: CONVE-1` against a field
declaring `collection: secrets` got 201, the item stored SECRE-1's UUID,
and the response's own `relation_targets` hydration came back
`{"ref":"SECRE-1","title":"Real Owner"}`. Every write door, silently.
That is the corruption PLAN-2857 U1 closed for free text ("red" resolving
to whatever is slugged red today), still open for anything ref-SHAPED —
which is the spelling the docs tell callers to prefer.
The fallback exists for a real case and keeps it: a collection RENAME
changes its prefix (BUG-2873), and a relation already written as the old
ref has to keep resolving. So the discriminator is the PREFIX, not the
item — by-number fires only when the written prefix names no LIVE
collection in the workspace, which is what a rename leaves behind. A
prefix that IS live and simply holds no item at that number is the caller
naming a real collection that does not contain what they said, and is
`not_found`. A soft-deleted collection counts as absent for the same
reason a renamed one does.
RESIDUAL, stated and tested rather than implied: a ref pasted from
another workspace whose prefix also names no collection here still
resolves by number. Nothing in the value distinguishes it from a rename's
leftover, so closing it would cost the case the fallback exists for.
`GetItemByRef`, the navigational read path, is deliberately unchanged and
now carries a comment saying why the two diverge.
Also here, from the measurement that found this:
- BUG-3015 is closed as already fixed by #1246 (TASK-2878), six days
before it was filed, and its receipt lands as a test. The carry door
carries AND reports every unresolvable relation value; an injected
default that resolves to nothing is dropped and reported through the
other warnings member. The filing's probe hit the undeclared-key path,
where the artifact allow-list drops the key at decode and no relation
code runs at all.
- A comment in relation_titles_import_test.go asserting that "no relation
value can ride in an artifact, whatever the destination collection's
schema has been reshaped to" is corrected. The allow-list filters KEYS,
not types: a key the kind DOES declare (`role` on a convention) rides in
under whatever type the destination schema gives it, which is how the
measurement reached the carry door at all.
- ToolSurfaceVersion 0.35 -> 0.36, a BEHAVIOR bump on the v0.29 grounds
(a write door refuses a value it used to accept), with CLAUDE.md,
instructions.md and README.md swept. Two stale version claims in
CLAUDE.md go with it — the prose still called the catalog v0.34 in one
place and v0.31 in another — found by codex round 3, which also caught
that the same file stated the soft-delete rule without the scope the
code comment gives it.
The live-prefix question and the by-number lookup are ONE statement (a
`NOT EXISTS` subquery), not a probe followed by a lookup. Codex round 1
found the window: two reads under READ COMMITTED see two databases, so a
collection created between them lets a ref resolve that the later state
would refuse. One read means the answer is always about a single state.
The window that remains — between the exact-prefix match and this query —
is benign in both directions and the comment says why.
Two comment claims are corrected from codex round 2, both wrong in the
same way — a statement about THIS query written as though it were about
the whole function. The remaining read window is not symmetric: a
collection disappearing between the two reads gives the right answer for
the newer state, but one APPEARING (or being renamed to this prefix) makes
this return nothing where the newer state would have resolved the ref
exactly. It stays, because that stale answer is `not_found` — a refusal
the caller sees and can retry, never a value bound to the wrong item. And
"a soft-deleted collection counts as absent" is true of this predicate
only: the ref-matched query above never filtered on the collection's own
`deleted_at`, so an item whose collection was soft-deleted still matches
there. Left alone rather than made consistent, because it cannot retarget
— the downstream collection check refuses it — with a test now asserting
the wrong-collection refusal specifically, since a `not_found` would be
the same 400 while proving nothing.
Mutation matrix 6/6, each with BUILD OK recorded before the outcome:
neutering the gate is caught by the retarget leg (with the wrong item
named in the failure); forcing it always-on is caught by the rename and
residual legs; dropping `deleted_at IS NULL` and dropping
`workspace_id = ?` each SURVIVED until a leg was added for them, and are
caught now; making the carry door stop reporting is caught by four legs of
the BUG-3015 table; and neutering the downstream collection check is
caught by the soft-deleted-collection leg. The workspace-scope mutant was re-run after
the first attempt changed the SQL without changing its argument list —
that mutant raised an argument-count error rather than the behaviour it
was meant to express, and a DETECTED verdict from it would have certified
a leg that never ran.
Gates on this tree: make test exit 0 (30 ok, 0 FAIL), make lint 0 issues,
make test-pg exit 0 (30 ok, 0 FAIL). The store-level leg is the one that
runs the new SQL on BOTH dialects — internal/server's harness is
hardwired to SQLite, so the server-level tests are SQLite-only and are
not claimed otherwise.
Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA