Entity definition architecture — common-denominator frontmatter and per-entity definition surface
Status: open/accepted
Summary
Section titled “Summary”- Every entity is a directory under
plugin/lib/model/entities/<type>/carrying a fixed surface:schema.ts(the Zod frontmatter schema and themarkdown-contractcontract(...)body grammar),body-template.eta,definition.md, plus optionalobsidian-index.yamlandops/(Entity file surface). - Frontmatter shared by every type is declared once as the
CommonFrontmatterZod base (_common.ts) and extended per type via.extend({ … }); defaults live as Zod.default()s. There is no separate frontmatter-defaults file (Common-denominator frontmatter). - The optional
obsidian-index.yamlis an Obsidian Bases view template;/sdlc:setup --obsidianmaterializes it into a consuming project. Its filename is configurable viaplugin/lib/configuration.ts(Obsidian Bases view template). - Operations are declared per entity in
definition.mdand implemented asdefineOpdescriptor modules underops/, discovered by a runtime walk (Operations). - A
plugin/lib/model/entity.tsprojection assembles the surface into one object so the validator, scaffolder, and dispatcher share schema-loading and validation glue (Entity projection).
Decision
Section titled “Decision”- Every entity is a directory under
plugin/lib/model/entities/<type>/. The directory name is the type. - Each directory ships the surface in Entity file surface: required
schema.ts(the Zod frontmatter schema and themarkdown-contractbody grammar),body-template.eta,definition.md; optionalobsidian-index.yamlandops/. - Frontmatter shared by every type is declared once as the
CommonFrontmatterZod base inplugin/lib/model/entities/_common.tsand extended viaCommonFrontmatter.extend({ … })from each per-type schema. - Frontmatter defaults live as Zod
.default()s inschema.ts. The scaffolder synthesizes the frontmatter block from the schema’s defaults — there is no separate frontmatter-defaults file. - Operations are declared in
definition.mdas a table and implemented asdefineOpdescriptor modules underops/(D-0007-deterministic-op-substrate). The framework discovers them by walking the directory; no per-entity registration code. - Every instance carries a
type:frontmatter field — the dispatch tag the validator and runtime use (Why keeptype:). tagsis required at the common-denominator level, defaulting to[]. Per-type schemas may addcontainsconstraints.
The substrate is TypeScript (D-0006-typescript-substrate); the body templating engine is Eta (P-0004-leverage-best-tool).
A uniform surface lets the framework treat every entity the same: resolve schema, validate
frontmatter, render body, walk operations, dispatch. The alternative — per-entity glue — defeats the
discipline S-0005-entity-definition-contract imposes and bloats every new entity with
boilerplate. Defaults live as Zod .default()s on the schema because they express the contract and
its defaults in one typed place; a second defaults file is a duplicate source of truth. Declaring
operations in definition.md keeps the contract human-auditable while a deterministic parser feeds
the framework.
Common-denominator frontmatter
Section titled “Common-denominator frontmatter”Every entity carries these fields. They are declared once as the CommonFrontmatter Zod base in
plugin/lib/model/entities/_common.ts; per-type schemas extend the base
(CommonFrontmatter.extend({ … })) and add their own fields.
| Field | Required | Description |
|---|---|---|
type | yes | Dispatch tag (decision, milestone, …). Const-valued per schema. See Why keep type:. |
schema_version | optional | Numeric string version of the per-type schema this instance conforms to. Independent of any product version. Never in any per-type required set — audit flags a missing stamp as drift, not the validator. |
id | yes | Immutable identifier per D-0002-entity-identifier-shape — AA-NNNN. |
status | yes | Lifecycle state, prefixed open/* (active) or closed/* (terminal). Per-type enums define legal values. |
title | yes | Human-readable headline. |
created | yes | ISO date of authorship. |
last_reviewed | optional | ISO date of last review. |
related | optional, defaults [] | Cross-references to other entities as wikilinks (slug optional per D-0002-entity-identifier-shape). |
tags | required, defaults [] | Free-form labels. Per-type schemas may add contains constraints (e.g. Principle requires exactly one principle/<category> tag). |
need_human_review | optional, defaults false | Review-tracking flag. |
Per-type schemas add their own fields beyond this set (e.g. Milestone adds version; Decision adds
supersedes / superseded_by).
A shared Zod base keeps the contract in one place: a per-type schema specializes type (literal),
id (pattern), status (enum), and related.items (pattern) by passing a narrower field of the
same name to .extend() — Zod replaces the key in place, so key order is preserved — then
.strict() rejects unknown keys. The Entity projection and the docs generator
derive from the same schema (zodToJsonSchema in memory); they do not generate the schemas. An
optional on-demand JSON-Schema export (editor/YAML-LS autocomplete) can be produced from the same
projection, but no schema.json is checked in.
Entity file surface
Section titled “Entity file surface”| Artifact | Required | Purpose |
|---|---|---|
schema.ts | yes | The entity’s full two-plane contract. Exports the Zod frontmatter schema (a CommonFrontmatter.extend({ … }).strict() schema declaring required fields, enums, and .default()s, registered in _registry.ts) and the markdown-contract Contract (that same Zod schema as the frontmatter plane, plus the body grammar — required / optional H2 sections and content leaves), registered in _contracts.ts. Source for validation (.safeParse frontmatter plus body-grammar conformance) and scaffolded frontmatter. |
body-template.eta | yes | Eta template for a new instance’s body, rendered with the frontmatter + context. Body-only — no frontmatter block. |
definition.md | yes | Human reference: Purpose, Identifier, Frontmatter, Body shape, Lifecycle, Relationships, Operations, Workflow invariants, Position in the entity model, Summary. |
obsidian-index.yaml | optional | Obsidian Bases view template (Obsidian Bases view template). |
ops/<op>.ts | zero or more | defineOp descriptor modules (Operations). |
The scaffolder writes the frontmatter block from the schema’s Zod .default()s and renders
body-template.eta after it. There is no frontmatter-defaults file.
The body-template, definition, and ops/ directory names are single-sourced in
plugin/lib/configuration.ts under configuration.entity.* (the view-template basename under
configuration.obsidian.entityIndexTemplate); the projection, scaffolders, checks, and the
registry walk all read from there rather than hardcoding literals. schema.ts is resolved by
static import through _registry.ts / _contracts.ts, not by filename.
Obsidian Bases view template
Section titled “Obsidian Bases view template”An entity MAY ship an obsidian-index.yaml — an Obsidian Bases
saved-view definition (a filters block plus one or more views). It is not frontmatter and not
part of any instance; it describes how that type’s instances are displayed as a table/cards view.
/sdlc:setup --obsidiancopies<type>/obsidian-index.yamlverbatim into the consuming project atdocs/planning/<plural>/<plural>.base(the.baseextension Obsidian’s Bases feature reads).- The template basename is configurable via
configuration.obsidian.entityIndexTemplate(defaultobsidian-index) inplugin/lib/configuration.ts— the home for global, potentially-configurable system properties. - Templates are validated headlessly by the
entities validateop’sbaseflavor (validateBaseFileinplugin/lib/model/ops/validate.ts— top-level key whitelist, view types, filter-expression shapes, sort entries)./project-check’scheck_entity_bases.tsruns every shipped template through that core at source, so a malformed template never ships a broken.baseto adopters.
Operations
Section titled “Operations”Each entity’s definition.md carries an Operations H2 table:
| Column | Meaning |
|---|---|
| Name | Kebab-case identifier scoped to the entity (e.g. task-create, standard-supersede). |
| Surface | Where the op is exposed: cli, skill, runner, or a combination. See Surface semantics. |
| Signature | Human-readable invocation shape. The op module’s descriptor is authoritative. |
| Pointer | Path to the implementation module (e.g. plugin/lib/model/entities/task/ops/create.ts). |
| Description | One line. |
A table (not a list) so columns can be added for new specification needs without restructuring every
entity’s section. A type with no operations omits ops/ and states “No operations” in the table.
Module contract
Section titled “Module contract”Each operation is a TypeScript module under ops/<op>.ts (depth-3 ops under ops/<group>/<op>.ts)
exporting a defineOp-shaped descriptor (D-0007-deterministic-op-substrate §2, amended by
D-H7FS-op-substrate-surface): path: string[] (2–3 kebab segments), Zod input / output,
cli hints (including an optional render hook for text projection), and a handler. The legacy
{ noun, verb } export is normalized to path: [noun, verb] by defineOp for back-compat. The
ops/ modules are the registry’s source — the runtime registry is the composed index over them,
and sdlc <path…> is one generated adapter among CLI / MCP / HTTP, not the sole dispatch target.
Non-entity, project-level ops register explicitly via defineOp(...) (D0007 §2a).
Surface semantics
Section titled “Surface semantics”Each Surface value commits the framework to a deterministic check; a declaration that doesn’t match reality is a drift-check failure.
| Value | Means | Drift check enforces |
|---|---|---|
cli | Invokable as sdlc <type> <op>. | The op module exists and exports a valid defineOp descriptor. |
skill | Invokable as /sdlc:<type>-<op>. | plugin/skills/<type>-<op>/SKILL.md exists. The framework only enforces presence; Claude Code owns skill loading. |
runner | Called by framework machinery (not user-facing). | The op module exists but is not registered as a CLI subcommand. |
Common combinations: cli, skill (the skill is the LLM-facing head per
S-0004-sdlc-cli-llm-head-deterministic-tail and its deterministic tail invokes the CLI op);
cli alone (pure deterministic op); skill alone (LLM-orchestrated flow with no CLI counterpart).
SKILL.md authoring rules and skill registration are owned by Claude Code and
plugin/.claude-plugin/plugin.json; this ADR does not duplicate them.
Discovery mechanism
Section titled “Discovery mechanism”Each entity type is registered in the entity schema registry (_registry.ts,
type → { schema, version }); the framework treats every registered type whose package dir ships a
definition.md as an entity (named by the directory), parses each Operations table, and composes
the defineOp descriptors under ops/ into the registry. Adding an entity is “add a schema.ts,
register it, drop the rest of the surface in”; adding an operation is “drop a module in plus a table
row.” The type: field on each instance is the dispatch tag.
Why keep type:
Section titled “Why keep type:”Position: keep type: rather than infer it from directory location.
- Self-describing instances. A file copied or moved without its directory context still announces its type — needed for cross-tool interchange (LLM tool calls pass content, not paths) and flat-directory consumers.
- Defensive against reorganization. Location-as-type makes any directory rename a breaking
schema change; frontmatter
type:decouples instance identity from filesystem layout.
The cost is one redundant field per file. Dropping it is a follow-up decision if that cost ever outweighs location-coupling.
Entity projection
Section titled “Entity projection”plugin/lib/model/entity.ts ships an Entity projection that assembles the surface (the type’s Zod
schema and markdown-contract contract, body-template.eta, definition.md, parsed operations)
into one object exposing schema/body validation, scaffolding, and the operations list. It is a
read-only projection — schemas remain hand-authored Zod modules — so the validator, the
scaffolder, and the CLI dispatcher do not each re-implement schema resolution and .safeParse
glue. Entity.schema is the in-memory zodToJsonSchema projection of the registered Zod schema.
Status
Section titled “Status”The architecture above is accepted and shipped: the entity-authoring pipeline
(T-87GH-new-scripts-derive-schema-bound-values — schema-driven frontmatter, Eta body templates,
born-valid validation), the full definition surface across all shipped types, and the op registry
with its module walk (T-FCVG-op-registry-and-module-walk). /project-check enforces op-module
conformance (check_op_modules.ts), body-only templates and schema-reference sync
(check_entities.ts), and view-template validity (check_entity_bases.ts); extending it to
enforce full surface presence and Surface-matches-reality
(Surface semantics) remains open.
S-0005-entity-definition-contract is the rule-statement that points here.
Out of scope
Section titled “Out of scope”- Cross-entity validation rules. Rules like “every Standard must be referenced by a Principle or Decision” are left to a future ADR.
Open questions
Section titled “Open questions”- Operation parameter declaration. Whether the Operations table gains a machine-readable
Parameters column, or the
defineOpZodinputstays the sole authority.