Card validation

How bbx validate reaches agents and commits inside a box, and the canonical ref rewrite.

What it is

How card validation reaches agents and commits inside a box. Installed during bbx init by src/core/install-validation-hooks.ts; the hook commands embed the absolute path to the installing bin/bbx so they don't depend on the user's PATH.

bbx validate checks all cards, a list of files, or --staged. Cards also validate on load (src/core/card-io.ts).

Engine-spawned agent runs (wakeup, procedures, chat) get an in-process SDK callback instead of the settings-file hook: cardValidatorHook (src/core/sdk-hooks.ts) matches PostToolUse of Write/Edit/MultiEdit and, by file type, compiles a view file, rejects a trick script outside tricks/scripts/<name>/, lints a .card (src/core/card-lint/core/lint-cards.ts), warns on a connector-owned markdown file, or runs markdownlint on built-in markdown. Every result is injected as additionalContext; none blocks the edit.

Three hooks are installed per box:

Body Markdoc

Every card body is parsed and validated against the core Markdoc tags plus the card type's markdocTags (see schemas). Findings are warnings; a tag the type does not declare is Undefined tag.

Canonical ref form (--canonical)

Refs should be written from the box root (/_content/notes/Plan.doc.card); the one exception is a card's own attach/… scope. A document-relative ref still resolves — liberal resolution is permanent (src/shared/ref-path/core.ts) — but it means something different depending on where the document lives.

bbx validate --canonical reports every ref written in the relative form, naming the box-root rewrite for each, with a two-bucket summary: card refs (frontmatter ref/refs, body Markdoc ref=, body links, view cardRef=) and .md dossier links. --canonical --fix performs those rewrites in place — but only for refs whose target actually exists; a dangling or box-escaping ref is reported and left alone. The rewrite is text-surgical, so frontmatter key order and ?query / #fragment suffixes survive untouched.

Box-root-intent rescue. Old system code wrote bare refs meaning them from the box root — a question card's ref: _content/inbox/scan-….capture-session.card, a chat thread's participants[0].ref: _content/people/Priya_Marlowe. Read document-relative, those dangle; read from the box root they resolve. So when a non-canonical ref's document-relative target does not exist, --fix tries the same bare path from the box root, and if that target exists writes the /-leading form — turning a broken ref into a working one. Repairs are counted and reported separately from ordinary canonicalizations (which only change how a ref is written, never what it points at), and the report mode marks them → /_content/inbox/… (repairs dangling ref).

The rescue is strictly gated by an ambiguity guard: if both readings name an existing file, the ref is left exactly as written and reported as ambiguous. The document-relative reading is what resolves at runtime today, so such a ref already works — rewriting it either way could silently retarget it, and that call belongs to a human. Same for a ref neither reading resolves: reported, never rewritten.

It is off by default and never runs in the hooks: a box carries legacy relative refs by the hundred, and warning about them in every validate run would bury the broken-ref signal that actually needs acting on. It is a whole-box check — it does not combine with --staged, explicit paths, --hook, --links, or --urls, which error rather than half-work. Design rationale: docs/implemented-plans/box-root-paths.md (Track F).

Format reference: format; design history and migration phases: docs/implemented-plans/cards-as-markdown-rfc.md. Migrators and their field-loss rules: migrations.