Skip to content

feat: declare the Service Intent metamodel once and parse every example with it - #77

Closed
ExtraToast wants to merge 1 commit into
mainfrom
feat/38-service-intent-metamodel
Closed

ExtraToast wants to merge 1 commit into
mainfrom
feat/38-service-intent-metamodel

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Closes #38 (parent #35)

What this branch does

The Service Intent metamodel is declared once, in src/wire/service-intent/, and every example in the repository is parsed against it. A document either conforms, or fails with the error code the specification says it should. This is the repository's first src/.

intent lint: 11 Service Intent document(s) and 5 env file(s) conform to the metamodel.

The judgements later tickets inherit

The wire schema is the metamodel. One exported Zod const per class of chapter 10's diagram, named as the diagram names it, collected in METAMODEL, with the 17 closed vocabularies beside it in CLOSED_VOCABULARIES. docs/architecture.md#the-wire-boundary already made the wire schema the single source of the runtime check, the TypeScript type and the JSON Schema; this makes it the source of the diagram and the tables as well.

Declaring it in the domain was rejected, and the reason matters for #41 and #42: the authoring shape carries exactly what the domain drops, a provides map, an omitted engine meaning kv, an absent block meaning none. Generating the authoring language from the domain would put the authoring vocabulary back in the core, which is what ADR 0066 separated.

A code is a registry entry, never a schema refinement. A rule with an E_ code lives in SERVICE_INTENT_RULES carrying {code, context, placement, anchor, states, evaluate}; a rule the specification gives no code stays in the schema and reports kind: "schema". The sharp case is E_ALERT_CLASS_WITHOUT_SIGNAL: Zod could express "whole or absent", but folding it in would refuse the same document while reporting schema, losing the code the fixture names. So Observability.scrape is deliberately optional, which is what makes the half-declared block representable and therefore refusable by name. Codes are unique, contexts are metamodel class names, anchors resolve against real chapter 10 headings, all asserted. No code was invented.

Conformance is four ordered steps. It parses as YAML, it is an instance of the metamodel (strict objects, so an unknown key fails, with a document path), it maps into the domain totally, and then every registered rule runs. All of them, never short-circuiting. An empty diagnostic list means it conforms. Semantics is not evaluated. Every domain node carries at, its document path, so a diagnostic's address is a property of the model rather than of the code that found the defect.

What one document cannot decide is exported data, not prose. NOT_DECIDED_BY_ONE_DOCUMENT holds 21 codes, each naming the input that is missing: the composed union, the Platform document, the node contract, the images lock, the ClusterState snapshot, the rendered set. A test holds it disjoint from the registry. E_UNKNOWN_SURFACE is deliberately left unimplemented and listed there, so #39 keeps its ticket whole.

All 11 rules are inv. The Placement type already carries derive and pre, so the registry's shape does not change when derivation arrives.

Three defects the parser found in committed examples

  • data.domain.yml's postgres-data volume declared no size, which chapter 10 requires. Set to the 100Gi its own committed rendered PVC already carries.
  • Both cutover fixtures declared an engine over a reconstructible volume, so each carried two defects and had stopped isolating one.
  • The three negative fixtures carried a Service-level alertClass that ADR 0021 replaced with the observability block, so they would have failed schema validation long before reaching the identity check they exist for.

Where the specification disagreed with itself

  • E_NON_KV_DELIVERY: chapter 10's field table, its Delivery section and ADR 0085 all say a transit grant is self only. Only chapter 10's own validation table still said "a non-KV grant". The stale row is what I fixed.
  • Asset.substitute: drawn in the class diagram, absent from the chapter's own Assets section, its field tables and every example. Removed from the mermaid and the SVG.
  • apiVersion and kind: stated normatively, carried by minimal, all four refusals and the Platform document, absent from four others. Made required and added, because the alternative legalises a document that does not say which language it is written in.
  • E_DUPLICATE_ROUTE versus E_DUPLICATE_ROUTE_MATCH: chapter 10 line 1088 says the first, its normative table says the second, and chapter 40 and ADR 0093 still say the first. Register every model rule with its context, placement, OCL statement and fixture #44 explicitly owns this cleanup, so I implemented the normative table's code and fixed only the one contradicting sentence inside chapter 10. Chapter 40 and 0093 are left for Register every model rule with its context, placement, OCL statement and fixture #44's diff to carry whole.

What the rebase changed

src/ exists now, so lint:boundaries stops skipping and enforces for the first time: all 17 rules live, 14 modules, 32 dependencies, no violations.

That flips one ledger row. RULE-043, generated artifacts are committed and CI fails on a regeneration diff, was pending because nothing generated anything yet. The intent gate is that first generator: it regenerates spec/v1/schemas/service-intent.schema.json from the metamodel and fails on a diff. Pending drops 19 to 18.

Two pending reasons that this branch made false were repaired in place rather than flipped, since nothing enforces either rule yet. Three more (RULE-024, RULE-025, RULE-041) now have code to constrain and are satisfied by it, but nothing enforces them, so they stay pending rather than claim a proof they do not have. That is #30's call.

REQ-015 in the behaviour ledger, count to 15. RULE-061 for the intent gate, count to 61. ADR 0105. Fifteen gates in docs/architecture.md, seven of them sharing the contracts job.

Ratchet

