Commit Graph

8 Commits

Author SHA1 Message Date
xarmian a05197c795 feat(items): idempotency keys for cross-workspace copy and move (TASK-3585) (#2070)
POST .../items/{ref}/copy accepts op_key (body) or Idempotency-Key. The
key is claimed after every pre-write refusal; its answer is the WHOLE
first ItemCopyResult (the dropped-field and attachment warnings cannot be
recomputed), written inside the copy's transaction through the new
CrossWorkspaceCopyRequest.AfterWriteTx, so a retry replays the first copy
and copies nothing again. The response is built by the extracted
copyResult().

A retried MOVE finds its source already archived, which the shared copy
ladder refuses (409 archived) before it reads the body. replayArchivedSourceCopy
runs first, only when the source resolves archived and visible, exactly
when the ladder would refuse, and settles the request through the same
claim: the stored answer replays, a different request is 422, one in
flight is 409, and a key that never copied this item is released and gets
the ladder's own 409 archived. The ladder (shared with the preflight) is
unchanged.

CLI: `pad item copy --op-key` (fresh per invocation by default) keys the
mutating call only; CopyItem makes ONE keyed retry after an unknown
outcome, and only against a server that advertises idempotency_keys.
DR-13's other guards (dedicated client and transport, unreplayable body,
no redirects) are unchanged.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 15:59:02 -04:00
xarmian 90a304765f fix(cli): copy --dry-run previews a big number with every digit (BUG-3456) (#1858)
The copy preflight decoded carried field values with plain json.Unmarshal
into `any`, so a number above 2^53 became a float64 and the dry run
previewed 9007199254740992 for a stored 9007199254740993. The copy itself
keeps the stored digits (server-side), so the preview misled exactly
where a user would check. Decode with models.DecodeJSONKeepingNumbers,
as BUG-3448 did for item show and the MCP resource.

The mutating copy's response is left on json.Unmarshal: it prints no
carried values (the item's fields are a string), and a decode error
there would misreport a committed copy.

Tests: the client keeps the digits, and the rendered dry run through the
real client shows them; on the old decode it renders
"= 9007199254740992".

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 12:37:49 -04:00
xarmian fd95b79694 fix(BUG-2367): moves and copies never carry into a computed field, and a carried unique-field collision is dropped and named instead of 500ing or storing a duplicate (#1514)
MigrateFields matched fields by key and type only. On every migrate door
(single move, bulk move, cross-workspace copy and its preflight):

- a value was carried into a destination field marked `computed`. It is
  now dropped, with preflight reason `target_computed`.
- a value colliding on a destination `unique_scope` field answered 500
  on single move (invocation_slug, which has an index), leaked the SQL
  error through bulk move, and stored a duplicate on an unindexed unique
  field. Provenance decides now, as TASK-2878 did for relation referents:
  a CARRIED collision is dropped and reported as `not_unique`, and an
  override or schema default that collides is refused with 409 conflict.
  A required field emptied by the drop is refused as required.

One store check (UniqueFieldConflictsQ / DropCarriedUniqueCollisionsQ)
serves all four doors, including the tx-bound copy. The report names the
value, and names the item holding it only when the caller may see that
item. The activity row always uses the holder-free sentence.

Reporting, all additive and omitempty: warnings.not_unique on the move
response, the copy response and bulk updated[] rows; `detail` on the
preflight's dropped row; CLI copy dry run and result, plus `  dropped:`
lines on `item move` stdout; web copy dialog, move and copy toasts, and
the timeline move card. ToolSurfaceVersion 0.51 (behaviour bump).

The relation leg of the original report was already closed by TASK-2878.
Item 4 (terminal/abandoned state change) is PR 2. Out of scope and filed
separately: BUG-3200 (missing needs_value routes) and BUG-3202 (float64
precision; the migrateCopyFields comment now points there).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 13:12:32 -04:00
xarmian 5e869b285e fix(export): mark bundle bodies that were stale when written, and refuse the migration that would abandon them (BUG-3032) (#1356)
* wip(items): mark bundle items whose body is behind the live doc (BUG-3032 doors 1-2)

* wip: doors 4 and 6 (BUG-3032)

* wip: store test for the gate predicate (BUG-3032)

* wip: door 3 import stale-body count + tests (BUG-3032)

* docs(backup): the migration refuses on unflushed collab edits; bundles mark stale bodies (BUG-3032)

* docs: discharge BUG-3032's open-decision conditional in the artifact door and the population guard

* fix(export): the migration gate's population cannot diverge from the bundle's (BUG-3032)

* docs(claude): Go build caches never go under /tmp — it is a tmpfs, so it is RAM

* wip: codex round 1 P1 + P2 fixes (BUG-3032)

* fix(db): refuse a migration whose bundle went stale after the pre-flight check (BUG-3032, codex r1 P1); print the import's stale-body count (P2)

* fix(db): refuse to migrate while a server can append, and parse the stale-body header (BUG-3032, codex r2 P1+P2)

* docs: narrow the running-server claim to what the probe measures (BUG-3032)

* wip: codex r3 fixes + web parity test (BUG-3032)

* fix(db): fail closed when the running-server check cannot run; match Go's header parsing on the web (BUG-3032, codex r3)

* fix(cli): the Go and web readers of the stale-body header accept exactly the same values (BUG-3032, codex r4)

* docs: correct two comment claims about integer representability and Atoi's leniency (BUG-3032, codex r5 nits)
2026-09-14 01:45:30 -04:00
xarmian 7659ad3cd3 feat(server,web,cli): say when a relation's copy target is unusable, instead of offering a picker that cannot answer (IDEA-2899) (#1262)
* feat(server): the copy preflight says when a relation's target is not usable (IDEA-2899)

TASK-2869 made a `needs_value` relation row collectable as soon as it names
a target collection. Naming one is not having one: the slug can name a
collection that has been DELETED, or one this caller cannot READ. The dialog
then mounts a picker that can return nothing and, because the row is not
blocked, Confirm stays disabled carrying only the generic required-field
message — the user is told a value is missing and never told that no value is
reachable.

`collection_unavailable` on the needs_value row is the server saying so.

THE CLIENT CANNOT COMPUTE THIS, which is why it belongs here. The dialog's
destination collection list is filtered through `canEditCollection`, because
it drives the copy-INTO picker; a relation TARGET needs only READ access, so
a perfectly usable target routinely does not appear in that list. Testing
against it would refuse rows the user could have filled in — over-blocking,
which is the worse failure and invisible to whoever hits it.

`visibleCollectionIDs` is the read-scoped view, and its NAV-LENIENT shape is
right here rather than merely tolerable: it includes a collection reachable
only through an item-level grant, and the question is "could a picker here
return anything at all". One granted item is a picker with one row.

DELETED and UNREADABLE are deliberately not distinguished. Same consequence,
no client branch would differ — and separating them would tell a caller who
cannot read a collection that it nonetheless exists.

`omitempty` on a BOOL drops `false`, so the field is phrased NEGATIVELY.
Present-and-true means the server checked and the target is unusable; ABSENT
means available, or a server that does not report. A client must block only
on an explicit true, so absence stays "no information" rather than becoming a
value — the rule `access_epoch` follows on the item doors, and the one whose
violation cost two review rounds on IDEA-2898 this morning.

Costs nothing on the common path: a destination schema declaring no relation
field runs no query at all.

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

* test(server): pin the type gate on collection_unavailable (IDEA-2899)

Found by a surviving mutant rather than by inspection: dropping the
`def.Type == "relation"` gate left every other test in the file green.

Nothing stops a schema declaring `collection` on a field of another type — the
validator does not police keys it has no use for — and such a field would then
pick up a flag whose meaning is defined only for relations. The dialog would
block a perfectly collectable `select` because some relation elsewhere in the
same schema points at a collection that happens to be gone.

The fixture is the discriminating one: ONE deleted collection, TWO required
rows that name it, and only one of them means anything by it.

Six mutants on this half, all killed: flag never set, flag always set, deleted
target not flagged, unreadable target not flagged, type gate dropped, and the
nil-visible-set case (an admin's "no filtering" read as "nothing visible",
which would flag every target for the callers who can see everything).

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

* feat(web): block a relation whose target is unavailable, and stop advising a command that cannot work (IDEA-2899)

The client half. `isCollectable` now refuses a relation row the server has
flagged, so the row lands in `blockedFields`, Confirm is disabled with a
reason, and no picker mounts that could only come back empty.

`collection_unavailable !== true` is STRICT on purpose. The field is absent
when the target is fine and absent from a server that predates it, so absence
must read as "no information". (Over the domain the type admits — `boolean |
undefined` — the truthiness spelling is EQUIVALENT and a mutant swapping it in
survives; that is recorded in the source rather than papered over with an
off-contract fixture. The strict form is kept because it states the contract
where the next edit will read it, and the inverse spelling would block every
row against an older server.)

THE PART THAT IS NOT WIRING: the existing blocked-field notice said the field
"is a required <type> field. This dialog can't collect a value for that type
safely" and then printed `pad item copy … --field key=value`. Both halves are
FALSE here. The type is perfectly collectable; the TARGET is gone. And the CLI
runs as the same user against the same referent validation, so the command it
prints is refused for exactly the reason the user is already stuck — advice
that sends someone to do work that cannot succeed is worse than no advice.

So the message branches on `uncollectableReason`, names the collection and the
destination workspace, and the CLI line is now gated on `cliFillableField` —
the first blocked row the CLI can ACTUALLY fill. `blockedFields[0]` was
correct while every blocked row was type-shaped; with an unavailable relation
sorted first it named the one field `--field` cannot set either.

Eleven unit tests on `copyNeedsValue`, plus a source pin on the dialog whose
own measured limit is in its docblock. Client mutants: 7 real, 6 killed, 1
recorded as equivalent with the domain argument that makes it equivalent.

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

* feat(cli): the copy preview marks an unavailable relation target and stops suggesting it (IDEA-2899)

Caught by `TestItemCopyMirrorsMatchServerShapes`, not by me. The CLI keeps a
mirror of the preflight response, and adding a field server-side without
mirroring it fails that test by design — a mirror that silently lags is a
mirror that lies. Working exactly as intended, and the reason this half exists
at all.

Mirroring the field turned out to be the smaller part. The CLI already prints
`target collection: people` for a relation row, and it builds an
`Add: --field owner_ref=<value>` suggestion from every unsupplied row. Both
are wrong when the target is unavailable: the first sends a user looking for a
ref in a collection they cannot read, and the second hands them a command the
referent validation refuses for exactly the reason they are already stuck.

So the target line is marked NOT AVAILABLE, and the row is excluded from the
suggestion with a sentence saying why — modelled on the empty-key branch,
which was written for the identical reason (a `--field =<value>` nobody can
run) and is three lines away.

That the same defect had to be fixed in two places is the shape worth naming:
the dialog and the CLI independently built "here is how to supply it" from
"here is a field needing a value", and neither had a notion of a field that
CANNOT be supplied. The empty-key case was the first instance and was fixed
locally; this is the second.

Five mutants on this half, all killed: suppression removed, suppression
applied to everything, the unavailable label dropped, the explanation dropped,
and the mirror field ignored. The available-target control leg is a separate
test so the omitempty contract is exercised on this surface too.

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

* fix(cli): route all three "how to supply it" sites through one predicate (IDEA-2899)

Review found the fix applied at one door and not its siblings — my own
recurring shape, arriving again.

THREE places tell a CLI user how to resolve an unsatisfied field: the detailed
`renderItemCopyNeedsValue`, the `--dry-run` summary, and the error the command
returns. The first commit fixed the render. The other two went on printing
`--field key=value` at someone for whom no value exists — and the ERROR is the
line a script or a hurried reader actually sees, so it was the worst of the
three to leave.

`itemCopyUnfillable` is now the single definition all three consult. Not
because three call sites are tidier than one, but because three sites
independently answering "how do I supply this" is exactly how they diverged in
the first place.

The dry-run summary branches three ways rather than two, because the MIXED
case is the one a boolean gets wrong: some fields can be supplied and some
cannot, and collapsing that either suppresses advice the user needs or offers
advice they cannot use. The error hint is suppressed only when NO field can be
supplied — with one fillable field left, `--field key=value` is still true.

Also pins the BOUNDARY the same review probed: a target collection that is live
and readable but EMPTY is deliberately not flagged. The symptom looks
identical — an empty picker — but the cases differ where it matters. An
unavailable target is unfixable from inside the dialog, so blocking costs the
user nothing they had; an empty collection is resolved by creating the item and
retrying, and blocking would refuse a copy they were about to complete. It
would also cost a live-visible-item count per relation target on a dry run the
UI calls on every keystroke. The weaker case — an empty picker that says
nothing about WHY — is filed as IDEA-2905 and belongs to the picker.

Ten mutants across this round, all killed, including both directions on the
error hint and both directions on the dry-run branch.

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

* fix: unfillable means EITHER reason, and a select never names a relation target (IDEA-2899)

Review round 2, two findings, both real and both about a rule stated in one
place and enforced in another.

**"Unfillable" answered for one of two reasons.** An EMPTY KEY cannot be
supplied either — `--field =value` is rejected by this command's own parser,
and the detailed render has explained that since Codex round 6. Only that
render knew: the --dry-run summary and the returned error went on advising
`--field` for those rows, because the predicate I extracted last commit covered
the relation reason alone. A predicate named "unfillable" that answers for half
its name is a worse trap than no predicate — right at the site that defined it,
wrong everywhere it was reused, which is precisely what extracting it was meant
to prevent.

Two functions now: `itemCopyUnfillable` (either reason — advice), and
`itemCopyUnavailableTarget` (the relation half — the render's own sentence,
since the two explanations are not interchangeable to a reader).

`itemCopyUnavailableTarget` deliberately does NOT also exclude empty keys,
though my first version did. A row can carry both faults, and a mutant removing
that exclusion survived every test — correctly, because all it changes is
printing two sentences that are both TRUE about such a row. The guard was
tidiness dressed as a rule; a condition nothing can distinguish is one the next
reader has to re-derive.

**`Collection` was emitted for non-relation fields**, while its own doc said it
is empty for every other type. That was a claim about the schemas people write,
not a property of the code: a `select` carrying `"collection": "people"` is
storable — field validation has no use for the key and does not police it — and
the value was copied straight through, so the CLI printed "target collection:
people" beneath a select. A relation fact asserted about a field that has none.
`relationTargetSlug` makes the documented contract true at the only place that
can make it true; my own type-gate test had created exactly that shape and
asserted only the FLAG, not the slug.

Three mutants on these fixes, all killed.

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

* fix(cli): the explanation now names the reason that actually applies (IDEA-2899)

Review round 3, and the sharpest miss of this unit — my own, one commit old.

Broadening what a predicate ACTS on silently broadened what a sentence SAYS.
Once `itemCopyUnfillable` counted empty keys as well as unavailable relation
targets, a set of empty-key rows selected the all-unfillable branch and was
explained as "the relation target is not available to you" — a false statement
about rows that contain no relation at all. Same in the returned error, which
is the line a script sees.

The tell was there to be read: a sentence that was TRUE while the predicate was
narrower is a sentence to re-read the moment it widens. I broadened the
predicate deliberately, wrote a commit message about how a half-answering
predicate is a trap, and left the sentence describing the half.

`itemCopyUnfillableWhy` names the reasons actually present — relation targets,
empty keys, or both — and the two one-sentence sites consult it. The detailed
render is unchanged: it explains each reason where the row is printed, which is
why it uses the narrower count.

Four mutants, all killed, including the two that matter: the explanation always
saying "relation" (the defect) and never saying it (the same defect pointing the
other way). The test carries a mixed-reason leg, because a sentence that picks
one of two true reasons is the failure a single-reason fixture cannot see.

Also corrected: three comments claiming `itemCopyUnfillable` is relation-only
or that the detailed render consults it. Both stopped being true last commit.

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

* fix: one row can carry both faults, and four docs said this was simpler than it is (IDEA-2899)

Review round 4. Four findings, no P1s, and the first is the one worth the round.

**A `continue` between the two counts.** `itemCopyUnfillableWhy` counted a row
as an unavailable relation target and then skipped the empty-key check, so ONE
row carrying both faults reported only the first. My mixed-case test used TWO
rows with one fault each — a different input, and the only one it exercised.
Two rows with one fault each and one row with two are not the same fixture, and
I built the weaker one while writing a commit message about fixtures that
cannot discriminate.

**The dialog could still print `--field =value`.** `cliFillableField` excluded
unavailable relation targets and not empty keys, so a required `json` field the
destination reported with no key was type-shaped, blocked, and still offered a
command the CLI's own parser rejects. The CLI has refused those since Codex
round 6; the web side had never learned it. Same defect, other surface —
which is the third time this unit has fixed one door and not its sibling.

**Cardinality.** "no --field can supply it" for several fields, and "reported
them with an empty key" for one. Both sites now agree with their counts, and
the empty-key phrase is neutral on number so it reads correctly after either.

**Four documents claimed every needs_value row is resolvable with an override**
— the CLI renderer's docblock, the server's `NeedsValue` field, the CLI mirror
type, and the dialog's collectability comment. That was true when each was
written and this unit falsified all four; a reader following any of them would
conclude the CLI had simply forgotten to print a flag.

Two mutants on the fixes, both killed: the `continue` restored, and the
dialog's empty-key exclusion removed.

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

* refactor(cli): one tally, because the rounds said the branching was the problem (IDEA-2899)

Four review rounds returned 2, 2, 2 and 4 findings. The counts looked like slow
convergence; the DISTRIBUTION was the finding. Every defect after round 1 lived
in this one layer — how the CLI and the dialog say "here is how to supply it" —
while the server half that computes availability stayed clean throughout.

The layer had accreted exactly the way IDEA-2898's cold path did: a count, then
a second count for the other reason, then a phrase function, then a `continue`
between two counters that made a dual-fault row report half of itself. Round 4
fixed something round 3 introduced to fix something round 2 introduced. That is
not a run of bad luck, it is a shape.

So this round removes branches instead of adding a seventh guard.
`itemCopyTally` walks the rows once and returns what every caller needs;
`AllUnfillable()` is the condition both one-sentence sites test, and `Why()` is
the phrase both interpolate. Three helpers become one type. There is no second
definition of "unfillable" to drift from the first, and no sentence describing
a subset of what a predicate counts, because the sentence and the count come
from the same walk.

`Unfillable` is deliberately NOT `UnavailableTarget + EmptyKey`: one row can
carry both, and double-counting makes `Unfillable == Total` false for a set
that is entirely unfillable — the comparison every caller makes. A mutant does
the addition and dies.

Five mutants, all killed. The last needed a new test rather than a new fixture:
`AllUnfillable`'s `Total > 0` guard is unreachable from both current callers,
so a mutant removing it survived every command-level test. Keeping an
unreachable guard and calling it defence is how a promise becomes a lie, so the
tally is now unit-tested directly — an empty set is not "entirely unfillable",
and a future caller outside the `len() > 0` gate would otherwise be told
silently that nothing can be supplied.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 15:48:22 -04:00
xarmian 07b2e439f2 TASK-2869 (U2b): the preflight names a relation's target collection, and the copy dialog scopes its picker to the destination (#1258)
* feat: the preflight's needs-value row names its relation target, and the copy dialog scopes its picker to the destination (TASK-2869)

U2b, per the day-55 ruling. Both blockers (U1 referent validation, U2 the
FieldEditor branch + picker) are in.

THE DEFECT. `ItemCopyPreflightNeedsValue` carried `type: "relation"` and no
target. FieldEditor gates its relation branch on `wsSlug` AND
`field.collection`, so a required relation in the destination reached the copy
dialog as a field it knew was a relation with no idea what to point at, and
rendered as FREE TEXT. Before U1 the copy stored whatever was typed.

SERVER. `collection` is added to the needs-value row, populated from the
DESTINATION schema's `def.Collection` — the only place it is known, since the
row is built from that schema. Additive and `omitempty`: a client that does not
read it is unaffected, and a row for a non-relation field is byte-identical to
before. Not a wire-version question, for the same reason
`models.ItemWriteWarnings` was not.

CLIENT. `toFieldDef` carries the collection through, and the FieldEditor call
passes `wsSlug={destWs}` — the DESTINATION, never the source. A relation
resolves at the destination, so the picker must list items the copy can
actually point at; that is same-workspace resolution AT the destination, not
the cross-workspace case PLAN-2857 rules out.

`relation` becomes collectable ONLY IF THE ROW NAMES ITS TARGET. Without a
collection, FieldEditor's gate renders the non-editable state, so offering the
row would produce a control that cannot be filled and a Confirm that cannot be
satisfied. Such a row now lands in the blocked list and the user is told which
field and why — the same disposition `multi_select` gets, for the same reason:
a control that silently cannot do its job is worse than an honest refusal.

TWO THINGS THIS UNIT TAUGHT ME THAT ARE NOT IN THE RULING.

1. THE WEB MUTANT SURVIVED, AND THAT IS WHY `isCollectable` MOVED. My first
   version left the predicate inline in `CopyItemDialog.svelte`. Making
   `relation` unconditionally collectable — the exact defect the negative leg
   of the proving test is about — passed EVERY suite in the repo. That is
   IDEA-2894's lesson arriving one unit later in the same file, so the
   predicate now lives in `$lib/items/copyNeedsValue` with tests. Two mutants
   die there: relation-always-collectable, and `multi_select` slipped into the
   collectable set.

   The Go half was pinned from the start (drop `Collection: def.Collection` ->
   FAIL naming the empty value and the expected slug). Only the client half was
   unpinned, and only because of where the code lived.

2. A U2-ERA TEST ASSERTED THE ABSENCE THIS UNIT CLOSES, and asserted it
   CORRECTLY. `fieldEditorRelationCallers.test.ts` required that the dialog
   pass no `wsSlug` and build its FieldDef from a shape with no `collection` —
   which was the behaviour, and withholding `wsSlug` was what kept an unscoped
   picker out. It also named this task by ref and told its successor to revisit
   the gate WITH the change rather than let it drift. Inverted here: the block
   now asserts the destination slug is passed, that the collection reaches the
   FieldDef, and — the half that is easy to lose — that the dialog still
   DELEGATES the collectability decision, so a future inlined predicate would
   pass the unit tests and fail this.

   Worth keeping: a test that pins a temporary absence should name what would
   make it wrong. This one did, and that is the only reason its inversion was a
   five-minute job instead of an argument about whether it was load-bearing.

Gates: `internal/server` ok 158.335s, `go vet` and `gofmt` clean,
`npm run check` 0 errors (6 pre-existing warnings), `make web-test` 127 files /
2123 tests. Postgres and CI are owed on this tip.

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

* fix(cli): mirror the needs-value collection, and name the relation target in the CLI (TASK-2869)

TWO CONSUMERS I DID NOT SWEEP. The previous commit added `collection` to the
preflight's needs-value row and updated the server struct and the TypeScript
type — "both sides", as its own message put it. There are THREE sides.
`internal/cli` keeps a mirror of the preflight shape and
`TestItemCopyMirrorsMatchServerShapes` requires it to match the server field
for field. It failed in the Postgres gate, in a package the change did not
touch.

That is the third time this session a producer change broke a consumer I had
not enumerated, and the shape is always the same: I name the surfaces I edited
and call that the population. The instrument that would have caught it is not
"run more tests" but "grep for the type's name before claiming the sweep is
done" — `ItemCopyPreflightNeedsValue` appears in exactly three files and I
looked at two.

Mirrored, with a comment saying why it exists: a mirror that silently lags is a
mirror that lies, and the CLI renders these rows.

AND THE CLI NOW NAMES THE TARGET COLLECTION, which is the point of the unit on
the surface that has no picker at all. A row reading

    owner_ref            (Owner, relation) required — required, with no value…

tells a user a value is needed and nothing about what kind of value exists.
The dialog answers that with a scoped picker; the CLI had no answer. It now
prints the relation analogue of the `options:` line a select already gets:

    owner_ref            (Owner, relation) required — …
                           target collection: people

Test asserts the line appears for the relation row, appears EXACTLY ONCE with a
select row rendered alongside — so it cannot pass by printing unconditionally —
and that the select's own `options:` line still renders, so this did not
displace it.

Gates: `internal/cli` and `cmd/pad` green, build and gofmt clean. The full
Postgres run and CI are owed on this tip; the earlier PG run is the one that
caught the mirror and is superseded.

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

* fix(web): the relation picker searches the preflight's canonical destination slug (TASK-2869)

Codex review, finding 3 of 3, and the only one of the three that belongs in
this unit.

`wsSlug={destWs}` handed `FieldEditor` a value that is NOT always a workspace
slug. An item can be opened through a workspace-UUID URL, the route parameter
is passed straight through as `sourceWsSlug`, and a same-workspace copy then
puts that UUID in `destWs`. `/search` resolves a workspace by SLUG only, so a
picker handed a UUID searches nothing and returns no results — a control that
looks usable, is not, and says nothing about why.

Now `pickerWsSlug`, which is the preflight response's own
`destination.workspace_slug`. The preflight IS the canonicalising round-trip:
the server resolved whatever it was given and answered with the real slug.
Falls back to `destWs` only before the first preflight returns, at which point
no needs-value row is rendered anyway.

The caller test asserts the prop AND the derivation, because asserting only the
prop would pass against a `pickerWsSlug` that was just `destWs` renamed.

THE OTHER TWO FINDINGS ARE REAL AND ARE FILED, NOT FIXED HERE.

IDEA-2898 — `ItemPicker` serves warm local-index results without re-authorising
them, so a collection whose access was revoked can still be listed. The cold
`/search` path is visibility-filtered and correct; the warm path is not. This
is PRE-EXISTING and applies to every caller of the picker, `ItemDetail`
included — last touched by TASK-2877, not by this unit. U2b widened the
exposure by adding a caller; it did not create the defect, and rewriting the
picker's cache-authorisation model inside a feature branch would be an
unrelated change riding along. The fix needs a decision about where the client
learns its access set from, which no current signal provides.

IDEA-2899 — a relation row can name a target collection that is DELETED or
UNREADABLE, so `isCollectable` says yes on a non-empty string and the user
meets a picker with nothing in it. I could not fix this correctly here, and the
reason is measured rather than assumed: the obvious test is to check the target
against `destCollections`, and that list is filtered by `canEditCollection` —
it is what the user may copy INTO. A relation TARGET needs only READ access, so
a perfectly usable target routinely is not in it. Using it would OVER-BLOCK,
refusing rows the user could have filled, which is a worse failure than the one
being fixed and invisible to whoever hits it. The right shape is probably the
server reporting the target's availability on the row it already builds — an
additive field on the same row this unit just changed, worth doing deliberately
rather than bolted on at the end of a branch.

Gates: `npm run check` 0 errors (6 pre-existing warnings), `make web-test` 127
files / 2123 tests. Postgres is running on this tip; CI is owed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-05 21:37:44 -04:00
xarmian cfc83e8c57 fix(server): report partial and legacy relationships in the copy dry-run (TASK-2369)
Two ways the cross-workspace copy preflight told a user "nothing to lose"
when there was, both violations of PLAN-2357 DR-17's "none of this may be
silent".

P1 — the five relationship counters are ACL-filtered by the caller's
collection visibility (correct, and TASK-2364 chose it deliberately), but
"none" and "none that you can see" rendered identically. A caller with
edit rights on the source and none on its relatives could read
`children_orphaned: false` and run a MOVE believing nothing was stranded,
while hidden children were orphaned in place.

The filtering stays; the uncertainty is now surfaced. Every point that
drops a relationship for visibility reasons sets a new
`warnings.relationships_partial` boolean. It is a BARE BOOLEAN by design:
how many are hidden, of what type and in which collection are exactly the
facts the filter exists to withhold, and a marker that varied with the
hidden count would reinstate the leak DR-10a, DR-10b and the moved-to
pointer each closed separately. A negative test asserts byte equality of
the whole warnings block across two workspaces that differ only in how
much is hidden. It is false for an unrestricted caller AND for a
restricted caller with nothing hidden, so the common case renders exactly
as it did before.

P2 — a child reachable only by a lone legacy `plan` edge was invisible to
GetChildItems (its join is restricted to store.ChildLinkTypes), so an
incoming `plan` relationship reported `child_count: 0` /
`children_orphaned: false` even though archiving the source strands it.
The link scan now folds such an edge into the child set, deduplicated
against the two mechanisms already covered and subject to the same
visibility, liveness and workspace guards. The outgoing direction (the
item's own parent) already reported correctly.

The mutating copy reports no relationship counters at all
(ItemCopyResultWarnings is deliberately narrower), so there is nothing for
assertPreflightMatchesCopy to disagree about.

CLI renders the qualifier on the five affected lines plus a plain-language
explanation; TS types carry the field for Phase 3's dialog.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 04:26:55 +00:00
xarmian 1e48a7a1dd feat(cli): add pad item copy for cross-workspace copy and move (TASK-2366)
Wraps PLAN-2357's two endpoints behind one command:

  pad item copy <ref> --to-workspace <slug> --collection <slug>
                      [--dry-run] [--archive-source] [--field key=value ...]

--dry-run renders the preflight's three contract buckets (carried /
dropped / needs_value) and DR-15's full warning set. Every bucket header
and every warning line prints unconditionally, zeros and empties
included: omitting a zero would make "no attachments" indistinguishable
from "this CLI does not report attachments", and DR-17's whole point is
that none of it is silent. Schema-supplied strings are escaped and list
members quoted, so a comma or newline in an option value cannot forge an
entry or a row.

--format json emits the endpoint's own response. json.Indent is a lexical
transform, so key order, unmodelled fields and int64 precision all
survive; the bytes are never round-tripped through a Go value.

DR-13, the no-retry obligation. There is no idempotency key, so a blind
re-run duplicates the item. Four mechanisms, each with a test:

  1. the mutating copy runs on its own *http.Client AND its own
     transport. The transport half is the one that matters: retry in Go
     is almost always a RoundTripper wrapper, which a merely-dedicated
     http.Client would inherit. A plain *http.Transport is cloned so
     proxy/TLS config carries; a wrapper is not used at all;
  2. its body is hidden behind an opaque reader, leaving Request.GetBody
     nil so net/http's own nothing-written replay cannot fire;
  3. redirects are refused rather than followed with the POST body;
  4. failures are classified into three exclusive outcomes, because each
     licenses a different thing to say. UNKNOWN (transport failure, 500
     copy_failed) sends the user to check the destination and never
     suggests a retry. COMMITTED-BUT-UNREPORTED (a 2xx whose body could
     not be read or decoded) exits ZERO -- a non-zero exit would tell a
     script the copy did not happen, which is the DR-13 duplicate
     arrived at through the reporting layer. A 4xx is a refusal made
     before any write and passes through plainly.

The same asymmetry governs stdout: a write failure on the dry run is an
error (nothing happened), while a write failure after the copy committed
goes to stderr and leaves the exit code at 0.

Refuse to guess. The preflight always runs first (it is read-only), and a
non-empty needs_value refuses before any mutating request, naming each
field and the exact --field flags to add. Mirrors the web dialog's
disabled confirm rather than round-tripping the user into an error they
could have been shown.

--field values are typed against the DESTINATION collection's schema, so
a number lands as a number. A malformed --field is a hard error here
rather than the silent skip `pad item create` does: this command's
contract is "you were told what to supply", and dropping a supplied value
would make the refusal a lie.

The response types in internal/cli mirror internal/server's. That is a
layering choice, not a cycle -- nothing in server imports cli, and the
mirror test imports server freely. It follows the posture already
recorded in internal/cli/bootstrap.go: this package is the HTTP client
and does not depend on the server package. An external cli_test package
walks both response shapes and fails on any JSON contract drift.

MCP is deliberately untouched: no pad_item.action: copy, and
ToolSurfaceVersion stays 0.15.
2026-07-31 02:23:24 +00:00