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:
.claude/settings.json— PostToolUse hook runningbbx validate --hookafter Edit/Write/MultiEdit. Warning-only results use exit 0 with JSONhookSpecificOutput.additionalContexton stdout, so the agent sees the warning while the completed edit remains successful. Errors use exit 2 with stderr; the edit has already happened, and pre-commit blocks invalid commits. The Codex plugin uses the same command afterapply_patch. A boxAGENTS.mdsize warning appears once per file and size tier in each agent session. The bounded notice cache is gitignored at.beebox/validate-hook-warnings.json; a clean edit resets that file's notice, and a new session gets its own warning..git/hooks/pre-commit— runsbbx validate --pre-commit; blocks commits that include cards failing validation. That one invocation is the whole commit-time suite (it replaced three separatebbxcalls, each of which paid the CLI startup cost — seedocs/plans/commit-performance.md): staged card/markdown validation (blocking, same as--staged); a box-wide broken-link scan (warn-only on stderr, and only when the staged diff deletes or renames a path — nothing else can dangle a link in a file that isn't staged); and the unlisted-binary guard, read from the index rather than by walking the box (bbx attachments check-unlistedremains for on-demand use, as doesbbx validate --links)..git/hooks/post-commit— firesbbx validate --urls --urls-since HEAD~1in the background (non-blocking) to HEAD-check external http(s) URLs the first time they appear. Warning-only, never gates; verdict cache is gitignored at.beebox/url-checks.json. The synchronous lint never touches the network — only this pass does. Seedocs/implemented-plans/external-url-validation.md.
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.