Skip to content

Entity definition architecture — common-denominator frontmatter and per-entity definition surface

Status: open/accepted

  • Every entity is a directory under plugin/lib/model/entities/<type>/ carrying a fixed surface: schema.ts (the Zod frontmatter schema and the markdown-contract contract(...) body grammar), body-template.eta, definition.md, plus optional obsidian-index.yaml and ops/ (Entity file surface).
  • Frontmatter shared by every type is declared once as the CommonFrontmatter Zod 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.yaml is an Obsidian Bases view template; /sdlc:setup --obsidian materializes it into a consuming project. Its filename is configurable via plugin/lib/configuration.ts (Obsidian Bases view template).
  • Operations are declared per entity in definition.md and implemented as defineOp descriptor modules under ops/, discovered by a runtime walk (Operations).
  • A plugin/lib/model/entity.ts projection assembles the surface into one object so the validator, scaffolder, and dispatcher share schema-loading and validation glue (Entity projection).
  1. Every entity is a directory under plugin/lib/model/entities/<type>/. The directory name is the type.
  2. Each directory ships the surface in Entity file surface: required schema.ts (the Zod frontmatter schema and the markdown-contract body grammar), body-template.eta, definition.md; optional obsidian-index.yaml and ops/.
  3. Frontmatter shared by every type is declared once as the CommonFrontmatter Zod base in plugin/lib/model/entities/_common.ts and extended via CommonFrontmatter.extend({ … }) from each per-type schema.
  4. Frontmatter defaults live as Zod .default()s in schema.ts. The scaffolder synthesizes the frontmatter block from the schema’s defaults — there is no separate frontmatter-defaults file.
  5. Operations are declared in definition.md as a table and implemented as defineOp descriptor modules under ops/ (D-0007-deterministic-op-substrate). The framework discovers them by walking the directory; no per-entity registration code.
  6. Every instance carries a type: frontmatter field — the dispatch tag the validator and runtime use (Why keep type:).
  7. tags is required at the common-denominator level, defaulting to []. Per-type schemas may add contains constraints.

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.

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.

FieldRequiredDescription
typeyesDispatch tag (decision, milestone, …). Const-valued per schema. See Why keep type:.
schema_versionoptionalNumeric 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.
idyesImmutable identifier per D-0002-entity-identifier-shapeAA-NNNN.
statusyesLifecycle state, prefixed open/* (active) or closed/* (terminal). Per-type enums define legal values.
titleyesHuman-readable headline.
createdyesISO date of authorship.
last_reviewedoptionalISO date of last review.
relatedoptional, defaults []Cross-references to other entities as wikilinks (slug optional per D-0002-entity-identifier-shape).
tagsrequired, defaults []Free-form labels. Per-type schemas may add contains constraints (e.g. Principle requires exactly one principle/<category> tag).
need_human_reviewoptional, defaults falseReview-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.

ArtifactRequiredPurpose
schema.tsyesThe 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.etayesEta template for a new instance’s body, rendered with the frontmatter + context. Body-only — no frontmatter block.
definition.mdyesHuman reference: Purpose, Identifier, Frontmatter, Body shape, Lifecycle, Relationships, Operations, Workflow invariants, Position in the entity model, Summary.
obsidian-index.yamloptionalObsidian Bases view template (Obsidian Bases view template).
ops/<op>.tszero or moredefineOp 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.

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 --obsidian copies <type>/obsidian-index.yaml verbatim into the consuming project at docs/planning/<plural>/<plural>.base (the .base extension Obsidian’s Bases feature reads).
  • The template basename is configurable via configuration.obsidian.entityIndexTemplate (default obsidian-index) in plugin/lib/configuration.ts — the home for global, potentially-configurable system properties.
  • Templates are validated headlessly by the entities validate op’s base flavor (validateBaseFile in plugin/lib/model/ops/validate.ts — top-level key whitelist, view types, filter-expression shapes, sort entries). /project-check’s check_entity_bases.ts runs every shipped template through that core at source, so a malformed template never ships a broken .base to adopters.

Each entity’s definition.md carries an Operations H2 table:

ColumnMeaning
NameKebab-case identifier scoped to the entity (e.g. task-create, standard-supersede).
SurfaceWhere the op is exposed: cli, skill, runner, or a combination. See Surface semantics.
SignatureHuman-readable invocation shape. The op module’s descriptor is authoritative.
PointerPath to the implementation module (e.g. plugin/lib/model/entities/task/ops/create.ts).
DescriptionOne 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.

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).

Each Surface value commits the framework to a deterministic check; a declaration that doesn’t match reality is a drift-check failure.

ValueMeansDrift check enforces
cliInvokable as sdlc <type> <op>.The op module exists and exports a valid defineOp descriptor.
skillInvokable as /sdlc:<type>-<op>.plugin/skills/<type>-<op>/SKILL.md exists. The framework only enforces presence; Claude Code owns skill loading.
runnerCalled 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.

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.

Position: keep type: rather than infer it from directory location.

  1. 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.
  2. 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.

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.

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.

  • Cross-entity validation rules. Rules like “every Standard must be referenced by a Principle or Decision” are left to a future ADR.
  • Operation parameter declaration. Whether the Operations table gains a machine-readable Parameters column, or the defineOp Zod input stays the sole authority.

← Back to Decisions