ADRs
0086. Garzone is grounded in the project's design tokens by a client-composed prompt + a role-aware guard

Status: Accepted (2026-08-17) — builds on ADR 0085 (client-side agent loop). Adds no backend/wire change; keeps all of ADR 0085 and ADR 0084. Date: 2026-08-17 Deciders: founder + co-admin (per Rules.md governance)

Context

Through P5, Garzone could edit the tree but had no design-token awareness. The model received only the raw instruction text and the tool catalog — no system prompt existed (route.ts called streamText({ model, messages, tools }) with no system), and the adapter read the package seed tokens, not the project's live catalog. Two failure modes followed:

  • Ungrounded generation. Asked for "a button", the model invented raw hex colours and arbitrary px values — off the project's token system entirely, violating the Arno canon that every colour/space/radius comes from a token (CLAUDE.md § UI & Design Implementation).
  • Role-blind writes. Nothing stopped a Surface colour landing on text, a Border colour on a fill, or a raw hex reaching a prop.

"Train Garzone to use tokens" was the P6 ask. Fine-tuning was never on the table: no inference infra for a custom model (ADR 0021's 30K-MAU, no-dedicated-infra ceiling), and a learned component is neither reproducible nor unit-testable. The design tree, the token registry, and undo all live in the browser (ADR 0085), so the grounding mechanism had to live there too.

A further wrinkle surfaced during verification: the token value-form is kind-dependent. globals.css defines colour vars in id-form (--text-primary) and the live cascade (TokensStudioContainer.tsx) pushes colour/gradient/type to :root — but spacing is keyed by px (--space-8, not --space-100) and no --radius-* var exists at all. So a var(--space-100)/var(--radius-2) in a tree prop renders 0. Grounding could not be a single "always emit a var" rule.

Decision

Ground Garzone through two client-side mechanisms — prompt + guard, not fine-tuning — and make the applied value kind-aware.

1. Grounding-by-context (A)

apps/web/src/features/garzone/token-context.ts (composeTokenContextMessage) builds a single { role: "system" } message from the live getFoundationStore().getTokenGroups(), tagged by origine colour section (Text / Surface / Border / Icon / Accents), listing the live spacing/radius scale and type tokens, plus the rule-set (token-only kind-aware form, role→prop table, clarify-on-ambiguity, relay-guard-clarifications, reads-are-untrusted). The runner prepends it at messages[0] so it rides every turn's re-POST. It is a pure function of the store; a cold store returns the full seed catalog (never empty), so the first command is always grounded.

2. Role-aware enforcement guard (B)

apps/web/src/features/garzone/token-guard.ts (checkProp(key, value, catalog)) is a pure, deterministic guard wired into the adapter's setProp, insertNode, and insertSubtree (the last two normalize props/subtree recursively — validate and apply, not validate-and- discard). It enforces the §4 role→prop allow-map: a wrong-section colour token, a raw hex/rgb/hsl/named colour, or an off-scale length returns needs-clarification and the write becomes a no-op that never reaches the pure tree op. Correct values pass through as the applied value.

3. Kind-aware applied form

  • Colour props store the id-form var(--<id>) reference — it cascades and renders.
  • Spacing / radius props store the resolved live-scale px (e.g. 8px); a var(--space-*)/var(--radius-*) the model may emit is resolved to its px and normalized (never stored as a var, which would render 0). The grounding constraint for these kinds is a value constraint (must equal a live scale step), not a reference form.

4. Clarify-on-ambiguity is the core interaction rule

When the token is ambiguous, the request doesn't map to a token, or a value would violate a role, Garzone asks and stops — never guesses, snaps to a nearby value, or silently drops. The guard's needs-clarification (question + detail) is threaded back to the model, which relays it and stops with finishReason: "stop" (zero write commits nothing).

Consequences

  • Reproducible, testable "training". Behaviour is a pure function of (store) and (key, value, catalog) — the whole capability is unit-tested with a fake live store and scripted turns, zero network, no key (web 345/345). No weights, no drift.
  • Token-native, role-correct output by construction. Generated designs use real project tokens in role-correct slots; a wrong-role or raw value cannot reach the tree.
  • Kind-aware correctness. Colours cascade live; spacing/radius render because they store the resolved px, closing the "var renders 0" class of bug.
  • Live, not seed. The adapter and grounding read the live store, so a maintainer's edited token values flow through automatically.
  • Known limitation (value-staleness race). The runner warms the store non-blocking rather than awaiting it (awaiting turned 4 pre-existing runner tests into ~5s network calls). On a hard-nav cold submit a maintainer-edited value can be momentarily stale in the prompt text — but the first command is still grounded (seed catalog), colours cascade from live CSS regardless of the printed value, and off-scale spacing/radius is caught by the guard. Clean fix (restore the await + mock the loader in the 4 network-hitting runner tests) is a queued follow-up. Detail: requirements/garzone-token-grounding.md §10.
  • C deferred. Letting Garzone author tokens (origine ToolPort write-back via setTokenValue) is out of scope: the origine mutation PUT is maintainer-only (apps/api/src/sorgente.ts → 403 for a non-maintainer), so it needs a per-project write path first. Detail-build in its own run.

Alternatives considered

  1. Fine-tune / distill a token-aware model — rejected: no inference infra (ADR 0021), not reproducible, not unit-testable, and stale the moment a project edits its tokens.
  2. Server-side system prompt on the Worker — rejected: the live token catalog and tree are browser state (ADR 0085); the Worker is a stateless per-turn proxy with no store access. Building the prompt there means shipping the catalog to the edge every turn.
  3. Emit var(--…) for every kind (uniform reference form) — rejected: spacing/radius CSS vars don't exist / aren't cascaded, so those refs render 0. The applied form must be kind-aware.
  4. Auto-snap an off-scale or wrong-role value to the nearest valid token — rejected: a silent snap is a wrong write the user didn't ask for. Clarify-and-stop keeps the user in control (§8 No auto-snap).
  5. canonicalVarOf() label-form vars (--ar-colours-surface-canvas) — rejected: that is the developer copy/export name, not what renders on a canvas box. Colour props use id-form vars per the component-builder precedent (color-tokens-seam.ts).

References

  • Builds on ADR 0085 (client-side loop, browser tree
    • undo + token singletons), ADR 0084 (ToolPort / adapter, token-SSOT rule D6), ADR 0021 (no dedicated inference infra).
  • requirements/garzone-token-grounding.md (P6 authority — §2 grounding, §4 allow-map, §5 guard, §7 TokenEntry.section, §9 C-deferred, §10 as-built + known limitation).
  • Scenarios: garzone-make-button-token-native, garzone-role-violation-clarifies, garzone-ambiguous-colour-asks, garzone-raw-hex-clarifies, garzone-insertnode-raw-hex-guard, garzone-listtokens-live-roles, garzone-spacing-live-scale. Ledger rows C62–C68 (requirements/CAPABILITIES.md).
  • Precedent: component-builder Library tab (commit b1bbbe7c, packages/cb-punta/src/inputs/color-tokens-seam.ts) — id-form colour vars grouped by section.