fix(server): a refused collection prefix reaches the caller as a 400 naming the rule (BUG-2951)
BUG-2943 made the store refuse a prefix outside the grammar, with a message
naming the rule and an example. Three of the four doors onto that refusal threw
the message away: they mapped conflict shapes and sent everything else to
writeInternalError, so `pad collection update docs --prefix "ab1"` answered
"An internal error occurred". The refusal kept its data-protection value and
lost its entire teaching value — a 500 with no text reads as an outage, so the
honest user response is to retry or report one.
An MCP agent was told something worse than nothing. internal/mcp classifies a
stdio failure by matching CLI stderr prose; the generic message matches none of
the validation patterns, so a permanently-invalid prefix arrived as the
RETRYABLE server_error code and the correct agent response was to retry a call
that can never succeed. Nothing in internal/mcp changes here: the store's own
message now reaches stderr and the existing `invalid` pattern recognises it.
Both directions are pinned by tests, including the negative control that the
old generic string still classifies as server_error — which is right for a real
internal failure, and is why the refusal had to stop wearing that message.
The population, read door by door rather than grepped:
- CREATE and UPDATE lost the message entirely (500, no text).
- DELETE kept it via strings.Contains on the store's error text — the right
status by the wrong mechanism: a reworded refusal became a 500 silently.
- Workspace IMPORT kept the text under a 500, while its sibling bundle-import
door already answered 400 for the same class.
store.ValidationError carries the caller-facing Reason, with AsValidationError
for the doors, following the InvalidDocumentTitleError precedent in the same
package. Constructing it is the per-site DECISION that a message is safe to
show; the alternative — returning err.Error() from the generic path — makes
that decision by default for every error any layer may later add. Doors render
Reason, never Error(), because Error() carries the sentinel prefix and whatever
a call path wrapped around it; a test helper asserts no response leaks that
prefix, after a mutant swapping Reason for Error() survived every message
assertion (Reason is a substring of Error(), so a contains-check cannot see it).
Two sites are deliberately NOT converted, both read and left:
- The template-seeding trait validation (collections.go) checks FIRST-PARTY
template code, not caller input. A 500 is the honest answer there.
- The two expected_updated_at refusals are converted for uniformity but are
unreachable through HTTP — both doors validate the token at the boundary.
They are defence in depth, not live paths.
WIRE CHANGE: POST /workspaces/import now answers 400 for a caller-input refusal
where it answered 500. The code string (import_failed) and the message are
unchanged, and its sibling bundle-import door has always answered 400 for this
class, so this aligns two doors onto one refusal. Ruled by the lead rather than
decided here. "Cannot delete a default collection" stays 400.
Codex round 1 caught the consumer this change created: the tar.gz import door
renders a bundle failure through its own envelope and its fallback wraps
err.Error(), so the very edit that made the JSON door actionable moved the
sentinel prefix into the bundle door's message. It now detects the type and
renders Reason in its own "Bundle pad-export.json is not importable" envelope,
with a test and a mutant. A producer change is not finished until its consumers
have been read; round 2 was CLEAN.
Verified by negative control (each door arm removed in turn) and a ten-mutant
matrix in which every arm removal, every store site reverted to fmt.Errorf, and
both Reason→Error() swaps are killed by a named test. One matrix attribution
was wrong on first run — a store test appeared as a casualty of a server-side
mutant — and re-running it in isolation showed the mutant does not affect it;
the runner had attributed every FAIL line in a two-package run to the mutation.
Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR