Status: Active Release Baseline Product:
sc-composer(library),sc-compose(CLI),sc-sha(hash library), and thebindings/python,bindings/sc-sha-python, andbindings/sc-sha-goadapters Document role: Normative release architecture for all in-repo packages
This document supersedes the prior high-level placeholder. It is the normative
release architecture baseline for sc-compose v1.4.1.
This document defines the required architecture of sc-composer,
sc-compose, sc-sha, and the Python and Go adapters for release work. It is not
a description of the current
implementation.
The goals are:
- one implementation of prompt and template composition semantics,
- deterministic outputs and diagnostics,
- runtime-agnostic library behavior,
- a thin CLI over library APIs,
- clear separation between reusable core logic and integration-specific edges.
outside this repo
+-----------------------------------------------+
| ATM adapter / other host integration |
| - builds ComposeRequest |
| - calls render_template() or Renderer |
| - may inject an observer implementation |
+-------------------------+---------------------+
|
v
+-------------------------------+
| sc-compose |
| CLI / UX / logger wiring |
+-----------+-------------------+
| uses concrete logger
v
+-------------------------------+
| sc-observability |
| Logger + file/console sinks |
+-------------------------------+
^
| injects CLI-owned observer adapter
|
+-----------+-------------------+
| sc-composer |
| core composition API + |
| local observer hook layer |
+-----------+-------------------+
ATM-specific integration attaches above the two-crate boundary. sc-composer
never imports ATM types, defines its observer hooks locally, and receives
concrete logging behavior through trait injection rather than a direct
dependency on sc-observability.
sc-composer is the core library crate. It owns:
- template parsing,
- frontmatter parsing and normalization,
- include expansion,
- variable discovery and validation,
- resolver policy evaluation,
- rendering,
- composition pipeline assembly,
- diagnostics production,
- reusable workspace helpers for initialization tasks.
sc-compose is the CLI binary crate. It owns:
- argument parsing,
- command dispatch,
- output formatting,
- exit codes,
- file-writing UX,
- CLI-facing observability wiring,
- bundled example-pack discovery,
- user template-pack discovery and storage,
- pack metadata parsing,
- templates add workflows,
- bundled CLI feature manuals (
help_topicsmodule: one ordered(topic_name, content)registry, each entry's content embedded from a singlecrates/sc-compose/docs/manual/<topic>.mdfile viainclude_str!; no per-topic Rust modules or hand-written string constants; manual sources live inside the crate directory, not under top-leveldocs/, becausecargo package's isolated verify build cannot see files outside the package root). - exclusive ownership of the
help_topicsmodule and ordered manual-topic registry;sc-composerandbindings/pythondo not define, import, or mutate manual-topic metadata.
bindings/python is the Python-facing adapter package. It owns:
- PyO3 wrapper classes and functions,
- maturin packaging metadata,
- Python type stubs and
py.typedmarkers, - Python wheel smoke tests,
- Python-facing request/result shims over
sc-composer.
It must remain an adapter layer only. It does not own:
- CLI argument parsing,
- observability or logger wiring,
- report runtime helpers,
- ATM-specific integration,
- any semantic reimplementation of composition or validation behavior.
sc-sha is the standalone pure-computation crate for content and composition
identity. It owns the two published hash operations and their input
validation/encoding contracts; it has no filesystem, template, CLI, or ATM
behavior.
bindings/sc-sha-go is the generated UniFFI Go adapter for the two public
sc-sha operations. It owns Go-facing generated types, CGo/native artifact
selection, and Go consumer packaging. It depends only on sc-sha; it does not
depend on sc-composer, sc-compose, the Python adapter, or ATM/runtime code.
The adapter follows ADR-0020: Generated Go Binding Strategy,
which is Accepted. Consumers download and extract the target-specific
release bundle before building; go get alone does not provide the native
library.
sc-composer-beads is the host-neutral Beads formula-composition library. It
owns the versioned request/receipt contract, deterministic render-to-bd
stage ordering, authorization checks, and direct executable invocation. It
depends on sc-composer, workspace serde/error support, and the approved
process-wrap platform-containment dependency. Beads formula parsing,
validation, runtime-variable semantics, and persistent state remain owned by
the bd executable.
It must not depend on sc-compose, a Python or Go adapter, Beads source or a
Beads database library, ATM/runtime code, or any CLI argument type. The CLI
and foreign-language bindings are callers of this library, never dependencies.
bindings/sc-composer-beads-python is a thin Maturin/PyO3 adapter over
sc-composer-beads. It owns only Python conversion, packaging, and the
versioned request/receipt presentation surface. It must not reimplement Beads
formula validation or process execution, and must not depend on sc-compose,
sc-composer, a Beads source/database library, ATM/runtime code, or another
adapter.
Required dependency direction:
sc-compose->sc-composersc-compose->sc-composer-beadssc-compose->sc-observabilitysc-composer->sc-shasc-composer-beads->sc-composerbindings/python->sc-composerbindings/sc-composer-beads-python->sc-composer-beadsbindings/sc-sha-python->sc-shabindings/sc-sha-go->sc-shasc-observability->sc-observability-types
Required observability split:
sc-observabilityis the concrete logging integration target for the CLI.sc-composerkeeps its observer interfaces local.
Forbidden dependency direction:
sc-composer->sc-composesc-composer-beads->sc-composesc-composer-beads-> foreign-language adapterssc-composer-beads-> Beads source/database librariessc-composer-beads-> orchestration-specific runtime cratessc-composer->bindings/pythonsc-composer->sc-observabilitybindings/python->sc-composebindings/python->sc-observabilitybindings/python-> orchestration-specific runtime cratessc-composer-> orchestration-specific runtime cratessc-composer-> mailbox helpers, daemon helpers, team-state helpers, or runtime-specific home-resolution helpers
ATM integration is an adapter concern outside this repository.
- An ATM adapter depends on
sc-composerorsc-compose; this repository does not depend on ATM crates. - The adapter constructs
ComposeRequestvalues and calls eitherrender_template()for one-shot usage or theRendererAPI for repeated rendering. - If ATM needs telemetry, the adapter or CLI injects a sink implementation through the library's trait-based observability hooks.
sc-composernever imports ATM types, mailbox abstractions, spool paths, or runtime-management helpers.
sc-composer should be organized around these modules:
frontmatter- parses YAML frontmatter,
- normalizes omitted fields to schema defaults,
- exposes typed frontmatter structures.
types- defines shared composition data structures such as pass configuration and verify result types,
- centralizes multi-pass request/result shapes that are reused across parser, validation, render, and verify code paths.
resolver- resolves explicit file paths and profile-mode prompt lookup,
- records search traces,
- applies resolver policy.
include- expands
@<path>directives, - enforces path confinement,
- tracks include stack,
- detects cycles and depth overflow.
- expands
validation(context merge and token discovery implemented here, not as separatecontext.rs/tokens.rsfiles)- merges explicit variables, environment variables, and defaults in precedence order (explicit > env > frontmatter defaults),
- tracks variable origin,
- applies unknown-variable policy,
- exposes
discover_tokens(text) -> BTreeSet<VariableName>for standalone token discovery workflows, - distinguishes declared, undeclared, missing, and extra variables.
render- configures the template engine,
- exposes the long-lived
Renderersession type as the primary API for repeated rendering, - keeps
render_template()as a one-shot convenience wrapper, - renders template content under normal or strict undeclared-token policy.
template_ext- exposes shared, case-insensitive template-path classification that removes
stacked
.j2/.jinja2/.jinjasuffixes before determining the content extension; it keeps renderer auto-escape, checked-render validation, and CLI JSON detection in agreement.
- exposes shared, case-insensitive template-path classification that removes
stacked
template_scanner- exposes the shared lexical Jinja variable-expression scanner used by
library JSON diagnostics and the
sc-composetemplate-lint command.
- exposes the shared lexical Jinja variable-expression scanner used by
library JSON diagnostics and the
directive_inspection- validates raw UTF-8 template bytes with MiniJinja,
- exposes
inspect_template_directivesand the purpose-builtTemplateDirective/SourceSpan/TemplateDirectiveKindtypes, - classifies include, import, and from-import statements without exposing parser or AST types and without resolving their targets.
validate- produces validation reports and diagnostics without writing output.
verify- renders templates through all configured passes and compares the result against deployed content,
- returns structured drift-check results for both library callers and the CLI wrapper.
error- defines crate-owned error types and shared recovery-hint structures,
- maps lower-level failures into stable public categories.
diagnostics- defines diagnostic types,
- defines the JSON diagnostic schema contract.
workspace- implements
frontmatter-initandinitlogic for reuse by the CLI and any future embedded callers.
- implements
observer- defines the local observer and sink traits used by embedded hosts and the CLI,
- owns the no-op observer used when no caller injects a concrete implementation,
- emits structured composition-stage events,
- never binds directly to
sc-observability.
Resolver policy must be data-driven and not embedded in CLI-only conditionals.
The policy model must express:
- runtime name,
- profile kind,
- ordered candidate directories,
- ordered filename probes,
- ambiguity rules when runtime is omitted.
.claude/agents/.claude/commands/.claude/skills/.hermes/agents/.hermes/commands/.hermes/skills/.codex/agents/.codex/commands/.codex/skills/.gemini/agents/.gemini/commands/.gemini/skills/.opencode/agents/.opencode/commands/.opencode/skills/
.agents/agents/.agents/commands/.agents/skills/
There is no flat shared fallback such as .agents/<name>.
For agent and command prompts, candidate probe order within a directory is:
<name>.md.j2<name>.md<name>.j2
For skills, candidate probe order within a directory is:
<name>/SKILL.md.j2<name>/SKILL.md<name>/SKILL.j2
- If a runtime is explicitly provided, only that runtime chain is evaluated.
- If a runtime is omitted, all runtime and shared roots are evaluated.
- If multiple candidates match, resolution fails with an ambiguity diagnostic.
- If exactly one candidate matches, it may be selected without an explicit runtime.
Frontmatter is a first-class typed structure.
Target frontmatter shape:
Frontmatter {
required_variables: Vec<String>,
defaults: Map<String, InputValue>,
metadata: Map<String, MetadataValue>,
}
Normalization rules:
- If frontmatter exists but omits
required_variables, normalize to[]. - If frontmatter exists but omits
defaults, normalize to{}. - If frontmatter uses
input_defaults, normalize it intodefaults. - If frontmatter exists but omits
metadata, normalize to{}. - If no frontmatter exists, the document has no declarations and no defaults.
- If both
defaultsandinput_defaultsappear, merge both maps, letinput_defaultsoverride overlapping keys, and emitWARN_VAL_CONFLICTING_DEFAULT_SECTIONS.
Semantic rules:
required_variablesdeclares variables that must exist after context merge.defaultssupplies optional values that may satisfy a required variable.metadatais descriptive only and does not affect render semantics in the initial design.- An empty sequence is a valid
InputValueand may satisfy a required variable. - When a referenced or required variable is satisfied by a default instead of
explicit caller input, validation emits
INFO_VAL_DEFAULT_USED.
The historical H2 nested-array restriction is superseded by Sprint E.1. The
shipped contract accepts finite JSON/YAML-compatible arrays and objects at any
depth because InputValue already uses serde_json::Value and Minijinja can
traverse those values. The top-level var-file object boundary, YAML
string-key rule, and string-only --var interface remain unchanged.
Sections 15 and 18 link here for the template-pack and diagnostic implications; they intentionally do not repeat this decision record.
InputValue in H1/H2/E.1 means one of:
- string
- number
- boolean
- null
- object/map with string keys
- finite recursive sequences and objects containing any supported value
Rust type contract:
InputValueis represented asserde_json::Value,- object values with string keys may cross the CLI-to-library boundary,
- nested sequences are accepted at any depth,
- arrays of objects and jagged arrays are supported in E.1,
- object trees may contain scalar leaves, nested objects, and recursive arrays.
Sequence values are recursively validated in E.1:
- sequence members may contain scalars, objects, arrays, or null,
- nested sequences may be jagged and may occur inside object fields,
- object-valued sequence members are supported at every variable path.
Historical H2 boundary and E.1 decision:
- The dated E.1 architecture decision record supersedes the former H2 shape
restriction. The implementation follows the existing
serde_json::Valuerepresentation and Minijinja traversal capability rather than introducing a second recursive value model. - The var-file document remains a top-level JSON/YAML object, YAML map keys
remain string-only, and
--var key=valueremains string-only.
MetadataValue may be any YAML value:
- scalar
- sequence
- mapping
Supporting public newtypes:
VariableName- validated variable identifier used by
required_variables,defaults, diagnostics, and variable-source maps, - prevents accidental use of arbitrary strings in the public API.
- validated variable identifier used by
IncludeDepth- non-negative bounded include-depth value used by include policy and errors.
ConfiningRoot- canonicalized root path newtype used by path-confinement checks and configuration validation.
The architecture must distinguish these cases:
- declared required variable,
- declared optional variable with a default,
- undeclared referenced token,
- extra provided input variable.
In default mode:
- undeclared referenced tokens are preserved in rendered output,
- undeclared referenced tokens produce diagnostics,
- undeclared referenced tokens are not implicitly promoted to required variables.
In strict mode:
- undeclared referenced tokens are fatal during validation,
- undeclared referenced tokens are fatal during rendering.
Missing required variables remain a separate diagnostic class:
- they fail validation and rendering,
- they are reported with file, line and column when available, and include chain.
Built-in render-context variables are injected after caller-provided inputs
(explicit --var and --env-prefix) are merged but before template-owned
defaults take effect. Caller-provided values always win over built-ins.
The built-in set is:
TEMPLATE_NAMEHOSTNAMEUSERNAMERENDER_DATERENDER_TIMESTAMP
Merge order is therefore:
- explicit input variables,
- environment-derived variables,
- built-in render-context variables,
- user-template
input_defaults, - frontmatter defaults.
The library API should expose explicit request and result types.
Required library surface:
resolve_profile(request) -> Result<ResolveResult, ComposeError>compose(request) -> Result<ComposeResult, ComposeError>validate(request) -> Result<ValidationReport, ComposeError>init_workspace(root, options) -> InitResultfrontmatter_init(path, options) -> FrontmatterInitResultRenderer::render(compiled, context) -> Result<String, RenderError>as the primary repeated-render APIRenderer::with_delimiters(open, close) -> Result<Self, RenderError>as the only public renderer-customization seamrender_loaded_template(request) -> Result<RenderedArtifact, RenderError>as the runtime-agnostic entry point for callers that already loaded template text outsidesc-composer
Primary render-entrypoint decision:
Rendereris the primary long-lived rendering API because it can retain a pre-builtminijinja::Environmentacross multiple render operations.render_template()remains a stable convenience API for one-shot rendering and simple callers.- Callers rendering the same template or environment repeatedly should use
Rendereronce implemented rather than paying per-call environment setup and AST re-parse cost.
The rendering and composition surfaces have distinct responsibilities.
| Surface | Owns | Does not own |
|---|---|---|
Renderer |
reusable template-engine environment setup plus inline/named rendering over caller-supplied template text and context, including delimiter customization through with_delimiters(open, close) -> Result<Self, RenderError> |
profile resolution, include expansion, variable validation, block assembly, repository bootstrap, arbitrary third-party engine configuration |
compose() |
top-level composition orchestration: resolve, include expansion, validation, built-in context injection, render, and block assembly | direct CLI UX decisions |
render_template() |
one-shot rendering entry point for callers that already have template text and context | profile resolution, repository scanning, include expansion, validation, workspace bootstrap |
validate() |
validation phase only; returns structured diagnostics without writing output | output generation or file writing |
frontmatter_init() |
frontmatter discovery and rewrite helper | template composition pipeline execution |
init_workspace() |
repository bootstrap helper | template composition pipeline execution |
ComposeRequest
runtime: Option<RuntimeKind>mode: ComposeModeroot: ConfiningRootvars_input: Map<VariableName, InputValue>vars_env: Map<VariableName, InputValue>vars_defaults: Map<VariableName, InputValue>guidance_block: Option<String>user_prompt: Option<String>policy: ComposePolicy
ComposeMode
Profile { kind: ProfileKind, name: String }File { template_path: PathBuf }
Semantics:
runtime = Noneis valid and enables the omit-runtime search behavior defined in the requirements.ComposeModeis variant-specific and must not be represented as a bag of unrelated optional fields.- In
Filemode,runtimemay beNoneand is ignored unless a caller wants to attach runtime context for logging or policy selection.
ComposePolicy
strict_undeclared_variables: boolunknown_variable_policy: UnknownVariablePolicyunbound_variable_policy: Option<UnknownVariablePolicy>; when omitted, referenced-but-unbound diagnostics inheritunknown_variable_policyfor compatibility, while an explicit value keeps the two policy axes independentmax_include_depth: IncludeDepthallowed_roots: Vec<ConfiningRoot>resolver_policy: ResolverPolicypasses: Vec<PassConfig>
PassConfig
pass_number: u8required_variables: Vec<VariableName>defaults: Map<VariableName, InputValue>metadata: Map<String, MetadataValue>
LoadedTemplateRequest
template_name: Stringtemplate_text: Stringcontext: BTreeMap<String, serde_json::Value>
RenderedArtifact
rendered: Stringtemplate_name: String
ParsedTemplate
passes: Vec<Frontmatter>body: String
Compatibility rule:
- the existing
frontmatter() -> Option<&Frontmatter>accessor remains the compatibility seam for current callers, - single-header templates preserve existing semantics,
- stacked templates may define
frontmatter()as the first (outermost) pass whilepassesexposes the full multi-pass structure.
ResolveResult
resolved_path: PathBufattempted_paths: Vec<PathBuf>ambiguity_candidates: Vec<PathBuf>
ComposeResult
rendered_text: Stringresolved_files: Vec<PathBuf>resolve_result: ResolveResultvariable_sources: Map<VariableName, VariableSource>warnings: Vec<Diagnostic>
ValidationReport
ok: boolwarnings: Vec<Diagnostic>errors: Vec<Diagnostic>resolve_result: ResolveResult
ComposeError
Resolve(ResolveError)Include(IncludeError)Validation(ValidationError)Render(RenderError)Config(ConfigError)
FrontmatterInitResult
target_path: PathBuffrontmatter_text: Stringdiscovered_variables: Vec<VariableName>changed: boolwould_change: bool
Template-init contract:
template-initconsumes an input file plus one or more pass-scoped variable maps from the CLI wrapper.- Replacement planning is CLI-owned in
sc-composeand sorts all pass-scoped literal values globally longest-first, with higher pass numbers breaking ties, so specific strings are reserved before substrings anywhere in the file. - Generated headers are emitted in outer-to-inner order and include
pass: Nonly when the output must remain genuinely multi-pass. - If the resulting template is effectively single-pass, the emitted header is
normalized back to the shipped
1.2.xsingle-header shape:required_variables,defaults: {},metadata: {}, and nopass: 1. template-initremains CLI-owned insc-compose;sc-composerowns only the reusable workspace/helper types needed to support the conversion.
InitResult
prompts_dir: PathBufgitignore_updated: boolscanned_templates: Vec<PathBuf>recommendations: Vec<Diagnostic>validation_passed: bool
Entrypoint contract:
compose(request) -> Result<ComposeResult, ComposeError>validate(request) -> Result<ValidationReport, ComposeError>resolve_profile(request) -> Result<ResolveResult, ComposeError>Diagnosticis not a failure type. Diagnostics describe warnings and user-actionable validation findings;ComposeErrordescribes operation failure.
The known-template reverse-extraction API is a pure sc-composer capability
defined by ADR-0011.
It accepts template and rendered text in memory and returns a generic
ExtractionReport containing string values, structural occurrence evidence,
report-level confidence, and typed diagnostics. The initial format adapter is
XML; the generic occurrence/path/source types are part of the public contract
so later XML matching can specialize them without creating a second report
model.
The API distinguishes invalid requests, malformed XML, unsupported syntax, and ambiguous structure with canonical diagnostic codes. A repeated variable at distinct structural occurrences is ambiguous and must not silently replace an entry in the recovered value map. File I/O, CLI parsing, unknown-template identification, loop or branch reconstruction, JSON/Markdown extraction, and typed-value inference remain outside this contract.
A dotted expression is object-field access, not a literal variable
identifier, and Phase G only supports the scalar (flat) variable subset.
Extraction parses each {{ expression }} into a VariableName for
occurrence tracking, but the shared VariableName grammar (also used by
composition/rendering token discovery) permissively accepts . as an
ordinary name character. The extraction call site must reject any parsed
variable containing . as unsupported syntax (ERR_EXTRACT_UNSUPPORTED)
before it reaches the report; Phase G.7 will add this check locally to the
extraction XML adapter without changing VariableName's shared grammar or
its use in composition/rendering.
The Python adapter exposes this in-memory report through
extract_variables(template, rendered, *, format="xml", include=None, exclude=None). XML remains the backward-compatible default, while the
approved JSON adapter selects the same shared extraction entry point with
format="json". Its report and provenance objects are wrappers over the
sc-composer values, and fatal extraction conditions use the adapter's
existing ScConfigError family with the canonical extraction code; Python
does not implement a second extraction algorithm. Here, “reuse the existing
exception hierarchy” means that all fatal extraction inputs use that
established ScConfigError class and expose the Rust diagnostic code,
recovery hints, and diagnostic detail; it does not introduce a Python-only
extraction exception subclass.
This capability is implemented from scratch in the production Rust library, Python adapter, and CLI. Prior reverse-extraction research informs the contract and its intentional boundaries, while the committed cross-surface corpus provides regression evidence. The research harness is not part of the product interface or a runtime dependency.
Phase-H planning records the three in-scope real-customer format candidates
from issue #193: JSON, YAML, and TOML adapters. XML mixed-content extraction
and a narrow non-XML preamble policy remain outside Phase H and are owned by
Phase I. ADR-0012
now records the accepted format-specific path/source contract and malformed-
input policy. Cross-surface evidence remains required before any adapter is
delivered. The generic report model may be extended, but the library/CLI/Python
ownership boundary and Phase-G fail-closed XML behavior remain unchanged while
H.2 through H.8 implement, harden, and validate the accepted extensions. H.8
is the phase-ending remediation gate and does not reopen H.7's settled QA
findings.
The stable cross-format diagnostic inventory is maintained in
docs/error-code-registry.md and is part of the H.1
contract rather than an implementation-time choice.
The format adapters do not own independent placeholder matchers. The accepted H.1 design defines the internal migration seam from the current XML value-matching path to a shared raw-text matching core. That core owns delimiter scanning, template-segment parsing, static-prefix/suffix matching, capture boundaries, and adjacent-variable ambiguity handling. Format adapters own structural parsing, occurrence paths, provenance, and format-specific diagnostics, then delegate candidate-value matching to the shared core. XML remains the first consumer of the extracted seam, followed by JSON, YAML, and TOML.
The shared core is also the architectural foundation for a future customer-facing best-effort/degraded-parse mode and a cross-format raw-text mode for arbitrary text such as Markdown. Those modes are not exposed or implemented in Phase H; the future mode must reuse this seam rather than require another matcher rewrite.
H.6 closure evidence is recorded in
docs/phase-H/evidence/h-6-cross-format-campaign.json
and its generated multi-worker report package under site/reports/. The
campaign proves equivalent JSON/YAML/TOML report semantics across library,
CLI, and Python surfaces, while preserving the explicit Phase-H boundary that
XML mixed-content and dirty-prefix handling belong to Phase I.
The H.6 execution record is bounded local evidence rather than a distributed
agent campaign; its report and summary must retain that caveat.
Phase I extends the generic extraction bridge without introducing a second report model or matcher. The accepted contract is defined by ADR-0013.
The Rust format selector adds ExtractFormat::Raw. The dispatching path and
source sums add Raw(RawPathSegment) and Raw(RawExtractionSource) variants,
where RawPathSegment stores zero-based half-open rendered byte offsets and
one-based line/column coordinates, and RawExtractionSource::TextSpan marks
the provenance. sc-compose maps --format raw; Python maps
format="raw"; both call sc_composer::extract and do not implement matching.
Raw mode is known-template, in-memory text matching for Markdown and other
unstructured text. It uses the H shared matcher, applies include/exclude
filters before report construction while retaining filtered variables for
neighboring capture matching, and uses only the stable raw diagnostic set
ERR_EXTRACT_INVALID_REQUEST, ERR_EXTRACT_TEMPLATE_UNSUPPORTED,
ERR_EXTRACT_AMBIGUOUS, and WARN_EXTRACT_LOW_CONFIDENCE.
XML's Phase-I structural extension allows one full element-content placeholder
to capture text plus approved child markup using deterministic canonical child
serialization. A separate rendered-only normalizer accepts a bounded leading
text/whitespace preamble before one XML document, preserves allowed prolog
constructs, and emits WARN_EXTRACT_DIRTY_PREFIX_STRIPPED when it removes
bytes. It rejects unmatched/truncated markup, malformed suffixes, multiple
roots, second documents, post-root content, and DTDs.
I.3 emits ERR_EXTRACT_XML_CHILD_STRUCTURE_MISMATCH when rendered child markup
falls outside the approved template structure, ERR_EXTRACT_XML_CONTROL_FLOW_UNSUPPORTED
when extraction would require unsupported control-flow reconstruction, and
ERR_EXTRACT_XML_DYNAMIC_ELEMENT_NAME for dynamic element names. These stable
codes keep XML structural rejection distinct from generic malformed or
unsupported extraction failures.
Validation token discovery recognizes the listed Jinja loop-context names only
inside active for scopes; loop outside a loop and arbitrary dotted names
remain ordinary validation inputs. Var-file decoding rejects YAML merge keys
with ERR_CONFIG_VARFILE and a source line/column before tagged-value
unwrapping, so inherited fields cannot disappear silently; callers recover by
writing the mapping explicitly. These changes are Phase-I runtime work and are
not retroactive claims about the completed Phase-H implementation.
The include graph is evaluated deterministically.
Merge behavior:
- required-variable declarations from included files participate in validation of the overall composition result,
- defaults from included files participate in context construction,
- parent-file defaults override defaults from included files,
- environment-derived variables override all defaults,
- explicit input variables override environment-derived values and defaults.
Metadata behavior:
- metadata from included files does not affect rendering,
- metadata may be retained in trace structures in a future API, but metadata is not part of current render semantics.
Diagnostics are structured records used by both the library and CLI.
Required fields:
codemessagepathlinecolumninclude_chainseverity
The JSON representation must be versioned. The version belongs to the schema contract, not to any single CLI command.
Top-level diagnostics envelope (payload fields are command-specific; "valid"
shown here matches the validate command — see §13.1 for per-command schemas):
{
"schema_version": "1",
"payload": {
"valid": false
},
"diagnostics": [
{
"severity": "error",
"code": "ERR_VAL_MISSING_REQUIRED",
"message": "missing required variable: name",
"path": "templates/example.md.j2",
"line": 12,
"column": 4,
"include_chain": []
}
]
}Minimal diagnostic record:
{
"severity": "info",
"code": "INFO_VAL_DEFAULT_USED",
"message": "variable name not provided, using default: \"world\"",
"location": "templates/example.md.j2"
}sc-composer must expose crate-owned canonical public error types.
Required error structs:
ResolveErrorIncludeErrorValidationErrorRenderErrorConfigError
Error requirements:
- every canonical error carries an underlying
source()cause chain when one exists, - include-related errors carry the include chain when applicable,
- configuration and validation failures may carry structured recovery hints,
- recovery hints must remain structured data rather than prose-only strings.
CLI boundary rule:
sc-composemay wrap library errors withanyhoworeyreat the command boundary,sc-composerpublic APIs must return the canonical error types defined in this document, notanyhow::Erroror third-party engine error types.
For compose and validate, the target lifecycle is:
- Resolve explicit path or profile path.
- Read the root template file.
- Parse frontmatter and body.
- For multi-pass templates, continue parsing only while the next bytes at
the current cursor begin another leading header. Later
---lines in the body remain literal content.
- For multi-pass templates, continue parsing only while the next bytes at
the current cursor begin another leading header. Later
- Expand includes while enforcing path and depth policy.
- Merge frontmatter declarations and include-derived declarations.
- Discover referenced variables from the expanded template graph.
- Merge context in precedence order:
- explicit input,
- environment,
- defaults.
- Apply validation policy:
- missing required variables,
- undeclared referenced tokens,
- extra provided variables.
- Render in normal or strict mode according to policy.
- When
policy.passesor parsed stacked headers indicate nested-template rendering, render outer-to-inner, using pass-specific delimiters andprotect_higher_braces-style higher-brace protection between passes.
- When
- Assemble final output blocks.
- Return composed output or validation report with diagnostics and trace data.
Internal lifecycle encoding:
- the composition pipeline must preserve the documented ordering of resolve, parse, include expansion, validation, render, and output assembly,
- internal helpers may use staged data structures to make ordering violations difficult to represent,
- the initial release does not expose a public typestate API or a public
pipelinemodule.
sc-compose should be a command router over library operations.
Command mapping:
render->composeresolve->resolve_profilevalidate->validatebead->execute_bead_requestfrontmatter-init->frontmatter_inittemplate-init-> CLI-ownedtemplate_init_filerewrite pathinit->init_workspaceverify->verifyextract->extractobservability-health-> CLI logger initialization, thenLogger::health()examples list-> list bundled example packsexamples <name>-> resolve the bundled example-pack file, merge packinput_defaults, thencomposetemplates list-> list user template packstemplates add-> copy a source file or directory into the user template root as one packtemplates <name>-> resolve the user pack entry template, merge packinput_defaults, thencomposereports init-> initialize the shared report scaffold and starter catalogreports smoke-> run the shared smoke fixture render path and emit the smoke latest-artifact setreports finalize-> materialize one producer-owned report artifact set into the shared sidecar and archive shapereports render-spec-> parse one TOML semantic spec and emit one Mermaid latest-artifact setreports index-> aggregate and summarize latest report entrypoints from the report catalogreports verify-> verify required report artifacts exist for the catalogreports publish-manifest-> emit one machine-readable publish handoff from current latest report outputshelp [topic]/help --list-> CLI-ownedhelp_topicsregistry lookup (no library call; see FR-22)
The CLI must not reimplement core composition semantics. If a command requires logic useful to non-CLI callers, that logic belongs in the library.
Command-specific rules:
render- accepts
filemode andprofilemode, - requires
--file <path>in file mode, - accepts optional guidance and user prompt blocks,
- writes to stdout by default unless an output path is chosen.
- accepts
resolve- is defined for
profilemode only, - fails for
filemode.
- is defined for
validate- uses the same resolver and include graph as
render, - never writes rendered output.
- uses the same resolver and include graph as
frontmatter-init- rewrites or inserts frontmatter for a single target file,
- uses token discovery but does not render the file.
template-init- rewrites a single target file into a single-pass or multi-pass template,
- accepts one or more
--pass Ngroups with pass-scoped--varand--var-fileinputs, - honors
--forcefor existing frontmatter/template rewrites, - honors
--dry-runwithout writing the rewritten file, - returns exit code
3when requested literal values are not found because that outcome is a usage/configuration failure rather than a successful drift result.
init- performs repository bootstrap and validation-oriented scanning.
verify- compares one deployed file against the rendered output of
--against <template>, - accepts
--quietto suppress diff body output, - accepts
--builtin-var KEY=VALUEoverrides for deterministic builtin values, - accepts pass-scoped
--pass Ngroups with per-pass--varand--var-fileinputs when--allis used, - returns exit
0when clean, exit1when drift is detected, and exit2or3for genuine validation/render or usage/configuration failures.
- compares one deployed file against the rendered output of
observability-health- reads logger health state without mutating composition behavior,
- prints a human-readable health summary by default,
- emits
LoggingHealthReportunder--jsonas defined in section 19.3.
examples listandtemplates list- enumerate entries under their respective roots,
- surface normalized flat example names or template directory names as pack names,
- emit stable JSON payloads containing
nameand absolutepath, - may append
template.jsondescriptionandversionin human-readable text output for templates when present.
templates add- accepts a single file or directory source,
- creates one pack directory in the user template root,
- uses the explicit
[name]when provided, - otherwise uses the source directory name for directory input or the normalized template filename for file input,
- fails if the target pack name already exists,
- does not merge into an existing pack in the initial release.
examples <name>andtemplates <name>- treat the command namespace as the pack root selector,
- support the same render flags and output semantics as
render, - are defined only when the target pack has exactly one root-level
*.j2file.
reports init- creates the shared reports scaffold and starter catalog,
- does not own repo-specific producer command bodies.
reports smoke- accepts the smoke fixture and vars inputs,
- runs the shared smoke render path,
- emits the smoke latest-artifact set without owning repo-specific smoke logic beyond the shared harness contract.
reports finalize- accepts one producer-owned latest artifact set,
- writes the canonical
report.jsonsidecar, - optionally copies the artifact set into the timestamped archive tree.
reports render-spec- accepts one TOML semantic spec file,
- renders Mermaid from
state_machineandsql_queryspecs, - writes one latest artifact plus sidecar using the shared report output contract.
reports index- summarizes deterministic latest entrypoints and report metadata from the catalog.
reports verify- checks required report artifacts for presence and reports missing evidence as a failure.
reports publish-manifest- writes
reports/latest/publish-manifest.json, - derives manifest content from current latest sidecars and artifact sets,
- skips optional reports whose latest artifact sets are absent,
- fails when required report evidence is missing,
- lists intended publish destinations without owning upload behavior.
- writes
help- with no topic and no
--list, prints a human-readable topic index, --listprints a UTF-8, newline-delimited topic name per line in registry order, with no labels or indentation, as the stable shell-pipeline form,--jsonuses the versionedDiagnosticEnvelope; the index/list payload is{ "topics": ["..."] }, and a topic payload is{ "topic": "...", "manual": "..." },- a valid topic prints that topic's bundled manual content verbatim,
- an unknown topic fails closed with exit
3and lists valid topic names, - has no rendering side effects and does not go through
compose, - is an explicit CLI command: clap's automatic
helpsubcommand is disabled while the generated--helpflag remains enabled, - resolves topic names only after the
helpcommand has been selected, so a topic may equal a real root command (help renderversusrender) without shadowing or reinterpreting that root command.
- with no topic and no
- The generated root
sc-compose --helpoutput must retain a final discoverability footer directing users tosc-compose help(andsc-compose help <topic>) for the complete bundled manual index. This is a CLI parser/help-rendering concern, not asc-composerlibrary concern.
Guidance and prompt input model:
--guidance <text>and--guidance-file <path|->feedguidance_block.--prompt <text>and--prompt-file <path|->feeduser_prompt.- The CLI rejects ambiguous attempts to read both blocks from the same stdin stream in one invocation.
CLI alias model:
--agent-typeis a CLI alias for--agent.--aiis a CLI alias for--runtime.- Aliases are normalized in the CLI before constructing
ComposeRequest.
The CLI owns final output shaping. Library result types may be richer than the command-facing JSON contract.
All --json command output uses the versioned DiagnosticEnvelope transport:
{
"schema_version": "1",
"payload": {},
"diagnostics": []
}help --list --json and help --json use this payload shape:
{
"topics": ["exit-codes", "render"]
}help <topic> --json uses this payload shape:
{
"topic": "render",
"manual": "# sc-compose render\n..."
}The non-JSON help --list schema is intentionally line-oriented for shell
pipelines: UTF-8 topic names, one per line, in registry order, with no labels
or indentation. The JSON form is the machine-readable alternative and carries
the same ordered topic data inside the envelope.
The schemas below define the payload shape for each command.
render --json
{
"output_path": "stdout",
"bytes_written": 123,
"template": "path/to/template.md.j2",
"body": "rendered document text"
}For non-dry-run stdout renders, body contains the rendered document. When
--output <file> is supplied, body is omitted because the file is the
source of truth.
render --dry-run --json
{
"would_write": ".prompts/example-01HXYZ.md",
"would_change": true,
"template": "path/to/template.md.j2",
"rendered_preview": "preview text"
}resolve --json
{
"resolved_path": ".claude/agents/example.md.j2",
"search_trace": [
".claude/agents/example.md.j2",
".agents/agents/example.md.j2"
],
"found": true
}template-init --json
{
"template_path": "path/to/template.md",
"template_added": true,
"would_change": true,
"vars": ["task"]
}validate --json
{
"valid": false
}init --json
{
"workspace_root": "/repo",
"created_files": [
".prompts/",
".gitignore"
]
}init --dry-run --json
{
"action": "init",
"would_affect": [
".prompts/",
".gitignore"
],
"changed": false,
"would_change": true,
"skipped": false
}examples list --json and templates list --json
{
"packs": [
{
"name": "hello",
"path": "/path/to/share/sc-compose/examples/hello.md.j2"
}
]
}templates add --json
{
"name": "pytest-fixture",
"source": "/path/from",
"destination": "/path/to",
"changed": true
}Named render through examples <name> and templates <name> reuses the
render and render --dry-run payload schemas.
observability-health --json
{
"logging": {
"state": "Healthy",
"dropped_events_total": 0,
"flush_errors_total": 0,
"active_log_path": "<log_root>/logs/sc-compose.log.jsonl",
"sink_statuses": [],
"last_error": null,
"query": null
}
}frontmatter-init --json
{
"template_path": "templates/example.md.j2",
"frontmatter_added": true,
"would_change": true,
"vars": [
"name",
"role"
]
}Non-render --dry-run --json
{
"action": "frontmatter-init",
"would_affect": [
"templates/example.md.j2"
],
"changed": false,
"would_change": true,
"vars": [
"name",
"role"
],
"skipped": false
}Schema notes:
search_traceis the CLI serialization of the library resolver search path trace.locationis a single string field in CLI JSON even when the library tracks path, line, and column separately.rendered_previewis the dry-run preview string.payload.logging.queryisnullwhen query/follow health is unavailable and otherwise contains aQueryHealthReport.active_log_pathis derived from the configured log root and service name using theLOG-008layout<log_root>/logs/<service>.log.jsonl.- The concrete path is platform-dependent; on Windows it may be drive-qualified.
validate --lint and the repository-level template-contracts target share
the source scanner in crates/sc-composer/src/template_scanner.rs.
The scanner expands includes through sc-composer, classifies JSON templates
using the shared suffix helper, and reports source paths, include chains,
line/column locations, effective mode, and canonical diagnostics. The
repository target is an allowlisted local sc-compose target registered in
.sc/sc-lint/targets/template-contracts.toml; it does not invoke a second
Python or shell parser. Its normal sc-lint report materializes both the JSON
raw artifact and the HTML index, and records whether a context-backed render
was available. Static source findings never claim that an unrendered dynamic
branch has been proven safe.
just lint keeps the existing external sc-lint profile and runs
template-contracts as the final full-profile step. A missing repository
root, unreadable template, or include failure is reported as a configuration
failure rather than a green pass.
File-writing behavior should be centralized rather than duplicated per command.
Policy requirements:
- file mode strips the final
.j2suffix, - profile mode writes to
.prompts/<name>-<ulid>.md, - explicit
--outputoverrides derived behavior, - dry-run returns the same derived target information without writing files.
Template packs are CLI-owned assets. They do not change the core
sc-composer composition semantics.
Root resolution:
- bundled examples root:
SC_COMPOSE_DATA_DIR/examples- install-relative
../share/sc-compose/examples/
- user templates root:
SC_COMPOSE_TEMPLATE_DIR- platform user-data directory joined with
sc-compose/templates/
Layout rules:
- examples are flat
*.j2files stored directly under the bundled examples root, - example names are derived from the filename by removing the trailing
.j2suffix and then one remaining source extension when present, - normalized example names must remain unique after that derivation step,
- templates are one subdirectory per template under the user templates root,
- template names are directory names,
- template directories may contain one or more files,
- template directories may contain non-template assets retained verbatim when a
directory source is imported with
templates add, - template directories may contain an optional
template.json.
TemplateStore
TemplateStoreis asc-composeCLI-layer abstraction and does not exist insc-composer,- it owns discovery, named lookup, and user-template import for one source root,
- concrete store roots are selected by
StoreKind::{Examples, Templates}, - minimum field shape:
source_dir: PathBufkind: StoreKind
TemplateMetacarries:name: Stringpath: PathBufdescription: Option<String>version: Option<String>
TemplatePackcarries:root: PathBuftemplate_path: PathBufinput_defaults: Map<VariableName, InputValue>
TemplateAddResultcarries:name: Stringsource: PathBufdestination: PathBufchanged: bool
- required methods:
list() -> Result<Vec<TemplateMeta>>get_example(name: &str) -> Result<Option<TemplatePack>>get_template(name: &str) -> Result<Option<TemplatePack>, GetTemplateError>add(source: &Path, requested_name: Option<&str>) -> Result<TemplateAddResult, AddError>
- examples and templates use the same abstraction with different layout rules:
- examples list and named lookup operate on flat
*.j2files, - templates list and named lookup operate on subdirectories and resolve the
single root-level
*.j2entry file when renderable, AddError::AlreadyExistsis the structured duplicate-import path,GetTemplateError::NotRenderableis reserved for zero-or-many root-level*.j2files,GetTemplateError::Parsecovers manifest and filesystem read failures.
- examples list and named lookup operate on flat
Command extraction:
src/commands/examples.rsownsrun_examples_listandrun_examples_render,src/commands/templates.rsownsrun_templates_list,run_templates_add, andrun_templates_render,main.rsretains the top-level CLI shape and dispatch only.
template.json is intentionally narrow and user-facing:
{
"description": "Minimal greeting example",
"version": "1.0.0",
"input_defaults": {
"name": "world"
}
}Manifest rules:
descriptionis for list and help output,versionis pack metadata only,input_defaultscontributes pack-level default inputs,- user-template input defaults merge with request inputs using the precedence
defined in the requirements:
- explicit input variables
- environment-derived variables
template.jsoninput_defaults- frontmatter defaults
input_defaultsvalues use the sameInputValuecontract as other caller inputs:- scalars,
- objects with string keys,
- arrays of scalars,
- empty arrays are valid,
- arrays of objects are valid when the array is the variable value itself,
- recursive arrays and objects are accepted at any finite depth
- Recursive structured-input behavior is governed by ADR-E1; no separate template-pack restriction applies.
- no manifest field selects entrypoints, paths, hooks, or alternate execution behavior in the initial release.
Implicit named render convention:
examples <name>resolves the flat example file with matching stem,templates <name>resolves the single root-level*.j2file in the named template directory,- if a template directory contains zero or multiple root-level
*.j2files, it remains listable but is not implicitly renderable by name, - supporting assets remain available for directory-import workflows and future expansion, but they do not change the initial render resolution rules.
Sprint B1 implements reporting as a generic artifact contract. This section is now active runtime behavior rather than a planning-only note.
Sprint B1 partial implementation scope:
sc-composeowns the report catalog loader and validator forreports/catalog/reports.tomlsc-composeowns the initialreportsCLI surface:reports initreports smokereports indexreports verify
- Sprint B1 does not yet implement shared repo scaffolding, latest/archive writers, or publish-manifest generation; later sprints close those follow-on runtime seams
Implemented filesystem contract:
- authored docs remain under
docs/ - report sources and catalog inputs live outside
docs/under paths such as:reports/catalog/reports/specs/reports/templates/
- generated evidence lives outside
docs/under paths such as:reports/latest/<report-id>/reports/archive/<timestamp>/<report-id>/
- each generated report carries a machine-readable metadata sidecar located
with its generated output, for example
reports/latest/<report-id>/report.json
Implemented catalog contract:
[[report]]
id = "sc-lint"
kind = "lint"
producer = "just lint"
required = true
entrypoint = "reports/latest/sc-lint/index.html"
metadata = "reports/latest/sc-lint/report.json"The canonical report catalog field inventory, requiredness rule, and shared
reporting boundary rule are defined in docs/requirements.md under
### Report Artifact Contract (Implemented In Sprint B1).
Ownership split:
- producer recipes own domain data gathering and report generation
sc-composeowns rendering semantics where it is selected as the renderer- consumer repos own domain-specific source inputs, producer entrypoints, and publish surfaces
Boundary rules:
- network publish behavior remains outside
sc-composerandsc-compose - browser-open behavior remains outside
sc-composerandsc-compose - A1 only locks the shared artifact and catalog shape
- the canonical latest/archive output policy is defined in
docs/requirements.mdunder### Phase A Latest/Archive Output And Reports Aggregator Contract (Implemented In Sprint B5) - the canonical publish-manifest contract is defined in
docs/requirements.mdunder### Publish-Manifest And CI Handoff Contract - later sprints may extend those contracts with implementation-specific publish workflow details
The canonical source-driven rendering contract, including collection-discovery
ownership and generated-manifest semantics, is defined in docs/requirements.md
under ### Source-Driven Rendering Contract (Implemented In Sprint B3). This
architecture section keeps only the illustrative extracted input shape for that
contract and does not restate the normative prose.
Planned extracted input shape per discovered source:
{
"source_path": "docs/atm/diagrams/atm-list.mmd",
"output_path": "reports/latest/state-diagrams/panels/atm-list.xhtml",
"stem": "atm-list",
"meta": {
"title": "`atm list`",
"sets": ["cli", "query"]
}
}The canonical latest/archive output policy is defined in
docs/requirements.md under ### Latest/Archive Output And Reports Aggregator Contract (Implemented In Sprint B5). This architecture section keeps only the
illustrative output shape for that contract and does not restate the normative
policy prose.
Illustrative output shape:
{
"latest": "reports/latest/sc-lint/index.html",
"archive": "reports/archive/2026-05-25T22-10-00Z/sc-lint/index.html",
"metadata": "reports/latest/sc-lint/report.json"
}The canonical publish-manifest contract is defined in
docs/requirements.md under ### Publish-Manifest And CI Handoff Contract.
This architecture section keeps only the
illustrative manifest shape for that contract and does not restate the
normative ownership or boundary prose.
Illustrative publish-manifest shape:
{
"generated_at": "2026-05-25T22:10:00Z",
"reports": [
{
"report_id": "state-diagrams",
"kind": "diagram",
"entrypoint": "reports/latest/state-diagrams/index.html",
"archive_root": "reports/archive/2026-05-25T22-10-00Z/state-diagrams",
"files": [
{
"role": "entrypoint",
"path": "reports/latest/state-diagrams/index.html",
"publish_to": "reports/state-diagrams/index.html"
},
{
"role": "metadata",
"path": "reports/latest/state-diagrams/report.json",
"publish_to": "reports/state-diagrams/report.json"
}
]
}
]
}The canonical producer-command surface and just reports contract are defined
in docs/requirements.md under ### Producer Recipe Contract (Implemented In Sprint B2). This architecture section intentionally defers to that
requirements section rather than restating the command block or aggregator
contract prose.
Adding repo-specific producer commands must not require changing the shared aggregation or discovery contract.
The canonical semantic report-spec contract is defined in
docs/requirements.md under ### Semantic Report-Spec Contract. That
requirements section is the normative owner for the
state_machine / sql_query field inventory, the transitional Mermaid rule,
the semantic QA direction, and the extension rule. This architecture section
intentionally defers to that requirements section rather than restating those
lists or transition-policy details.
Runtime integration notes:
- semantic spec source files are TOML
reports render-specemits Mermaid latest outputs and shared sidecarsreport-render-manymay discover TOML semantic specs and render shared diagram-family outputs from them
This repo now carries one checked-in Phase B proof set under reports/ plus
the reference Justfile producer surface.
Runtime proof direction:
- lint/test/smoke producers remain repo-local commands while using the shared artifact, sidecar, archive, verification, and publish-manifest runtime
- state-machine and SQL-query diagrams render through the same shared model without inventing a diagram-only publication contract
report-evidence-summaryis the new bundled Phase B proof vehiclesprint-report-htmlremains the backward-compatible bundled example covered by the shared proof harness
Sprint B4 implements shared template-family selection and shared panel chrome so consumer repos reuse one UI contract instead of rebuilding it per report family.
Initial template families:
- lint/test/smoke evidence reports
- public API, CLI, and ICD style reports
- diagram, state-machine, and SQL-query reports
For the canonical selection and override example, see
docs/phase-A/sprint-A5.md.
The authoritative override contract, bundled shared template root, consumer
activation config, shared panel contract, ownership split, Jinja2 block
boundary, required template variables, and include deferral are defined in
docs/phase-A/sprint-A5.md.
init_workspace and the CLI init command must:
- create
.prompts/if needed, - ensure
.prompts/is ignored by Git, - scan repository templates,
- validate discovered templates,
- return recommendations for missing or weak frontmatter,
- fail when invalid templates are found.
This keeps the repository bootstrap step useful as an early correctness check, not just as directory creation.
Variable-file behavior:
--var-fileloads a JSON or YAML object,- keys are strings,
- values are
InputValue, - object values with string keys are valid,
- sequence values may contain recursive JSON/YAML-compatible values at any finite depth.
- Default deny for out-of-root file access
- No shell execution inside the composition pipeline
- No evaluation of arbitrary host code from templates
- No hook execution from template packs or
template.json - Include stack tracked for all include-related diagnostics
- Deterministic failure semantics for path escape, missing include, cycle, and depth overflow
Follow-on boundary note:
- Browser-open/post-render behavior for HTML report workflows remains outside
sc-composeitself and belongs in wrapper tooling such as the/sprint-reportskill. - The follow-on HTML report plan intentionally separates structured-input
support in
sc-composefrom workflow orchestration around multiple render calls.
The library should expose typed errors with stable categories. Validation results should remain structured and not collapse into string-only errors.
Target CLI exit semantics:
0success2validation or render failure3usage, configuration, or contract error
Canonical failures must map to stable error families and stable codes.
| Failure condition | Error type | Stable code |
|---|---|---|
| Template not found | ResolveError |
ERR_RESOLVE_NOT_FOUND |
| Ambiguous template match | ResolveError |
ERR_RESOLVE_AMBIGUOUS |
| Include target not found | IncludeError |
ERR_INCLUDE_NOT_FOUND |
| Include path escapes confinement root | IncludeError |
ERR_INCLUDE_ESCAPE |
| Include cycle detected | IncludeError |
ERR_INCLUDE_CYCLE |
| Include depth exceeds limit | IncludeError |
ERR_INCLUDE_DEPTH |
| Include target cannot be exhaustively enumerated as a static dependency | IncludeError |
ERR_INCLUDE_DYNAMIC_UNRESOLVED |
| Duplicate frontmatter variable | ValidationError |
ERR_VAL_DUPLICATE |
| Empty template body | ValidationError |
ERR_VAL_EMPTY |
| Root template has no frontmatter block | ValidationError |
ERR_VAL_MISSING_FRONTMATTER |
| Required variable not satisfied after context merge | ValidationError |
ERR_VAL_MISSING_REQUIRED |
| Undeclared referenced token in strict validation or render mode | ValidationError |
ERR_VAL_UNDECLARED_TOKEN |
Extra provided variable when policy is error |
ValidationError |
ERR_VAL_EXTRA_INPUT |
Referenced variable has no merged runtime binding when the unbound-variable policy is error |
ValidationError |
ERR_VAL_UNBOUND_VARIABLE |
| Stdin read attempted twice | RenderError |
ERR_RENDER_STDIN_DOUBLE_READ |
| Output write failure | RenderError |
ERR_RENDER_WRITE |
| Frontmatter rewrite refused on read-only target | ConfigError |
ERR_CONFIG_READONLY |
| Command or helper invoked in incompatible mode | ConfigError |
ERR_CONFIG_MODE |
| Text/config file exists but is not readable as valid text | ConfigError |
ERR_CONFIG_READ |
| Config file missing or malformed | ConfigError |
ERR_CONFIG_PARSE |
| Invalid var-file shape or unsupported YAML merge key (with source location and explicit-mapping recovery) | ConfigError |
ERR_CONFIG_VARFILE |
| Malformed object from structured input source | ValidationError |
ERR_VAL_OBJECT_SHAPE |
| Legacy H2 nested-array restriction (retained code; not emitted for recursive values) | ValidationError |
ERR_VAL_NESTED_ARRAY_UNSUPPORTED |
| Nested required path expects an object but receives a scalar, or vice versa | ValidationError |
ERR_VAL_SHAPE_MISMATCH |
| Nested required field absent inside a present object or array member | ValidationError |
ERR_VAL_MISSING_NESTED_FIELD |
| Example or template pack name not found | ConfigError |
ERR_CONFIG_PACK_NOT_FOUND |
| Help topic name is not registered | ConfigError |
ERR_CONFIG_HELP_TOPIC_NOT_FOUND |
Named pack is not renderable because a bundled example name is ambiguous or a template pack has zero or multiple root-level *.j2 files |
ConfigError |
ERR_CONFIG_PACK_NOT_RENDERABLE |
templates add target name already exists |
ConfigError |
ERR_CONFIG_TEMPLATE_EXISTS |
The legacy ERR_VAL_NESTED_ARRAY_UNSUPPORTED code is governed by
ADR-E1. It remains
reserved for compatibility and must not reject values accepted by the
recursive contract.
Architecture rules:
sc-composeremits composition telemetry through its localsc_composer::observerhook layer.sc-composeprovides the canonical concrete binding to the fullsc-observabilityLogger.- The initial release scope is logging-only:
- structured log events
- logger health reporting
- graceful shutdown
- downstream extension through the local observer hook model
- If no observer is provided, library and CLI behavior degrade to a no-op observability path rather than failing.
- Library observability hooks must remain usable by embedded consumers.
- Default sink paths for standalone CLI behavior must be tool-scoped.
- Observer and sink traits must be object-safe and
dyn-compatible. - Observer and sink adapters are intentionally public and unsealed so embedded hosts can provide their own implementations.
sc-observeandsc-observability-otlpare not part of this initial release architecture.- The current CLI uplift targets
sc-observability1.2.0directly and does not add thesc-observefacade becausesc-composestill owns concrete logger construction and sink registration at this seam.
The observability dependency chain is intentionally split so the library stays runtime-agnostic:
sc-compose -----> sc-composer
|
v
sc-observability -----> sc-observability-types
sc-composerdefines its ownObservationEvent,ObservationSink, andCompositionObserverhook types locally.sc-observabilitydepends onsc-observability-typesand ownsLogger,LogSink, file sinks, console sinks,LoggingHealthReport,QueryHealthReport, andQueryHealthStatethrough its public re-export surface.sc-composedepends on bothsc-composerandsc-observability.sc-composeadapts to theLogger<Running>/Logger<Stopped>typestate by keeping the CLI observer responsible for the shutdown-state transition while preserving post-shutdown health inspection.- The CLI observer adapter now routes direct lifecycle logging through
Logger::log(...);Logger::emit(...)remains only as an upstream compatibility path and is not the primarysc-composecall surface.
sc-composer exposes a caller-provided observer/sink injection path through
its local observer module:
use sc_composer::observer::{
CompositionObserver, ObservationEvent, ObservationSink,
};
pub enum ObservationEvent {
PassStart(PassStartEvent),
PassEnd(PassEndEvent),
VerifyStart(VerifyStartEvent),
VerifyEnd(VerifyEndEvent),
ResolveAttempt(ResolveAttemptEvent),
ResolveOutcome(ResolveOutcomeEvent),
IncludeExpandOutcome(IncludeOutcomeEvent),
ValidationOutcome(ValidationOutcomeEvent),
RenderOutcome(RenderOutcomeEvent),
}
pub trait ObservationSink {
fn emit(&mut self, event: &ObservationEvent);
}
pub trait CompositionObserver {
fn on_pass_start(&mut self, event: &PassStartEvent) {}
fn on_pass_end(&mut self, event: &PassEndEvent) {}
fn on_verify_start(&mut self, event: &VerifyStartEvent) {}
fn on_verify_end(&mut self, event: &VerifyEndEvent) {}
fn on_resolve_attempt(&mut self, event: &ResolveAttemptEvent) {}
fn on_resolve_outcome(&mut self, event: &ResolveOutcomeEvent) {}
fn on_include_outcome(&mut self, event: &IncludeOutcomeEvent) {}
fn on_validation_outcome(&mut self, event: &ValidationOutcomeEvent) {}
fn on_render_outcome(&mut self, event: &RenderOutcomeEvent) {}
}
pub fn compose(request: &ComposeRequest) -> Result<ComposeResult, ComposeError>;
pub fn compose_with_observer(
request: &ComposeRequest,
observer: &mut dyn CompositionObserver,
) -> Result<ComposeResult, ComposeError>;Required library behavior:
Renderer::new()remains observer-free, andcompose()installs the built-in no-op observer unless a caller supplies an explicit observer.compose_with_observer(...)is the public end-to-end observability injection entry point.ObservationSinkandCompositionObserverremain the local extension points for embedded hosts that do not opt into the CLI.ObservationSink::emit()is the host-facing single-event adapter surface. Internal composition code emits through the typedCompositionObservercallbacks rather than routing throughemit().- The approved minimum library-owned variant set is:
PassStartPassEndVerifyStartVerifyEndResolveAttemptResolveOutcomeIncludeExpandOutcomeValidationOutcomeRenderOutcome
- The observer surface remains object-safe and callable through
&mut dyn CompositionObserver. - Command lifecycle events remain CLI-owned and must not be defined in
sc-composer.
sc-compose constructs sc-observability::Logger during CLI startup, wraps it
in a CLI-owned adapter that implements sc_composer::observer::ObservationSink
or sc_composer::observer::CompositionObserver, then passes that adapter into
compose_with_observer(...).
CLI wiring rules:
- normal terminal execution enables both file and console sinks,
--jsonexecution disables the console sink so command stdout remains valid machine-readable output,- command lifecycle logging remains CLI-owned and emits:
- command start
- command completion
- command failure
observability-healthinitializes the logger using the same configuration path as a normal CLI process, readsLogger::health(), prints a human-readable summary by default, and serializes the returnedLoggingHealthReportunder--json,observability-healthreports process-local logger state only and does not depend on any daemon or background runtime,- the same logger configuration keeps
RetainedLogPolicy::default()enabled, so rotation, pruning, maintenance cadence, and shutdown join behavior stay owned bysc-observabilityrather than by wrapper code insc-compose, sc-observabilityrotates log files by rename-then-open rather than by truncate-in-place, so the active log path is replaced through the logger's own file-rotation flow instead of wrapper-managed mutation,- on POSIX systems, rename-based rotation can replace an open path while
readers or tailers still hold the old inode; on Windows, the maintenance
thread must coordinate file-handle release and reopen behavior through
sc-observability's platform-aware rotation path, sc-composedoes not implement its own Windows file-lock workaround or alternate rotation algorithm; it relies on thesc-observabilitymaintenance thread to perform platform-correct rotation and retained-log cleanup,- CLI shutdown calls the logger's
shutdown()path so registered sinks flush before process exit.
The normative public API paths for this design are:
sc_composer::composesc_composer::compose_with_observersc_composer::Renderersc_composer::observer::ObservationEventsc_composer::observer::CompositionObserversc_composer::observer::ObservationSink
The composition pipeline emits ObservationEvent values through the local
observer hook layer. The CLI adapter maps those events into concrete logger
records with stable target, action, and message fields that describe:
Message rules:
messageis a short human-readable summary of the event outcome.- Structured fields, not
message, carry schema-relevant details. messagewording must remain stable enough for operator-facing logs and test assertions.
The CLI also emits command lifecycle events with stable target, action,
and message fields for:
- command start,
- command completion,
- command failure.
The adapter-owned mapping is:
sc-compose event source |
LogEvent.target |
LogEvent.action |
LogEvent.message |
Other LogEvent fields |
|---|---|---|---|---|
| command start | compose.command |
started |
human-readable summary such as render started |
fields include command name and relevant mode flags |
| command end, success | compose.command |
completed |
human-readable summary such as render completed |
fields include command name, elapsed time, and output mode; outcome is success |
| command end, failure | compose.command |
failed |
human-readable summary such as render failed |
fields include command name, exit code, elapsed time, and output mode; outcome is failure; diagnostic is attached when available |
| resolve attempt or outcome | compose.resolve |
phase-specific action such as attempt, resolved, or failed |
concise resolver summary sentence | outcome reflects success/failure; diagnostic is attached for failures; resolver traces or selected paths live in fields |
| include-expand outcome | compose.include_expand |
phase-specific action such as expanded or failed |
concise include-expansion summary sentence | include stack and path details live in fields; failures attach diagnostic |
| validation outcome | compose.validate |
phase-specific action such as completed or failed |
concise validation summary sentence | validation counts and policy decisions live in fields; failures attach diagnostic |
| render outcome | compose.render |
phase-specific action such as completed or failed |
concise render summary sentence | render metadata lives in fields; outcome and diagnostic reflect success/failure |
This mapping is intentionally adapter-owned so sc-observability preserves a
generic logging contract and command lifecycle events remain CLI-owned.
The release architecture keeps room for future extensions without destabilizing the core behavior.
Expected extension points:
- typed variable schemas,
- remote include providers,
- template caching,
- custom resolver policies,
- richer frontmatter metadata consumers,
- template-pack lifecycle commands beyond
add, - named render for multi-template packs.
Trait openness decisions:
- sink traits are open extension points for embedded hosts,
ResolverPolicyis open because caller-specific path policy is an explicit product requirement,- value-model and metadata extension points remain narrow by design: finite recursive JSON/YAML-compatible values are open in the initial release, but hooks and arbitrary manifest-driven behavior remain deferred.
This section describes the shipped H1-H4 architecture plus the explicit H5+ boundary. It must not be read as license to silently expand the delivered contract.
The shipped structured-input track expands InputValue to support:
- object/map values with string keys,
- arrays of objects,
- nested object trees and recursive arrays needed for report composition,
- repeated report sections such as
sprints.
Finite nested arrays are supported by E.1. Examples such as
sprints[].checks[] are valid input shapes; bracket notation remains an
illustrative template-data notation rather than VariablePath grammar.
Resolver and merge behavior for structured inputs must remain consistent with the existing precedence model:
- explicit input variables
- environment-derived variables
template.jsoninput_defaults- frontmatter defaults
Additional structured-input rules:
VariableNameremains a top-level key only. Discovery of{{ pr.number }}yieldspr, notpr.number.- Required nested references use a separate
VariablePathconcept rather than reusingVariableName. VariablePathgrammar is dotted segments of alphanumeric, underscore, and hyphen characters such aspr.numberorreport.plan_url.- H1/H2
VariablePathdoes not support bracket notation. Any prose examples using[]describe future shape, not the H1/H2 path grammar. - Nested required-variable satisfaction walks the
InputValuetree by path segment. - The traversal semantics are:
- missing top-level key ->
ERR_VAL_MISSING_REQUIRED - missing nested segment inside a present object ->
ERR_VAL_MISSING_NESTED_FIELD - scalar where an object is required for the next segment ->
ERR_VAL_SHAPE_MISMATCH
- missing top-level key ->
- Structured variable defaults are replaced, not deep-merged, at the top-level variable boundary. When an explicit input and a frontmatter default both provide the same top-level variable key, the explicit input replaces the entire default value.
- Extra-variable policy from FR-2b applies at the top-level variable boundary only. Fields inside a provided object that the template never accesses are always accepted and do not trigger extra-input diagnostics.
- Extra-input detection therefore operates on discovered top-level keys such as
prandsprints, not on nested paths such aspr.number.
--var-file
- remains the primary structured-input ingress,
- parses JSON or YAML objects,
- carries recursive JSON/YAML-compatible values in this phase.
--var key=value
- remains string-only,
- does not gain ad hoc object parsing or dotted-key assembly in this phase.
Frontmatter defaults
- gain structured-value support in H1 using the same
InputValuetype and the same validation gate as--var-file, - accept recursive JSON/YAML-compatible values at any finite depth.
template.json input_defaults
- gain structured-value support under the same recursive rules as frontmatter defaults.
validate
- must report missing nested field paths,
- must reject malformed objects and unsupported scalar/key shapes with stable diagnostics,
- may validate field presence and supported shape without growing into a full schema language.
required_variables
- remains the declaration surface for required inputs,
- must support nested field paths such as
pr.numberandreport.plan_url.
frontmatter-init
- must discover nested references such as
{{ pr.number }}, - must discover loop-body references such as
{% for sprint in sprints %}{{ sprint.id }}{% endfor %}, - must attribute
{{ sprint.id }}inside that loop to the array variablesprints, not tosprintorsprint.id, - requires scope-aware token scanning. A regex identifier sweep without scope tracking cannot distinguish loop-bound names from context variables,
- H2 resolves the spike in favor of a hand-rolled
for/endforscope tracker. MiniJinja does not expose a stable public AST interface for this use case, while the scope tracker covers the required discovery contract without couplingsc-composerto parser internals, - the scope tracker collects identifiers from loop iterable expressions before binding loop locals, then ignores loop-bound names inside the loop body,
- must emit understandable generated field paths instead of opaque flattened names.
The HTML sprint-report track uses the structured-input expansion for a bundled
single-panel sprint-report-html example and wrapper integration.
Architectural boundaries:
sc-composeowns rendering,- the example/template pack owns the HTML structure,
- H3 keeps the bundled example as a single flat file
examples/sprint-report-html.html.j2, - directory-based example layout is deferred beyond H4,
- filename-aware
AutoEscape::Custom("sc-compose-html")applies to.html.j2,.htm.j2,.xml.j2, and.xhtml.j2templates. The shared formatter escapes markup and represents XML-illegal control bytes with the legal replacement-character NCR�; see FR-14 and its FIX-278 clarification, - wrapper tooling such as
/sprint-reportowns open/display behavior and is documented in.claude/skills/sprint-report/SKILL.md, - the wrapper-owned orchestration flow is:
- build one structured JSON payload,
- call
sc-compose examples sprint-report-html --var-file ... --output ..., - let the wrapper write and optionally open the rendered output file,
- wrapper-owned orchestration may write one rendered HTML artifact and open it
after render, but it does so by calling existing
sc-composecommands rather than introducing multi-render orchestration, hooks, or browser behavior into the CLI, - no hook execution is added to
sc-composefor this phase.
Follow-on work may explore:
- multi-panel HTML/XHTML report composition,
- wrapper-level
--openor application-selection behavior, - optional post-render hook designs that remain outside
sc-composerand do not become implicitsc-composebehavior without an explicit later architecture amendment.
The native @<path> include expansion path is the ownership point for source
composition discovery. It returns a first-seen, path-deduplicated manifest of
canonical local sources and ordered include occurrences, then delegates the
per-file and composition calculations to the two published sc-sha
operations. sc-composer does not maintain a second hash implementation.
The resulting CompositionFingerprint is exposed alongside the expanded
template and successful composition result. MiniJinja dependency statements
remain a separately tested inspection/loading capability until they are wired
to this same manifest contract; they must not grow a second fingerprint
algorithm. The standalone sc-sha-python package is a thin maturin adapter
with no dependency on sc-compose, sc-composer, or ATM runtime packages.
Native includes may use a statically enumerable conditional path expression,
such as @<{{ "partials/item.md" if mode == "item" else "partials/other-item.md" }}>.
The include walker hashes both branch
candidates and preserves the condition in the expanded template so rendering
still selects one branch. Other dynamic targets remain
ERR_INCLUDE_DYNAMIC_UNRESOLVED and cannot produce a cacheable fingerprint.