SPEC-0019: Actionable Validation Errors
Graph Edges
- Implements: ADR-0025: validation errors carry field, reason and limit.
- Requires: SPEC-0002: the REST surface and ADR-0012 envelope this extends.
- Related: SPEC-0006 (annotation validation), SPEC-0007 (MCP tool
errors), SPEC-0008 (CLI rendering and tag folding), SPEC-0004 (span
validation), SPEC-0009 (policy and TTL endpoints), SPEC-0017
(redaction rejections use this contract with
secret_detectedandtoo_large_to_scan).
Overview
A validation_failed response today says only "the request was invalid". This
capability defines a violations array: field, location, reason code, limit, a
bounded echo of the value, and a human message. It is rendered identically on
REST, MCP and the CLI. It is additive to the ADR-0012 envelope, and leaves
not-found, unauthorized and forbidden responses byte-identical. The CLI also
folds tag case, with a warning. See ADR-0025.
Requirement IDs (VE-n) are stable and are cited as SPEC-0019 VE-n.
Requirements
Requirement: VE-1 Violation Shape
A validation_failed or payload_too_large error envelope MUST carry a
violations array with at least one entry. Each entry MUST have:
field: the caller-facing name (header, query parameter, form field, or a JSON path such astags[2],members[3].content,spans[12].args);location: one ofheader,query,form,body,path;reason: a code from the VE-2 registry;message: a human sentence built from the other fields.
An entry MAY have:
limit: a JSON number or string;unit: one ofseconds,bytes,count,chars;value;- reason-specific keys that VE-2 documents (for example
rule,line,column).
Scenario: TTL over the cap
a create sends X-Cairn-Ttl-Seconds: 5184000 to a server whose maximum is 30 days
the response is 400 validation_failed, with one violation whose field is X-Cairn-Ttl-Seconds, location header, reason exceeds_max, limit 2592000, unit seconds, and value "5184000"
Scenario: Malformed TTL
a create sends X-Cairn-Ttl-Seconds: 7d
the violation's reason is invalid_format, and its message says the value must be a positive integer number of seconds
Scenario: Oversize body
a body exceeds the upload cap
the response is 413 payload_too_large, with one violation whose field is body, reason too_large, limit the cap in bytes, and unit bytes
Requirement: VE-2 Closed Reason Registry
reason MUST be one of: required, invalid_format, invalid_charset,
uppercase, too_long, too_short, too_many, exceeds_max, not_positive,
unknown_value, duplicate, mismatch, not_allowed, checksum_mismatch,
too_large, too_large_to_scan, secret_detected, invalid.
a test enumerates every reason constant in code each is in this registry, and each registry entry is documented in the public error referenceinvalid is reserved for sites not yet migrated (VE-6). A new reason MUST be
added to this registry, and to the public error reference, before any code
emits it. secret_detected entries MAY carry rule, line and column.Scenario: Registry is enforced
Requirement: VE-3 Safe Value Echo
value MUST be the offending input, truncated to at most 64 bytes on a UTF-8
boundary. It MUST be omitted when:
- the reason is
secret_detected; - the field carries body content (
body,members[*].content, comment bodies, span args or outputs); - the field is a credential-bearing header.
a create carries the tag the violation's field is a code artifact is rejected for a detected credential (Cairn SPEC-0017) the violation has reason value and message MUST be JSON-encoded, and MUST NOT be rendered as HTML by
any Cairn surface without escaping.Scenario: Tag echoed
Size:Mtag, its reason is uppercase, and its value is "Size:M"Scenario: Secret never echoed
secret_detected, rule, line and column, and no value key
Requirement: VE-4 Cumulative Where Cheap
Validators over lists MUST report every failing element, not only the first:
- tags from every source on one request;
- bundle members' names and sizes;
- span-tree structural errors within one batch.
The top-level a create carries tags there are two violations, message MUST be the first violation's "<field>: <message>"
when there is one violation, and "<n> problems: <first field>: <first message>; …"
when there are several.Scenario: Two bad tags
Size:M and lane muppercase for Size:M and invalid_charset for lane m, and the message begins 2 problems:
Requirement: VE-5 Envelope Compatibility and Uniformity
code,message,detailsandrequest_idMUST keep their ADR-0012 types.detailsMUST remain a flat string map, carrying only handler-supplied keys. It MUST NOT duplicate violation data: field names appear only inviolations. No key is added solely for old clients (pre-1.0).- Responses with codes other than
validation_failedandpayload_too_largeMUST carry noviolations, and MUST be byte-identical to their pre-change form. Not-found stays uniform across unknown, unauthorized and expired ids.
Scenario: Old client decodes
a client that decodes details as map[string]string and ignores unknown keys receives a new validation error
it decodes without error, and shows the new top-level message
Scenario: Not-found unchanged
an unknown artifact id is read
the response body is byte-identical to the pre-change body (apart from request_id)
Requirement: VE-6 Domain-Built Violations and Migration Guard
Violations MUST be built in the domain layer as a typed error that wraps
ErrValidation, so CodeOf and errors.Is are unchanged. Transports MUST NOT
derive violations by parsing error strings. A bare validation error MUST render
as one violation with field request, reason invalid, and the generic message.
A test MUST enumerate the client-reachable validation sites for artifact create,
bundles, tags, TTL, policy, annotations, runs and hooks. It MUST fail if any of
them returns a bare validation error. an unmigrated validator fails the response carries one violation with reason a contributor changes a migrated validator back to a bare validation error the migration guard test fails, naming the siteScenario: Unmigrated site still renders
invalid, and the top-level message is the generic oneScenario: Guard catches a regression
Requirement: VE-7 MCP Parity
A failing MCP tool call caused by validation MUST return:
- an error result whose text content is the top-level message;
- structured content
{"code": "validation_failed", "violations": [...]}with the same violations REST would return for the equivalent request.
It MUST NOT return the bare string validation_failed: the request was invalid.
Every typed tool's published an agent calls the tool error's structured content contains a violation with field a client that validates the error content validates against the schema published at outputSchema MUST admit both its success shape
and this error shape, so a client that validates structured content — the
TypeScript SDK does, on error results too — shows the violations instead of
failing the schema check and discarding them.Scenario: Agent learns the tag rule
artifact_create with the tag Handofftags[0], reason uppercase, and value "Handoff"Scenario: A schema-validating client shows the violation
structuredContent against the tool's published outputSchema receives the failing artifact_create resulttools/list, and the violation is shown rather than thrown away
Requirement: VE-8 CLI Rendering
The CLI MUST decode stderr reads violations, and print one line per violation to stderr. It
MUST map wire fields to its own flags: X-Cairn-Ttl-Seconds is --ttl, tag
and tags[n] are --tag, and X-Cairn-Type / type is --type. It MUST
express TTL limits in the largest whole unit (30d). It MUST exit with the
usage exit code for validation_failed, as today.Scenario: The reported TTL failure explains itself
cairn add notes.md --ttl 60d is sent to a server with a 30-day maximumcairn: --ttl 60d exceeds the server's maximum of 30d, and the exit code is the usage code
Requirement: VE-9 CLI Tag Case Folding
Before sending, the CLI MUST lower-case each the request carries the CLI sends it unchanged, and renders the server's --tag value that contains
uppercase ASCII. For each changed tag it MUST print
cairn: warning: tag "<given>" sent as "<folded>" to stderr. It MUST NOT alter
any other character, and MUST NOT fold on any non-CLI surface. The server's
rejection of uppercase tags (ADR-0018) is unchanged.Scenario: Uppercase tag folded with a warning
cairn add plan.md --tag size:M runssize:m, stderr shows the warning, and the create succeedsScenario: Charset still the server's call
cairn add plan.md --tag "lane m" runsinvalid_charset violation
Requirement: Error Handling Standards
- Errors MUST be wrapped with context at each layer. The violation MUST survive
wrapping (
errors.As). - Sentinel errors MUST remain discoverable with
errors.Is(err, errs.ErrValidation). - The server log line for a validation failure MUST keep the full internal error string. Only the response is shaped by violations.
- Structured logging MUST be used.
Scenario: Log keeps the internal detail
a TTL violation is rendered
the log line still carries the wrapped internal error, and the response carries only the violation
Security Requirements
- Authentication: Unchanged. Violations are returned only on requests that reach validation. An unauthenticated request still gets the uniform 401 first.
- Information disclosure: Only
validation_failedandpayload_too_largegain detail. Violations name caller-supplied fields and server-published limits, never internal state, other users' data, internal ids or storage errors. Not-found uniformity is preserved (VE-5). - Rate limiting: Unchanged.
- Security headers: Unchanged. Error bodies are JSON.
- Request body size limits: Unchanged. The 413 now states the limit.
- CSRF protection: Unchanged.
- Redirect validation: Not applicable.
- Output encoding: Echoed values are capped (VE-3) and JSON-encoded, and secrets are never echoed.
Accessibility Requirements
Web forms that surface these errors (the share dialog's TTL control, and the comment composer):
- WCAG 2.1 AA is the target.
- ARIA landmarks: unchanged.
- Icon-only controls: an error icon MUST carry an accessible name.
- Dynamic content: a violation message MUST be announced through a polite
live region, and associated with its control through
aria-describedby. - Keyboard navigation: focus MUST move to the first invalid control after a rejected submit.
- Focus management: in the share dialog, focus stays trapped in the dialog while an error is shown.