Render engine contract
Once /ack-init has written and validated project.manifest.yaml, the render engine
turns the manifest plus the templates/archetypes/<archetype>/ tree into a working child
project. This page is the conceptual contract; the authoritative source is
docs/RENDER-ENGINE.md.
_when.* path guards AND render.map.yaml globs), SUBSTITUTE ${VAR} from managed: (scalars only), then MERGE idempotently (managed blocks + the rendered_files[] ledger) — emitting the child working tree plus a managed.rendered_files[] ownership ledger. An unbound ${var} not in managed: raises RenderError and is FAIL-CLOSED: a hard error that writes nothing, never a blank.
The design keeps three responsibilities strictly separate:
| Layer | Responsibility | Where the logic lives |
|---|---|---|
| 1. Substitution | replace ${VAR} from the manifest | a tiny renderer regex (§ below) |
| 2. Conditional inclusion | decide which files render | the manifest + _when.* dirs + render.map.yaml — never in-template if logic |
| 3. Idempotency | never clobber hand-edits on re-run | the managed:/user: split + rendered_files[] ledger + managed blocks |
Conditionals are out of templates by construction: a template file is either included or not, decided by the manifest. There are no loops, filters, or inheritance inside a template, so the engine stays a pure string substitution and is trivially auditable.
Substitution: ${VAR} from the manifest
- Placeholder form:
${dotted.path}referencing a key undermanaged:— e.g.${project.name},${persistence.db},${contract_gate.mode}. .tplsuffix stripping: template files carry a.tpl.<ext>suffix. The renderer strips.tplon output, soCLAUDE.md.tplbecomesCLAUDE.mdandsettings.json.tplbecomes.claude/settings.json. A file without.tplis copied byte-for-byte with no substitution scan, so literal${...}in shipped scripts survives.
The core resolver is about a dozen lines of python3:
VAR = re.compile(r"\$\{([a-z0-9_]+(?:\.[a-z0-9_]+)*)\}")
def render(text, managed, user):
def sub(m):
path = m.group(1)
val = lookup(path, managed, user) # dotted-path walk
if val is MISSING:
raise RenderError(f"unbound {path} (not in managed:)") # FAIL-CLOSED
if isinstance(val, (dict, list)):
raise RenderError(f"{path} resolves to a container, not a scalar")
return scalar_to_str(val) # bool -> "true"/"false", num as-is
return VAR.sub(sub, text)The rules that matter:
- Unbound variable is a hard error, never a silent empty string — the render-time
mirror of
additionalProperties: false(invariant I5). A typo’d${...}fails the render rather than emitting blank text. - Scalars only. A
${...}must resolve to a string, number, or bool. To inject a list (e.g.protected_paths) the template uses a render directive (below), not a raw${...}. - No re-scan. Substituted output is not re-scanned, so manifest data can never be re-interpreted as a placeholder.
- JSON-safe variant. For
*.json.tplfiles the renderer substitutes, thenjson.loadsandjson.dumpswith sorted keys and 2-space indent, guaranteeing valid, deterministic JSON regardless of manifest key order.
Lists and inline blocks: two render directives
Two explicit, line-oriented directives cover the only non-scalar cases. They are not a templating language — each is a single line the renderer recognizes:
| Directive | Expands to |
|---|---|
#ack:each <list.path> as "<fmt-with-$item>" | one rendered line per list element, with $item bound to the element |
#ack:if <bool.path> … #ack:endif | the enclosed lines, only if the bool is truthy |
#ack:if is a convenience within an already-included file; it does not replace
file-level conditional inclusion, which stays the primary mechanism.
Why ${CLAUDE_PROJECT_DIR} survives
The substitution regex matches lower-case dotted paths only. The shell variable
${CLAUDE_PROJECT_DIR} is upper-case, so it is never matched and passes through
verbatim. That single design choice is what lets a manifest ${...} and a shell
${CLAUDE_PROJECT_DIR} coexist in the same file — and it is why rendered child hooks can
reference python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/contract-gate portably
(forkability, invariant I7).
Conditional inclusion, decided by the manifest
Which files render is decided by the manifest, expressed two ways that AND together.
1. Path-segment guards (_when.<bool.path>/) — evaluated first
A directory segment of the form _when.<bool.path>/ in a template’s output path is an
inclusion guard. If the boolean it names is truthy, the segment is stripped from the
output path; if falsy, the file is omitted (short-circuit). The canonical example is
the fullstack design-system tree:
templates/archetypes/fullstack/_when.design_system.install/design-system/...That whole subtree renders only when design_system.install is true, and the
_when.design_system.install/ segment disappears from the output path. The same
mechanism gates persistence scaffolding under
_when.persistence.enabled/ and nested _when.persistence.migrations.enabled/.
2. render.map.yaml glob guards
The kit-shipped templates/archetypes/render.map.yaml is the glob-guard half, applied to
the post-strip output path for cross-cutting files not naturally expressed as a path
segment. The v1 rules:
version: 1
rules:
- glob: "**/.mcp.json.tpl"
archetype: "*"
when: features.mcp # MCP wiring only when the child opts in
- glob: "**/.claude/hooks/contract-gate"
archetype: "*"
when: features.sdd_gate # master switch; false => omit the gate hook entirely
- glob: "**/design-system/**"
archetype: "*"
when: design_system.install
requires_archetype: [fullstack, saas] # ASSERTION (list since v3), not a selectorThe combination rules (mirrored from RENDER-ENGINE.md):
- Path-segment guards run first. A false segment short-circuits to omit before the map is even consulted.
- The map then applies by glob against the post-strip path. A file guarded by both a
path segment and a map
whenis included only if both are truthy (logical AND). requires_archetypeis an assertion, not a selector. If a rule’s glob matches and itswhenis truthy butmanaged.archetypeis not amongrequires_archetype(a list since v3, e.g.[fullstack, saas]), the render aborts loudly (an authoring bug). It never silently omits.- An absent
managed:key referenced by anywhenevaluates false → omit. This is how design-system renders only for fullstack and saas falls out for free: the schema forbidsdesign_systemunderbackend-api, so the lookup is absent → false → omitted, with no special-casing.
Some v3 subtrees need no glob rule at all because a path-segment guard fully expresses
them. The IaC scaffold under templates/archetypes/{fullstack,saas}/_when.features.iac/
is gated by _when.features.iac/ for the whole infra/ tree, then provider-split by
_when.iac.is_aws/ vs _when.iac.is_gcp/ (the derived booleans, exactly one of which
is true). It is deliberately left out of render.map.yaml: a pure path-segment guard is
both the inclusion control and its own documentation, so adding a redundant glob would
only create a second place to keep in sync.
Idempotent re-renders
Idempotency is structural (invariant I2) — there is no 3-way merge engine. Three mechanisms cooperate.
The ownership ledger (managed.rendered_files[])
After a successful render, the renderer writes back the list of paths it owns. On re-run
this ledger is authoritative: anything not in rendered_files[] is user territory and
is never touched. A path that was rendered but whose when is now false is left in
place and flagged — the renderer deletes nothing it cannot prove it solely owns.
rendered_files:
- path: .claude/settings.json
managed_block: "json:hooks,env" # JSON key-set ownership
- path: CLAUDE.md
managed_block: "ack:managed" # text file: a delimited comment region
- path: .claude/hooks/contract-gate
managed_block: null # whole file is ack-ownedManaged blocks — never clobber a foreign file
For files shared with the human, the renderer owns only a bounded slice:
- Text files (
CLAUDE.md): a delimited comment region betweenack:managedmarkers. Re-render rewrites only the bytes between the markers; content outside is preserved verbatim. If the file exists without markers on first run, the renderer appends the managed block at the end and never reorders human content. - JSON files (
settings.json,.mcp.json): there are no comments to delimit, so ownership is by key set. Forsettings.json, ack ownshooks(only whenfeatures.sdd_gateis true) and, withinenv, only the single keyCLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS(whenfeatures.agent_teamsis true). Every other key —permissions, userenvkeys, user-added MCP servers — is human-owned and never written.
Whole-file ownership and the no-op fast path
Files the renderer fully owns (managed_block: null, e.g. the contract-gate hook
script) are overwritten wholesale on re-run; they are not meant to be hand-edited.
Removing the path from rendered_files[] tells the renderer to stop managing it.
The fast path: /ack-init computes manifest_hash over the canonical managed: subtree.
On re-run, if the recomputed hash equals the stored one and every
rendered_files[].path is unchanged on disk, the result is nothing to do — exit 0,
zero writes.
Determinism and path hygiene guarantees
- Determinism. Identical
managed:plus identical templates produce identical bytes. The renderer emits no timestamps of its own into child files; the only clock value,generator.rendered_at, lives in the manifest and is outsidemanifest_hash. - Render only from
templates/. The META.claude/tree is never copied — meta-only agents, the orchestrator, and the telemetry MCP can never leak into a fork. - Output-path assertion (fail-closed). Before writing any child file, the renderer
asserts the content contains no
templates/archetypes/substring and no absolute kit path. A violation aborts the render.
Why not copier or cookiecutter
Both copier and cookiecutter impose a hard Python-runtime-plus-dependencies precondition
(jinja2, pyyaml, and friends) on every child fork. The kit ships into JS/Claude-Code
projects via fork; a Python-toolchain precondition would kill the no-Python distribution
story. The one feature copier gives for free — re-render against a versioned answers file
— is unnecessary here, because idempotency is structural (the managed:/user:
split plus an ownership ledger), not a 3-way merge. The result is a renderer with zero
npm/pip dependencies that is auditable by inspection.
See also: Interview → Manifest → Render (the producer that writes the
managed:subtree this engine consumes) · The META vs CHILD boundary · the authoritative source,docs/RENDER-ENGINE.md.