Appearance
ADR-0003 · Template system scalability
STALE — read current architecture state first (2026-08-08)
Manifest templates stand. Two additions: the manifest gains a first-party offer block (ADR-0025), and ⚠ a campaign is the first render-time temporal gate — this ADR's seasonal windows gate selection only, which is why campaigns need scheduled cache purge rather than a rendering-time check.
Status: 🟢 Accepted, amended 2026-08-04 · Depends on: — · Drives: ADR-0004, ADR-0019
Amendment — 2026-08-04: accepted, and Option C is cut down to A′ — the custom HTML block is REMOVED, not deferred
The Setu Card + Catalog foundation design (2026-08-04) is the first real build against this ADR, and two things found while building it change the recommendation below from C to A′ (C minus the custom block). The original analysis is kept as-written beneath this notice because the reasoning is still correct; only the conclusion it points to has moved.
- The live
templatestable is a direct, verified instance of Option B — the option this ADR's own analysis already rejected.html_structure text NOT NULL,css_styles text NOT NULL,javascript_code text— the exact "arbitrary string interpolation is the mechanism, not an escape hatch" shape Option B warns against, and it cannot even hold a JSON manifest (the HTML columns areNOT NULL). It has zero references anywhere in the tree: 5 placeholder stub rows, 0template_selections, 0 profiles pointing at one. Free to replace with no migration cost — dropped outright in the cleanup pass (QRS-341), not migrated. - Option C's
customsanitized-HTML escape hatch has no user in R1, and keeping it contradicts this ADR's own security argument. There is no admin template-authoring UI — templates are repo-authored, reviewed, CI-gated manifest files — and no untrusted content author of any kind: Claude Design generates a manifest + palette (JSON data), never markup, specifically so there is no rendering logic to review. "Never markup" and "markup once, sanitized" are in direct tension — the second is exactly the surface the first exists to close. With nobody to author custom HTML, the escape hatch is pure attack surface with zero offsetting value. Cut, not deferred — reintroducing it later is a new ADR amendment with a named author and a sanitization review, not a flag flip.
Revised recommendation: A′ — JSON-schema blocks only, closed vocabulary, no HTML escape hatch of any kind. Everything else in the original recommendation (a TemplateDocument block-array schema, save-time + render-time validation, the sponsored/promo_slot block type) stands. Storage also moves from "a DB-authored row" to repo-authored manifest files, CI-validated on every commit (check:templates); a card_templates table still exists but holds only registry metadata (composite (template_key, version) PK, status, feature_code, archetype_keys, lifecycle timestamps) seeded from the manifest files, never authored independently of them. Moving manifest content into a DB-authored row later (if a builder UI ever ships) is a storage change, because the TemplateDocument schema and its validation gates are unchanged either way — not a redesign, and not blocked by this decision.
One-renderer decision, versioning locks, the switching invariant (T12), and the preview architecture are decided in ADR-0019, not here. This ADR owns only the block model and the html-vs-JSON storage question; ADR-0019 owns everything about how a manifest becomes a rendered, versioned, switchable card.
Context
Template-first is a locked product decision: core, but greenfield — templates/template_selections exist in the baseline schema but have zero references in src/; the live app is 100% dedicated per-feature UIs. That prior decision named this "the #1 architecture initiative to sequence before it drives feature design."
The prototype's Templates.dc.html (thoroughly reviewed across two revalidation passes, see Templates review) is a genuinely strong reference for what the real engine needs: a real plan-aware entitlement model (PLANRANK/TPLMETA), a real lifecycle state machine (draft → review → scheduled → published → paused → archived → deprecated with a version log), and — as of the fix just revalidated — a real widget-type security model: five named, schema-typed widget types (widget-calculator, widget-booking, widget-map, widget-leadform, widget-payment) plus one sanitized Custom HTML escape hatch gated behind an independent pending_security_review state, separate from the template's own lifecycle.
Two real gaps remain, unaffected by today's revalidation:
- No Claude Designs → real-template import/export contract (Templates Finding 3) — a screen becomes a real template today only by hand-rebuilding it; there's no JSON-schema export/import path.
- The block library has no ad/sponsor-slot concept (surfaced during the AdManager review) — AdManager defines a
servicecard.footerad placement with no template-side block type that could ever render it.
This ADR is the actual "what does adopting template-first mean, mechanically" decision the prior product-vision memory flagged as needed before templates drive feature design further.
Options considered
A — Server-rendered JSON-schema block model
Templates stored as an ordered JSON block array ({type, props, plan}[]). A single BlockRenderer React component maps type → component. Entitlement and security are enforced twice: at save-time (an Edge Function validates the prop schema against the block type + plan) and at render-time (a component tree, never raw HTML string interpolation). WIDGETSCHEMA's existing shape (five named widget types with typed props) generalizes directly into this model.
- For: the security boundary is structural — a
BlockRenderercan only ever render known component types with validated props, so there's no string-interpolation surface to sanitize in the first place, for every block except the one deliberate Custom HTML escape hatch. - Against: more up-front schema design work than a string-template approach.
B — Handlebars/string-template engine
Templates as Handlebars (or similar) strings with registered helper functions — the approach the greenfield product-vision memory named as a "design-toward target."
- For: simpler to stand up initially; very flexible authoring.
- Against: reopens exactly the Custom-HTML-sanitization problem at the engine's core — arbitrary string interpolation is the mechanism, not an escape hatch — instead of solving it once, in one contained block type, the way the just-revalidated fix already does.
C — Hybrid: JSON-schema blocks (A) + one sanitized Custom HTML escape hatch
Most blocks are JSON-schema/component-based per A. Exactly one block type (custom) allows raw HTML, sanitized at write-time with the disclosed policy the prototype now already implements (scripts, event-handler attributes, iframe, object/embed, and external stylesheets stripped before render), gated behind the independent security-review state machine already designed.
- For: this is already what the prototype converged on, through two rounds of revalidation — a named widget-type system plus one deliberately-contained, sanitized escape hatch. Adopting C is formalizing what's already been designed and verified, not inventing a new approach.
- Against: none identified beyond A's up-front schema cost.
Recommendation
C. This isn't a new architectural bet — it's naming and generalizing the pattern the Templates screen already arrived at across its security fix and this ADR's own review process. The real engine should define a portable TemplateDocument schema (ordered blocks, each {type, props, plan}, matching WIDGETSCHEMA's shape) consumed by both the real render layer and the save-time validation Edge Function.
Closing the import/export gap (Finding 3)
Define the TemplateDocument JSON schema first — before building any "Import from Claude Designs" UI. Once it exists, a Claude Designs prompt can ask the design tool to emit that JSON schema directly for a finished screen, rather than the real app trying to parse arbitrary .dc.html markup and inline JS (renderVals(), data-props, tsType hints) after the fact — asking the source system to emit a known target schema is far more robust than reverse-engineering a design-tool-specific format.
Closing the ad-slot gap (surfaced post-AdManager review)
Add a type:'sponsored' block to BLOCKLIB/WIDGETSCHEMA itself — a first-party, schema-typed slot ({slotId, fallback}) that calls the same getPromo(slotId, workspace) contract AdManager/manifest.js already use elsewhere (e.g. mobile-console/Home.dc.html's getPromo('home.banner', ws)). This turns "servicecard.footer is a defined slot with no consumer" into "any template can declare a sponsored block, and the Service Card template does" — templates and the ad system become one connected model instead of two.
Consequences
- The real Edge Function that saves a template must validate the full block array against
TemplateDocument(per-block prop schema + plan gate) — this is the "primary write-enforcement layer" the data-access rule already requires, applied to templates specifically. - ADR-0004 depends on the
sponsoredblock type existing, so any template (not just AdManager's currently-fixed slot list) can carry a sponsored placement. - Templates Finding 2 (the "AI-assisted" creation stub) and Finding 4 (this same ad-slot gap, now addressed here) are otherwise unaffected by this ADR.
Open question for product
Is a template marketplace (users buying/selling templates — raised in the original Claude Designs open questions) in scope for this same phase, or fully roadmap? This affects whether entitlement needs a "purchased" tier in addition to plan-rank, which changes TemplateDocument's save-time validation rules. Unaffected by the 2026-08-04 amendment — still fully open, still fully roadmap.
Amendment consequences (2026-08-04)
- The
customblock type is never implemented. No sanitization policy, nopending_security_reviewstate machine for it — that entire surface in the original Option C analysis is dropped along with the block. TemplateDocumentgains a closed-vocabulary constraint enforced in CI, not only at save-time:check:templates(ADR-0019) validates every manifest file on every commit against the same schema the (still-future) save-time Edge Function would use, because in R1 there is no save-time path at all — manifests are files, not writes.- The live
templates/template_selections/setu_blockstables andprofiles.template_idare dropped, not migrated, in the QRS-341 cleanup migrations — verified zero live references (see Setu Card design finding 1). - ADR-0004 is amended to match: the
sponsored/promo_slotblock type ships as a schema line with an inert resolver (resolvePromo()always returnsnull), not a working ad path — see that ADR's own 2026-08-04 amendment for why.
Related
- Tracker: "Templates block library has no ad-slot concept...", QRS-341 (cleanup), QRS-344 (template framework contracts), QRS-346 (
check:templatesgate) - ADR-0019 — rendering, preview, versioning and lossless switching for the manifest this ADR defines the shape of.
- Templates review (Findings 1, 3, 4 and both revalidation passes)
- Product-vision decision #5 (template-first: core but greenfield)