Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
- 'uses': './.github/actions/setup'
# The layer boundaries on the module graph: no layer reaching outward,
# no adapter reading another adapter, no cycle, nothing unreachable from
# an entry point. Skips loudly while src/ does not exist.
# an entry point. Skips loudly on a tree with no src/ at all.
# See docs/adr/architecture/0069-boundaries-enforced-on-the-graph.md.
- 'name': 'Boundaries'
'run': 'npm run lint:boundaries'
Expand Down Expand Up @@ -144,6 +144,14 @@
- 'name': 'Docs contract'
'run': 'npm run lint:docs'

# Every Service Intent document in the repository, parsed against the one
# metamodel that declares the language: the worked examples, the refusal
# fixtures, the negative fixtures and the 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.
- 'name': 'Intent metamodel'
'run': 'npm run lint:intent'

'tests':
'name': 'Tests'
'runs-on': 'ubuntu-latest'
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ lands, the **compiler** that turns that model into deployable artifacts.
| [`spec/v1/diagrams/`](spec/v1/diagrams/README.md) | One drawn diagram per chapter, as an SVG with the editable draw.io diagram embedded. One palette; colour carries the layer. |
| [`spec/v1/examples/minimal/`](spec/v1/examples/minimal/README.md) | The smallest complete Service: one domain, one Service, one Workload, 26 authored lines reaching 10 objects. |
| [`spec/v1/examples/`](spec/v1/examples) | Worked examples: real Services from this estate, written in the model. |
| [`scripts/`](scripts/) | The gates: the ADR contract, links, manifests and layer boundaries. TypeScript that Node runs directly ([tooling](docs/architecture.md#tooling)). |
| [`spec/v1/schemas/`](spec/v1/schemas) | JSON Schema, generated from the metamodel and committed. An editor reads it; CI fails on a diff. |
| [`src/`](src) | The compiler, as it lands. Today: the Service Intent metamodel, its well-formedness rules and the use-case that parses a document against them. |
| [`scripts/`](scripts/) | The gates: the ADR contract, links, manifests, the intent metamodel and layer boundaries. TypeScript that Node runs directly ([tooling](docs/architecture.md#tooling)). |

## The shape of the model

Expand Down Expand Up @@ -87,7 +89,7 @@ npm run verify # lint, format, typecheck, ADR contract, tests + coverage
`npm run lint:adrs` alone runs the decision-record contract, and `npm test`
runs the suite without enforcing coverage. `npm run test:coverage` (part of
`npm run verify`) enforces the ratchet in `vitest.config.ts`: statements
98.31%, branches 92.43%, functions 100%, lines 98.19%.
98.78%, branches 94.16%, functions 100%, lines 98.68%.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,4 @@ Decisions about the compiler's own structure, not about the model. Their
| [0102](architecture/0102-the-gate-grows-with-the-code.md) | A new gate's script and its CI job land in the same pull request, and a test proves the two stay matched | settled |
| [0103](architecture/0103-a-behaviour-ledger-names-what-a-test-proves.md) | A behaviour ledger names every guarantee and the test that proves it, and a meta test holds the two together | settled |
| [0104](architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md) | Every enforced rule has an id, a ledger row and a fixture that proves it fires | settled |
| [0105](architecture/0105-the-wire-schema-is-the-metamodel.md) | The wire schema is the metamodel, and the rules one document decides are a registry beside it | settled |
115 changes: 115 additions & 0 deletions docs/adr/architecture/0105-the-wire-schema-is-the-metamodel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
tier: decision
status: proposed
claim: settled
date: 2026-09-14
normative: docs/architecture.md#the-wire-boundary
rests-on: ["0003"]
---

# The wire schema is the metamodel, and the rules one document decides are a registry beside it

## Rests on

Each model in the pipeline is a language with a definition, and a language
definition has four parts: an abstract syntax, a concrete syntax, a set of
well-formedness rules, and a semantics. False if: a part of the definition
exists that cannot be held in one declaration without duplicating another,
which would put the copies back. Settled by: `npm run lint:intent` parsing
every Service Intent document in this repository against
`src/wire/service-intent/`, with the chapter's class diagram, its
closed-vocabulary table and the committed JSON Schema all checked against that
same declaration and no second copy of any of them.

## Why

[0066](0066-wire-shape-is-not-the-domain.md) settled that the authoring shape
is not the domain model and that a mapper joins them. What it did not settle is
which of the two is the **language definition**, and that question has an answer
with consequences: the chapter's class diagram, its field tables, its
closed-vocabulary table and the JSON Schema an editor reads are four more
statements of the same language, and before this decision every one of them was
kept by hand.

The review that produced [issue #35](https://github.com/JorisJonkers-dev/deploy-kit/issues/35)
counted the cost: one class diagram kept in three notations, none of them
machine-readable, and 48 places where the chapters, the decision records, the
examples and the proposal disagreed, most of them one fact held in several
hand-maintained copies. A closed vocabulary was spelled out in a chapter table,
in a drawing and again as a constant in a test. Well-formedness was prose: 61
error codes, four negative fixtures, two of them proven to fire, and those by
reading YAML indentation.

The decision is therefore a placement, and it has two halves.

**The wire schema is the metamodel.** One export per class, named as the class
diagram names it, collected in one enumerable record. Everything else that
states the language is generated from it or checked against it. The concrete
syntax (which YAML the file is, and the placeholder grammar of the env files
beside it) lives in the same directory, because it is the same language's other
half, and a reader looking for what a document may say should find both in one
place.

**The rules one document decides are a registry beside it, in the domain.** A
rule is a pure function from a parsed document to a diagnostic list, registered
with its code, the metamodel class it constrains, its Essential OCL placement
and the chapter anchor that defines it. That is the same shape
[chapter 40](../../../spec/v1/40-composition.md#the-estate-wide-invariants)
already gives the estate-wide invariants, and it is what makes "a rule with no
fixture, no test or no specification anchor" a condition a script can detect
rather than an absence nobody can see.

Two placements follow, and both are deliberate. A rule that carries an `E_`
code is a **registry entry** and never a schema refinement, because the code is
what a refusal fixture names and what CI asserts on; folding
`E_ALERT_CLASS_WITHOUT_SIGNAL` into the schema would refuse the same documents
and report `schema`, losing the code. A rule the specification gives **no**
code, conversely, stays in the schema, because inventing one would add a
sixty-second error code to a register that is being reduced.

What the registry deliberately does not hold is stated as data beside it: every
rule that needs a second document names the input it is missing. That is a
boundary rather than a backlog, and it is what keeps the next two metamodels
from re-deciding which half is theirs.

## Alternatives

| option | cost if taken | why rejected |
|---|---|---|
| Keep the diagram and the tables by hand; use Zod only as a runtime check | Nothing to build; the drawing stays free to be drawn well | The state this decision leaves: four statements of one language, drifting, with the drift invisible because each artefact is internally consistent |
| Make the domain model the metamodel and generate the wire schema from it | The language definition sits in the pure layer, where the rules already are | The authoring shape carries what the domain deliberately drops: a `provides` map, an omitted `engine` meaning `kv`, an absent block meaning none. Generating those from the domain would put the authoring vocabulary back into the core, which is exactly what [0066](0066-wire-shape-is-not-the-domain.md) separated |
| A separate metamodel declaration that both Zod and the domain are generated from | One source for three things instead of two | A third notation to learn, a code generator to maintain, and nothing in the estate yet needs the third target. Zod already produces the runtime check, the TypeScript type and the JSON Schema from one declaration |
| Put every rule in the schema as a refinement | One place to look, and no registry to keep | A refinement cannot carry a code, a context, a placement or an anchor, so [issue #44](https://github.com/JorisJonkers-dev/deploy-kit/issues/44) would have nothing to register and a refusal fixture could not name what refused it |
| Evaluate every rule the specification defines, reading whatever a rule needs | Every rule proven in one place | Half of them need the composed union, the Platform document or a pinned lock. Running them against a single document would report an absence as a violation, which is worse than not running them |

## Reversibility

Undo cost today: the registry collapses into the schema by folding each rule
into a refinement and deleting the codes, and the generated JSON Schema goes
back to being hand-written: hours, and no document changes either way.
Becomes irreversible once: a chapter's class diagram, field table or
vocabulary table is generated rather than hand-kept
([issue #40](https://github.com/JorisJonkers-dev/deploy-kit/issues/40)), since
un-generating them means writing four artefacts by hand again with no record
of what they used to say.

## Consequences

- A closed vocabulary is an enum in one file and nowhere else, so the chapter's
table is checked against it rather than kept in step with it. Paid by whoever
adds a vocabulary, once, in one place.
- A rule carries a code, a context, a placement and a chapter anchor whether or
not anything reads all four yet, which is one more field than today's code
needs. Paid per rule, and it is what
[issue #44](https://github.com/JorisJonkers-dev/deploy-kit/issues/44) hangs an
Essential OCL statement off without re-reading all 61 codes.
- A refusal fixture asserts a code rather than a message, and a fixture that
starts failing for two reasons at once fails the gate. Paid by whoever makes
a fixture stop isolating its defect, at the moment they do it.
- The next two metamodels (Platform Intent, the Resolved Deployment) are the
same shape, so their tickets copy a pattern rather than choosing one. Paid
once, here.
- A document family's concrete syntax is now the wire layer's, so the wire layer
imports a YAML reader. Paid in one line of `docs/architecture.md`'s layer
table, and it is the honest place for it: YAML is what the language is
written in, not an effect the domain needs a port for.
9 changes: 5 additions & 4 deletions docs/architecture-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ fails the gate, so the taxonomy cannot grow entries nothing stands behind.

## Rules

This ledger holds **60** rules, **19** of them pending.
This ledger holds **61** rules, **18** of them pending.

A row is enforced or pending, never both. An enforced row names its enforcer as
`kind:value`: `depcruise:` a rule in
Expand All @@ -84,10 +84,10 @@ moving a live rule to pending fails the gate rather than quietly retiring it.
| RULE-006 | layering | A use-case takes ports, never a concrete infrastructure implementation | `depcruise:application-takes-ports-not-adapters` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `application-takes-ports-not-adapters` |
| RULE-007 | layering | Infrastructure implements ports: it does not orchestrate, parse or render | `depcruise:infrastructure-implements-ports-only` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `infrastructure-implements-ports-only` |
| RULE-008 | layering | Nothing inside imports the CLI ring | `depcruise:nothing-depends-on-the-cli` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `nothing-depends-on-the-cli` |
| RULE-009 | layering | Shipped code never imports a test file or anything under `dist/` | pending (#30): no `src/` exists yet, so the rule has nothing to constrain and no fixture tree can be shaped like the real one | pending |
| RULE-009 | layering | Shipped code never imports a test file or anything under `dist/` | pending (#30): `src/` exists now, but nothing in the ruleset states this rule, and no shipped module imports a test file for a fixture to be shaped against | pending |
| RULE-010 | purity | The domain reaches for no filesystem, network, clock, environment, process or crypto: hashing arrives through a port | `depcruise:domain-reads-nothing-ambient` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `domain-reads-nothing-ambient` |
| RULE-011 | purity | An adapter renders only: documents in, attributed Deliverables out, with no ambient read and no outward import | `depcruise:adapters-render-only` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `adapters-render-only` |
| RULE-012 | purity | Environment, clock, randomness, console, spawning and synchronous filesystem calls are allowed only in the infrastructure and CLI rings | pending (#30): needs a probe file per ring to prove it fires, and the rings do not exist until the first module lands | pending |
| RULE-012 | purity | Environment, clock, randomness, console, spawning and synchronous filesystem calls are allowed only in the infrastructure and CLI rings | pending (#30): needs a probe file per ring to prove it fires, and the two rings it names, `infrastructure/` and `cli/`, are the two that still hold no module | pending |
| RULE-013 | purity | Exiting the process and writing to stdout or stderr happen only in `src/cli/boundary.ts`, the one file excluded from coverage | pending (#30): the boundary file is the subject of its own decision record, which lands with the CLI ring | pending |
| RULE-014 | graph | No import cycle between modules | `depcruise:no-circular` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `no-circular` |
| RULE-015 | graph | No orphan module: every module but an entry point is imported by something | `depcruise:no-orphans` | [test/boundary-contract.test.ts](../test/boundary-contract.test.ts) `no-orphans` |
Expand Down Expand Up @@ -118,7 +118,7 @@ moving a live rule to pending fails the gate rather than quietly retiring it.
| RULE-040 | toolchain | Coverage is a ratchet over an explicit include list, and no ignore comment exempts a line from it | `file:vitest.config.ts` | [test/harness.test.ts](../test/harness.test.ts) `an ignore is slack nobody decided` |
| RULE-041 | toolchain | No default export outside a tool configuration file | pending (#30): the tool configs are the only modules with exports today, and they are the exception the rule carves out | pending |
| RULE-042 | toolchain | Shipped code is ESM, and the one CommonJS file is the dependency-cruiser configuration that cannot be anything else | pending (#30): stated by `type: module` and enforced by hand until a lint over `src/` can read it | pending |
| RULE-043 | toolchain | Generated artifacts are committed, and CI fails when regenerating one produces a diff | pending (#30): nothing generates anything yet; the rule lands with the first generator | pending |
| RULE-043 | toolchain | Generated artifacts are committed, and CI fails when regenerating one produces a diff | `npm:lint:intent` | [test/intent-lint-negative.test.ts](../test/intent-lint-negative.test.ts) `differs from what the metamodel generates` |
| RULE-044 | cli | The CLI prints help on `--help` and `-h`, data on stdout and diagnostics on stderr, emits only data under `--json`, maps failures through one exit-code enum, honours `NO_COLOR`, and never prompts | pending (#30): the CLI ring does not exist, and each clause needs a process-level fixture to be worth a row of its own | pending |
| RULE-045 | registry | Every registered adapter satisfies the adapter port, attributes every Deliverable to itself, and renders deterministically | pending (#30): there is no registry and no adapter; the table-driven contract suite arrives with the first one | pending |
| RULE-046 | registry | Every estate-wide invariant is registered with its code, its spec anchor and its test, so an unregistered one is detectable rather than merely absent | pending (#30): the registry is a compiler module, and the enumeration it makes possible needs it to exist | pending |
Expand All @@ -136,6 +136,7 @@ moving a live rule to pending fails the gate rather than quietly retiring it.
| RULE-058 | gates | No em-dash enters tracked text outside `docs/mde/` and `CHANGELOG.md` | `file:test/emdash.test.ts` | [test/emdash.test.ts](../test/emdash.test.ts) `contains an em-dash` |
| RULE-059 | gates | A gate's npm script and the CI job that runs it land together, or the script is listed pending with a reason | `file:test/pipeline-wiring.test.ts` | [test/pipeline-wiring.test.ts](../test/pipeline-wiring.test.ts) `every script either runs in some workflow` |
| RULE-060 | gates | No committed secret matching the default gitleaks ruleset or this repository's own allowlist, checked locally by the same command CI runs | `npm:lint:secrets` | [test/secret-scan-contract.test.ts](../test/secret-scan-contract.test.ts) `secret scan: could not run` |
| RULE-061 | gates | Every Service Intent document parses against the metamodel, and a refusal fixture fails with exactly the code its `expect:` header names | `npm:lint:intent` | [test/intent-lint-negative.test.ts](../test/intent-lint-negative.test.ts) `no Service Intent documents` |

## Considered and rejected

Expand Down
Loading
Loading