mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
89a9fb0241
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
59 lines
2.6 KiB
Go
59 lines
2.6 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// ErrValidation reports a write the store refused because of what the CALLER
|
|
// asked for, as opposed to anything that went wrong inside the server.
|
|
//
|
|
// The distinction is the whole point of the type. A handler that cannot tell
|
|
// the two apart has one honest option — 500 with no text — and that is what
|
|
// three of the four collection doors did with the prefix-grammar refusal
|
|
// (BUG-2951): the store composed a message naming the rule and the example,
|
|
// and the caller was told "An internal error occurred", which reads as an
|
|
// outage rather than as "fix your input". An MCP agent is told worse than
|
|
// that: internal/mcp/errors.go classifies stdio failures by matching CLI
|
|
// stderr prose, the generic message matches none of its validation patterns,
|
|
// so the refusal arrives as the RETRYABLE server_error code and the correct
|
|
// agent response to it is to retry a call that can never succeed.
|
|
var ErrValidation = errors.New("store: validation")
|
|
|
|
// ValidationError carries the caller-facing reason a write was refused.
|
|
//
|
|
// Reason is the contract: a door renders THAT field, never Error(), because
|
|
// Error() carries the sentinel prefix and any wrapping a call path has added,
|
|
// and none of that is addressed to the caller. Same rule, and for the same
|
|
// reason, as InvalidDocumentTitleError's Reason (documents.go).
|
|
//
|
|
// Constructing this type is the per-site DECISION that a message is safe to
|
|
// show — it says "this text quotes the caller's own input and the rule it
|
|
// broke, and nothing about how the server is built". The alternative shape,
|
|
// where a door returns err.Error() for anything it does not recognise, makes
|
|
// that decision by default for every error any layer might add later, which
|
|
// is how internal detail escapes.
|
|
type ValidationError struct{ Reason string }
|
|
|
|
func (e *ValidationError) Error() string { return ErrValidation.Error() + ": " + e.Reason }
|
|
|
|
func (e *ValidationError) Unwrap() error { return ErrValidation }
|
|
|
|
// AsValidationError reports whether err is (or wraps) a ValidationError, and
|
|
// returns it. Doors use this instead of a bare errors.As so the Reason-not-
|
|
// Error() rule has one place to be stated.
|
|
func AsValidationError(err error) (*ValidationError, bool) {
|
|
var v *ValidationError
|
|
if errors.As(err, &v) {
|
|
return v, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// invalidf builds a ValidationError. Unexported: the store decides what is
|
|
// caller-visible, and a caller-supplied Reason from outside the package would
|
|
// be a text-injection channel into 4xx bodies.
|
|
func invalidf(format string, args ...any) error {
|
|
return &ValidationError{Reason: fmt.Sprintf(format, args...)}
|
|
}
|