Statements 98.31 to 98.78, branches 92.43 to 94.16, functions 100, lines 98.19 to 98.68. Every module under src/ is at 100% statements, lines and functions.

Verification

Eleven codes, each from a base fixture with one mutation so each isolates one defect. Two examples:

E_CUTOVER_UNHONOURABLE  services[0].workloads[0].cutover: workload fixture-api asks for a rolling cutover over 1 volume(s) that cannot surge
E_NON_KV_DELIVERY       services[0].workloads[0].secrets[0].delivery: a transit grant on k may only be delivered as self, never as env

Breaking the auth worked example at the case chapter 10 names, auth-ui listening on 8080:

spec/v1/examples/auth/auth.domain.yml: expected accepted, got E_PRIVILEGED_PORT_UNDER_NONROOT
  services[0].workloads[1].provides.http: E_PRIVILEGED_PORT_UNDER_NONROOT: workload auth-ui provides http on 80; the answer is a port above 1024

Restored, and the gate is green again. npm run verify clean on the rebased tree: 92 ADR files, 159 link files, 15 ledger rows, 61 rules, 33 doc claims, no leaks, no dependency violations, 373 tests. actionlint 1.7.7, the version CI pins, exit 0.

…le with it

The Service Intent language had four statements of itself and no declaration:
a class diagram kept by hand in three notations, a closed-vocabulary table, a
set of field tables, and prose for 61 error codes. Nothing joined them, so a
disagreement between any two was invisible: each artefact was internally
consistent. The checks that existed read YAML by indentation, which could see
neither a closed vocabulary nor a union nor where in a document a defect was.

Declare the language once, as the wire schemas docs/architecture.md prescribes,
and parse every Service Intent document in the repository against it.

- src/wire/service-intent/ is the metamodel: one export per class of chapter
  10's diagram, collected in one enumerable record, plus the concrete syntax
  (YAML, and the placeholder grammar the env files are written in, which had a
  table of four sources and no grammar until now). Unions are unions: a grant
  is discriminated on engine per 0085, a probe is http or tcp. The seventeen
  closed vocabularies are enums there and nowhere else in code.
- src/domain/service-intent/ is the abstract syntax and the well-formedness
  rules. A rule is a pure function from one parsed document to a diagnostic
  list, registered with its code, the class it constrains, its Essential OCL
  placement and its chapter anchor: the shape chapter 40 already gives the
  estate-wide invariants. Eleven rules are registered; the rules that need a
  second document name the input they are missing instead.
- src/application/parse-service-intent.ts is what "conforms to" means: YAML,
  then the metamodel, then the mapper, then every rule, each stage reporting
  everything it finds.
- scripts/lint-intent.ts parses all eleven documents and five env files. A
  refusal fixture must fail with exactly the code its expect header names, and
  the committed JSON Schema must be what the metamodel generates.

Settled here, per the epic: stateful is deleted from the language, from
chapter 10 and its class diagram, from chapter 20's authority table and from
the examples; the object kind derives from lifecycle and volumes. A Workload
has zero or more env files. The indentation-based checks in the simplification
and diagram-consistency tests are replaced by checks over the parsed model.

Three example defects the parser found and this fixes: the postgres claim
declared no size, the two cutover fixtures declared an engine over a
reconstructible volume that derives no backup, and the negative fixtures
carried a Service-level alertClass that 0021 replaced. Two places where the
specification disagreed with itself are repaired in the spec's own favour:
E_NON_KV_DELIVERY narrows to transit, which is what the field table, the
Delivery section and 0085 all say and only the validation table did not; and
chapter 10's Asset class drew a `substitute` map that its own Assets section
and every worked example do not carry.

Closes #38
@ExtraToast ExtraToast added type: feature New user-facing or operator-facing capability. area: deploy homelab-deploy, deploy-v2, manifests, and rollout flow. component: config Configuration schema, defaults, or repo settings. component: tests Unit, integration, system, or contract test concern. labels Sep 14, 2026
it("includes the auth worked example, parsed into the domain model", () => {
const file = join(
REPOSITORY,
"spec/v1/examples/auth/auth.domain.yml".replaceAll("/", "/"),
"expect: E_ENGINE_WITHOUT_DURABILITY\ndomain: fixture",
)
.replace("runtime: node", "runtime: node\n engine: valkey")
.replace("provides", "provides")
@ExtraToast
ExtraToast marked this pull request as draft September 14, 2026 09:46
@ExtraToast

Copy link
Copy Markdown
Contributor Author

Parked as a draft. The model's design is not settled, so landing the first src/ modules now would fix decisions that are still open. The branch stays as it is, rebased on e420920 and green, until the design is agreed.

@ExtraToast

Copy link
Copy Markdown
Contributor Author

Superseded by the three pull requests that rewrote this work against the renamed model, with the model-driven implementation beside it: #112 (the minimal case end to end), #113 (every worked example, the descriptor and the JSON Schema) and #114 (refusals, their diagnostics oracles and the OCL invariants, closing #38). Closing.

@ExtraToast ExtraToast closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deploy homelab-deploy, deploy-v2, manifests, and rollout flow. component: config Configuration schema, defaults, or repo settings. component: tests Unit, integration, system, or contract test concern. type: feature New user-facing or operator-facing capability.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Declare the Project Intent metamodel once and parse every example with it

2 participants