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); avar(--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
ToolPortwrite-back viasetTokenValue) 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
- 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.
- 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.
- 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. - 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 (
§8No auto-snap). 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
requirements/garzone-token-grounding.md(P6 authority — §2 grounding, §4 allow-map, §5 guard, §7TokenEntry.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.