From 44f408f0f0f1b210699d04cbd333dfd747f6aa2a Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Mon, 14 Sep 2026 11:33:19 +0200 Subject: [PATCH] feat: declare the Service Intent metamodel once and parse every example 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 --- .github/workflows/ci.yml | 10 +- README.md | 6 +- docs/adr/README.md | 1 + .../0105-the-wire-schema-is-the-metamodel.md | 115 + docs/architecture-rules.md | 9 +- docs/architecture.md | 9 +- docs/requirements.md | 3 +- package-lock.json | 46 +- package.json | 4 +- scripts/lint-boundaries.ts | 10 +- scripts/lint-intent.ts | 196 + spec/v1/10-service-intent.md | 23 +- spec/v1/16-dependencies.md | 1 - spec/v1/20-resolved-deployment.md | 4 +- .../10-service-intent-model.drawio.svg | 2 +- spec/v1/examples/auth/auth.domain.yml | 11 +- spec/v1/examples/data/data.domain.yml | 13 +- spec/v1/examples/data/rendered/README.md | 31 +- .../examples/knowledge/knowledge.domain.yml | 9 +- spec/v1/examples/knowledge/rendered/README.md | 16 +- .../negative/duplicate-service-id/README.md | 4 +- .../intent-a/knowledge.yml | 9 +- .../duplicate-service-id/intent-b/agents.yml | 9 +- .../duplicate-workload-name/README.md | 10 +- .../duplicate-workload-name/intent/agents.yml | 12 +- spec/v1/examples/refusals/README.md | 9 +- .../cutover-recreate-over-rwo.domain.yml | 2 - .../cutover-rolling-over-rwo.domain.yml | 2 - spec/v1/schemas/service-intent.schema.json | 3266 +++++++++++++++++ src/application/parse-service-intent.ts | 67 + src/domain/diagnostic.ts | 64 + src/domain/service-intent/model.ts | 287 ++ src/domain/service-intent/rules.ts | 473 +++ src/index.ts | 41 + src/wire/service-intent/env-file.ts | 168 + src/wire/service-intent/json-schema.ts | 41 + src/wire/service-intent/map.ts | 268 ++ src/wire/service-intent/read.ts | 93 + src/wire/service-intent/schema.ts | 483 +++ src/wire/service-intent/vocabularies.ts | 130 + test/diagram-model-consistency.test.ts | 155 +- test/intent-contract.test.ts | 785 ++++ test/intent-lint-negative.test.ts | 230 ++ test/simplification-contract.test.ts | 439 +-- vitest.config.ts | 31 +- 45 files changed, 7149 insertions(+), 448 deletions(-) create mode 100644 docs/adr/architecture/0105-the-wire-schema-is-the-metamodel.md create mode 100644 scripts/lint-intent.ts create mode 100644 spec/v1/schemas/service-intent.schema.json create mode 100644 src/application/parse-service-intent.ts create mode 100644 src/domain/diagnostic.ts create mode 100644 src/domain/service-intent/model.ts create mode 100644 src/domain/service-intent/rules.ts create mode 100644 src/index.ts create mode 100644 src/wire/service-intent/env-file.ts create mode 100644 src/wire/service-intent/json-schema.ts create mode 100644 src/wire/service-intent/map.ts create mode 100644 src/wire/service-intent/read.ts create mode 100644 src/wire/service-intent/schema.ts create mode 100644 src/wire/service-intent/vocabularies.ts create mode 100644 test/intent-contract.test.ts create mode 100644 test/intent-lint-negative.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7243875..5a929b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' @@ -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' diff --git a/README.md b/README.md index 20f556c..b283c78 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 43a80f3..eed1826 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/docs/adr/architecture/0105-the-wire-schema-is-the-metamodel.md b/docs/adr/architecture/0105-the-wire-schema-is-the-metamodel.md new file mode 100644 index 0000000..b30e5d0 --- /dev/null +++ b/docs/adr/architecture/0105-the-wire-schema-is-the-metamodel.md @@ -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. diff --git a/docs/architecture-rules.md b/docs/architecture-rules.md index ae75e81..5f4cd62 100644 --- a/docs/architecture-rules.md +++ b/docs/architecture-rules.md @@ -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 @@ -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` | @@ -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 | @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 89dfc61..21d45d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ below, and `npm run lint:boundaries` fails on a violation. |---|---|---| | `domain/` | layer 1 aggregates, layer 2 derivation, the ports the core declares | itself only | | `objects/` | the typed Kubernetes object model layer 3 builds | nothing | -| `wire/` | Zod schemas per document family per `schemaVersion`, and the mappers into the domain | `domain/`, `zod` | +| `wire/` | Zod schemas per document family per `schemaVersion`, the mappers into the domain, and the readers for the concrete syntax a document is written in | `domain/`, `zod`, `yaml` | | `adapters/` | the registered adapters, one directory each, shared code in `adapters/shared/` | `domain/`, `objects/`, `adapters/shared/` | | `application/` | the use-cases; orders derivation, performs no IO of its own | `domain/`, `wire/`, `adapters/`, `objects/` | | `infrastructure/` | port implementations: filesystem, `oras`, hashing, the serializer, the writer | `domain/`, `objects/` | @@ -188,7 +188,7 @@ clean tree is untested: nothing proves it would fail. ## Gates -Fourteen gates hold the structure, and each exists because its absence has already +Fifteen gates hold the structure, and each exists because its absence has already cost something in the generation this compiler replaces. Each runs as its own CI job, aggregated by one required check that fails when any gate job fails, is cancelled, or is skipped @@ -209,13 +209,14 @@ proves the two never drift apart. | requirements | `npm run lint:requirements` | a behaviour ledger row that no longer parses, names a missing or empty test, drifts from its stated count, or is cited by an id no row carries | | rules | `npm run lint:rules` | a [rule ledger](architecture-rules.md) row whose enforcer no longer exists, whose fixture no longer asserts on its witness, or that is pending with no ticket and no reason, and a rule the ruleset or the lint configuration enforces that no row claims | | docs | `npm run lint:docs` | a script, path, coverage number or Node version README.md or CONTRIBUTING.md name that no longer matches the repository | +| intent | `npm run lint:intent` | a Service Intent document that no longer parses against the metamodel, a refusal fixture that fails with a code other than the one its `expect:` header names, an env file whose placeholder is not in the grammar, or a committed JSON Schema that differs from what the metamodel generates | | tests | `npm run test:coverage` | behaviour, plus the coverage ratchet | | package contents | `node scripts/check-package-contents.ts` | `npm pack` shipping a file outside `docs/adr/` and `spec/`, the boundary the package's `files` field states but does not enforce on its own | | actionlint | a pinned `actionlint` binary | invalid workflow syntax, an undefined `${{ }}` expression, a shellcheck finding inside a `run:` step | | secret scan | `npm run lint:secrets` | a committed secret matching the default ruleset, or this repository's own allowlist entries | -Decisions, links, manifests, requirements, rules and docs share one CI job, -`contracts`: all six check a document against a rule rather than code +Decisions, links, manifests, requirements, rules, docs and intent share one CI +job, `contracts`: all seven check a document against a rule rather than code against a graph. Boundaries runs alone as `architecture`, because it is the one gate that speaks for `docs/architecture.md` itself rather than for a document beside it. diff --git a/docs/requirements.md b/docs/requirements.md index 4a1fbe8..6a13fe2 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -19,7 +19,7 @@ test file and holds at least one test; ids are unique; the count this document states matches the number of rows it holds; and every id cited anywhere in the tracked tree resolves to a row here. -This ledger holds **14** rows. The compiler's behaviours join it as they land. +This ledger holds **15** rows. The compiler's behaviours join it as they land. | id | a contributor or a consumer can rely on | proved by | |---|---|---| @@ -37,3 +37,4 @@ This ledger holds **14** rows. The compiler's behaviours join it as they land. | REQ-012 | A pull request's title, body and every commit in it carry no agent attribution: no Co-Authored-By trailer naming a coding agent, no "generated with" banner naming one, no link back to an agent session | [test/pr-title-contract.test.ts](../test/pr-title-contract.test.ts) | | REQ-013 | `npm run verify` runs the same secret scan CI runs, failing on a committed secret rather than only after a push | [test/secret-scan-contract.test.ts](../test/secret-scan-contract.test.ts) | | REQ-014 | Every rule this repository enforces has a ledger row with a greppable id and a fixture that proves it fires, and a rule not enforced yet is listed as pending with a ticket and a reason rather than dropped | [test/rules-contract.test.ts](../test/rules-contract.test.ts) | +| REQ-015 | Every Service Intent document in the repository is parsed against one declared metamodel: an accepted example conforms, a refusal fixture fails with exactly the code its `expect:` header names, and the committed JSON Schema is what that metamodel generates | [test/intent-contract.test.ts](../test/intent-contract.test.ts) | diff --git a/package-lock.json b/package-lock.json index f552b5f..3587471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "LicenseRef-JorisJonkers-Proprietary-1.0", "dependencies": { + "yaml": "2.9.1", "zod": "^4.5.4" }, "devDependencies": { @@ -430,9 +431,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -450,9 +448,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -470,9 +465,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -490,9 +482,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -510,9 +499,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -530,9 +516,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2156,9 +2139,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2180,9 +2160,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2204,9 +2181,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2228,9 +2202,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3189,6 +3160,21 @@ "node": ">=0.10.0" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 83d4e90..eeb649c 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,14 @@ "lint:requirements": "node scripts/lint-requirements.ts", "lint:rules": "node scripts/lint-rules.ts", "lint:docs": "node scripts/lint-docs.ts", + "lint:intent": "node scripts/lint-intent.ts", "lint:secrets": "node scripts/lint-secrets.ts", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", - "verify": "npm run lint && npm run format:check && npm run typecheck && npm run lint:adrs && npm run lint:links && npm run lint:manifests && npm run lint:requirements && npm run lint:rules && npm run lint:docs && npm run lint:secrets && npm run lint:boundaries && npm run test:coverage" + "verify": "npm run lint && npm run format:check && npm run typecheck && npm run lint:adrs && npm run lint:links && npm run lint:manifests && npm run lint:requirements && npm run lint:rules && npm run lint:docs && npm run lint:intent && npm run lint:secrets && npm run lint:boundaries && npm run test:coverage" }, "devDependencies": { "@eslint/js": "10.0.1", @@ -59,6 +60,7 @@ "gitops" ], "dependencies": { + "yaml": "2.9.1", "zod": "^4.5.4" } } diff --git a/scripts/lint-boundaries.ts b/scripts/lint-boundaries.ts index f82182e..dd95494 100644 --- a/scripts/lint-boundaries.ts +++ b/scripts/lint-boundaries.ts @@ -1,8 +1,10 @@ // Layer-boundary lint. The ruleset is .dependency-cruiser.cjs, which is where -// the hexagon is written down; this wrapper exists for one reason: the -// compiler has no src/ yet, and dependency-cruiser exits non-zero when asked -// to read a directory that does not exist. It skips loudly rather than passing -// silently, and starts enforcing the moment src/ lands. +// the hexagon is written down; this wrapper exists for one reason: +// dependency-cruiser exits non-zero when asked to read a directory that does +// not exist. It skips loudly rather than passing silently. This repository's +// own src/ landed with the Service Intent metamodel, so the gate enforces here +// rather than skipping; the skip branch is still reached by a fixture tree +// with no src/ at all, which is what its negative fixtures are. // // A library first: tests call lintBoundaries() in-process against fixture // trees, and `node scripts/lint-boundaries.ts [root]` is the command. diff --git a/scripts/lint-intent.ts b/scripts/lint-intent.ts new file mode 100644 index 0000000..b9540da --- /dev/null +++ b/scripts/lint-intent.ts @@ -0,0 +1,196 @@ +// Every Service Intent document in this repository, parsed against the one +// metamodel that defines the language. +// +// A worked example proves the model can express the estate; it cannot prove +// that the model refuses what it says it refuses. So this gate holds both +// halves to the same declaration: an accepted document parses, and a refusal +// fixture fails with **exactly** the code its `expect:` header names. A fixture +// that fails for a second reason is as much a defect as one that passes. +// +// Until this landed, the checks over these files read YAML by indentation: a +// six-space `- name:` was a Workload, unless it was an exposure entry, in which +// case it was not. Those checks could not see a closed vocabulary, could not +// see a union, and could not produce a document path. The parser can. +// +// The gate also regenerates the committed JSON Schema and fails on a diff, so +// the editor completion an author gets and the refusal the loader gives come +// from one declaration rather than two. +// +// A library first: tests call `lintIntent()` in-process against fixture trees, +// and `node scripts/lint-intent.ts [root]` is the command. +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import { parseServiceIntent } from "../src/application/parse-service-intent.ts"; +import type { Diagnostic } from "../src/domain/diagnostic.ts"; +import { parseEnvFile } from "../src/wire/service-intent/env-file.ts"; +import { + JSON_SCHEMA_PATH, + serviceIntentJsonSchemaText, +} from "../src/wire/service-intent/json-schema.ts"; +import { isEntrypoint } from "./lib/entrypoint.ts"; +import { processOutput, type GateOutput } from "./lib/output.ts"; + +const REPOSITORY = join(import.meta.dirname, ".."); + +/** + * Directories under the worked examples that hold something other than layer-1 + * intent: rendered Deliverables, the Platform document (#41's metamodel), and + * the workflow fixtures. + */ +const NOT_INTENT = new Set(["rendered", "workflows", "platform"]); + +/** `expect: [, why]`, the fixture metadata chapter 10's refusals carry. */ +const EXPECT = /^expect:[ \t]*([^,\n]+)/m; + +/** Every file under `dir` whose name ends in `suffix`, sorted, relative to `root`. */ +function under(root: string, suffix: string): string[] { + const examples = join(root, "spec", "v1", "examples"); + if (!existsSync(examples)) return []; + return readdirSync(examples, { recursive: true, encoding: "utf8" }) + .filter((rel) => rel.endsWith(suffix)) + .filter((rel) => !rel.split(sep).some((part) => NOT_INTENT.has(part))) + .map((rel) => join(examples, rel)) + .filter((path) => statSync(path).isFile()) + .sort(); +} + +/** Every authored Service Intent document: the accepted set and the fixtures. */ +export function intentDocuments(root: string): string[] { + return under(root, ".yml"); +} + +/** Every authored env file: the second artefact of layer 1 (0011). */ +export function intentEnvFiles(root: string): string[] { + return under(root, ".env"); +} + +/** + * What a document says should happen to it. An accepted worked example carries + * no header at all, and the absence is the claim: it parses. + */ +export function expectationOf(text: string): string { + return EXPECT.exec(text)?.[1]?.trim() ?? "accepted"; +} + +/** + * The `expect:` header is fixture metadata and "is not part of the Domain + * schema" (spec/v1/examples/refusals/README.md), so it is removed before the + * document is parsed. Removing the line rather than the text keeps every other + * line at its own number, which a YAML offset still points into. + */ +export function withoutExpectation(text: string): string { + return text.replace(/^expect:.*$/m, ""); +} + +/** What actually happened to a document: `accepted`, `schema`, `syntax`, or a code. */ +function outcomeOf(diagnostics: readonly Diagnostic[]): string { + const first = diagnostics[0]; + if (first === undefined) return "accepted"; + // A refusal fixture isolates one defect, so one outcome is the whole answer. + // Several distinct ones mean the fixture has stopped isolating, which the + // caller reports as the mismatch it is. + const distinct = [...new Set(diagnostics.map((d) => d.code))]; + return distinct.length === 1 ? (distinct[0] as string) : distinct.join(" + "); +} + +export interface IntentLintResult { + readonly documents: number; + readonly envFiles: number; + readonly errors: readonly string[]; +} + +/** One diagnostic, rendered for a human: the file, the path inside it, the code. */ +function render(root: string, diagnostic: Diagnostic): string { + return ( + ` ${relative(root, diagnostic.document)}: ` + + `${diagnostic.at}: ${diagnostic.code}: ${diagnostic.message}` + ); +} + +/** Parse every Service Intent document and env file under `root`. */ +export function lintIntent(root: string): IntentLintResult { + const errors: string[] = []; + const documents = intentDocuments(root); + if (documents.length === 0) + errors.push("intent lint: no Service Intent documents found"); + + for (const file of documents) { + const text = readFileSync(file, "utf8"); + const expected = expectationOf(text); + const result = parseServiceIntent(withoutExpectation(text), file); + const diagnostics = result.ok ? [] : result.diagnostics; + const actual = outcomeOf(diagnostics); + if (actual === expected) continue; + errors.push( + `${relative(root, file)}: expected ${expected}, got ${actual}`, + ...diagnostics.map((diagnostic) => render(root, diagnostic)), + ); + } + + const envFiles = intentEnvFiles(root); + for (const file of envFiles) { + const { diagnostics } = parseEnvFile(readFileSync(file, "utf8"), file); + errors.push(...diagnostics.map((diagnostic) => render(root, diagnostic))); + } + + const committed = join(root, JSON_SCHEMA_PATH); + const generated = serviceIntentJsonSchemaText(); + if (!existsSync(committed)) + errors.push( + `${JSON_SCHEMA_PATH}: not committed; the metamodel generates it, ` + + "an editor reads it, and `node scripts/lint-intent.ts --write` writes it", + ); + else if (readFileSync(committed, "utf8") !== generated) + errors.push( + `${JSON_SCHEMA_PATH}: differs from what the metamodel generates; ` + + "regenerate it with `node scripts/lint-intent.ts --write` rather " + + "than editing it by hand", + ); + + return { documents: documents.length, envFiles: envFiles.length, errors }; +} + +/** + * Write the generated JSON Schema over the committed copy. + * + * The gate itself never writes: a CI step that mutates the tree turns a diff + * into a silent repair. This is the developer command behind it, + * `node scripts/lint-intent.ts --write`, named in the failure the gate prints. + */ +export function writeJsonSchema(root: string): string { + const target = join(root, JSON_SCHEMA_PATH); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, serviceIntentJsonSchemaText()); + return target; +} + +/** Lint the tree named by argv[0], or this repository. `--write` regenerates. */ +export function main( + argv: readonly string[], + output: GateOutput = processOutput, +): number { + const write = argv.includes("--write"); + const root = argv.find((arg) => !arg.startsWith("--")) ?? REPOSITORY; + if (write) + output.out(`intent lint: wrote ${relative(root, writeJsonSchema(root))}\n`); + const { documents, envFiles, errors } = lintIntent(root); + if (errors.length > 0) { + output.err(`${errors.join("\n")}\n`); + return 1; + } + output.out( + `intent lint: ${documents} Service Intent document(s) and ` + + `${envFiles} env file(s) conform to the metamodel.\n`, + ); + return 0; +} + +if (isEntrypoint(import.meta.url, process.argv[1])) + process.exitCode = main(process.argv.slice(2)); diff --git a/spec/v1/10-service-intent.md b/spec/v1/10-service-intent.md index d59d0a0..7885b11 100644 --- a/spec/v1/10-service-intent.md +++ b/spec/v1/10-service-intent.md @@ -100,7 +100,11 @@ graded by full below, and it is the only composite on the Workload that is **required**. Env files hang off the **Workload**, not the Service ([0011](../../docs/adr/model/0011-configuration-env-files-per-workload.md)), and so -does `provides`: a port is a property of a process. `exposure` hangs off the +does `provides`: a port is a property of a process. A Workload has **zero or +more** of them, because a Workload that needs no configuration of its own +needs no file to say so: `auth-ui`, `platform-rabbitmq` and `platform-valkey` +have none in the worked set, and a file holding nothing but comments would be +a third way of writing the same absence. `exposure` hangs off the **Service**, because a hostname is a property of the product rather than of any one process, and one hostname routes into two of them. @@ -322,8 +326,8 @@ Vault role are called (chapter 16). never a digest here. `lifecycle` is `service` or `job`. Not `deployment` / `statefulset` / `job`, -because those are mechanisms; the object kind derives from `lifecycle`, `stateful` -and `volumes`. +because those are mechanisms; the object kind derives from `lifecycle` and +`volumes`. `runtime` selects the Runtime Profile: `jvm`, `python`, `node`, `static`, `none`. `none` is correct for a third-party image and injects no profile values at all. @@ -1085,7 +1089,8 @@ The rendered route carries that ordering explicitly, so what the document says is what the edge does, the ordering is visible in a diff, and a proxy that tie-breaks differently changes nothing. -Two routes on one host with the same `path` and `match` are `E_DUPLICATE_ROUTE` +Two routes on one host with the same `path` and `match` are +`E_DUPLICATE_ROUTE_MATCH` ([chapter 40](40-composition.md#references)). There is no correct interpretation of the pair: whichever wins is decided by a string comparison inside a proxy, which no author can see in the document. @@ -1725,7 +1730,7 @@ this chapter owns: | a literal secret value in an env file or an Asset | `E_RAW_SECRET` | composition | | `delivery: env` with `rotation.tolerates: reload` | `E_ENV_CANNOT_RELOAD` | schema | | an illegal access × delivery cell | `E_ILLEGAL_DELIVERY_FOR_ACCESS` | schema | -| a non-KV grant with `delivery: env` or `file` | `E_NON_KV_DELIVERY` | schema | +| a `transit` grant with `delivery: env` or `file` | `E_NON_KV_DELIVERY` | schema | | `delivery: env` or `file` against a Context without `secretsEncryption` | `E_SECRETS_AT_REST_REQUIRED` | render | `keys: ['*']` has no error code because it is not in the grammar: a document @@ -1741,7 +1746,7 @@ startupBudget: 600s # knowledge-api: JVM cold start measured at ~250-300s cutover: rolling # required: continuity during the cutover, or an accepted stop-then-start ``` -Derived from these plus `stateful`, `placement` and `volumes`: rollout strategy, +Derived from these plus `placement` and `volumes`: rollout strategy, surge and unavailability, startup probe period and threshold, the progress deadline, and the health-gate deadline the Service's switchover waits on. @@ -1883,7 +1888,7 @@ declaring site is fixed: | an image tag or digest | the images lock | | a `ports` list, or a port as a string | an integer at its point of use | | `RollingUpdate`, `maxSurge`, `progressDeadlineSeconds` | derived from `cutover`, `startupBudget` and the declared volumes | -| `statefulset` / `deployment` | derived from `lifecycle` + volumes | +| `statefulset` / `deployment` | derived from `lifecycle` and `volumes` | | a liveness probe with no path | state it, or use `tcp`, or `probes: none` | | a Dependency Coordinate as a literal | `${dependency:…}` | | a Runtime Profile key in an env file | `runtime`: the model injects them, and an exceptional value is not a layer-1 concept | @@ -2008,7 +2013,6 @@ classDiagram +Engine engine +Duration startupBudget +Cutover cutover - +bool stateful +Path[] writablePaths } class Capacity { @@ -2052,7 +2056,6 @@ classDiagram class Asset { +Path from +Path mountAt - +map substitute } class Volume { +string claim @@ -2128,7 +2131,7 @@ classDiagram Route ..> Surface : resolves by name DependencyEdge ..> Surface : resolves by name - Workload "1" *-- "1..*" EnvFile : env per workload + Workload "1" *-- "0..*" EnvFile : env per workload EnvFile "1" *-- "0..*" Placeholder : resolves Service "1" *-- "0..*" Grant : secrets diff --git a/spec/v1/16-dependencies.md b/spec/v1/16-dependencies.md index 2f8931e..5357881 100644 --- a/spec/v1/16-dependencies.md +++ b/spec/v1/16-dependencies.md @@ -631,7 +631,6 @@ flowchart LR d_bud["startupBudget"] d_cut["cutover
rolling | recreate"] d_life["lifecycle"] - d_sf["stateful"] d_vol["volumes + durability"] d_plc["placement
hard dimensions:
memory, cpu, arch,
site, disk, gpu,
capabilities"] d_rep["replicas
count + reason"] diff --git a/spec/v1/20-resolved-deployment.md b/spec/v1/20-resolved-deployment.md index c5f8407..c1d32d9 100644 --- a/spec/v1/20-resolved-deployment.md +++ b/spec/v1/20-resolved-deployment.md @@ -138,7 +138,7 @@ field's placement link to this anchor rather than copying rows. | workload `name` | Service | unique, checked | unique within the **domain**; `E_DUPLICATE_WORKLOAD_NAME`, and it names the derived identity | | `provides` surface names and ports | Service | no contention | declared on the Workload, because a port is a property of a process; written once, there | | `dependsOn` edges | Service | no contention | provider, surface, necessity ([chapter 16](16-dependencies.md#dependency-edges)) | -| `image`, `runtime`, `lifecycle`, `stateful` | Service | no contention | what the Workload is | +| `image`, `runtime`, `lifecycle` | Service | no contention | what the Workload is | | env files, `assets` | Service | no contention | per Workload; derived values appear only as placeholders | | `secrets` grants: `path`, `keys`, `access`, `delivery`, `rotation` | Service | no contention to declare | per Service and never raised; the *path* is arbitrated (below), what a Service asks of a path is its own | | `exposure[].name` | Service | unique, checked | required; unique **within the Service**, `E_DUPLICATE_EXPOSURE_NAME` at composition. It is the half `${exposure:.#url}` addresses | @@ -184,7 +184,7 @@ field's placement link to this anchor rather than copying rows. | container probe timings | derived | - | the startup probe's target from the **liveness** declaration and its period from `startupBudget`; readiness and liveness cadence from the Platform Intent's probe policy ([0088](../../docs/adr/model/0088-startup-probe-targets-liveness.md)) | | `progressDeadlineSeconds` | derived | - | from `startupBudget` | | rollout strategy, surge, unavailability | derived | - | from `cutover` and `volumes`; `cutover: rolling` over an RWO volume is `E_CUTOVER_UNHONOURABLE`, not a silent downgrade | -| object kind | derived | - | from `lifecycle`, `stateful` and `volumes` | +| object kind | derived | - | from `lifecycle` and `volumes` | | the Service's release-gate deadline | derived | - | `max` over the Service's Workloads of `progressDeadlineSeconds` ([The release gate](#the-release-gate)) | | the object label set | derived | - | fixed, from Workload name, Service Id and the images lock ([chapter 10](10-service-intent.md#the-label-set)) | | Secret and VSO sync objects | derived | - | from grants with `delivery: env` or `file`, plus `rolloutRestartTargets` from `rotation`; a grant with `delivery: self` and `tolerates: reload` derives **no** restart target, which is what makes its rotation zero-downtime ([chapter 10](10-service-intent.md#zero-downtime-rotation)) | diff --git a/spec/v1/diagrams/10-service-intent-model.drawio.svg b/spec/v1/diagrams/10-service-intent-model.drawio.svg index 590cfd6..a2594d7 100644 --- a/spec/v1/diagrams/10-service-intent-model.drawio.svg +++ b/spec/v1/diagrams/10-service-intent-model.drawio.svg @@ -1,4 +1,4 @@ -Asset+ Path from+ Path mountAt+ map substituteCapacity+ int count+ string reasonDependencyEdge+ ServiceId service+ string surface+ bool requiredDiskRequest+ Media[] mediaDomain+ DomainName domain+ string owner+ SemVer schemaVersionEnvFile+ ClusterTarget cluster+ dotenv entriesExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicyGpuRequest+ GpuClassName class+ Quantity memoryGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeObservability+ AlertClass alertClassPlaceholder+ PlaceholderKind kind+ string sourcePlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilitiesProbe+ Path path+ int port+ int tcpRotation+ Tolerance tolerates+ Duration maxAgeRoute+ Path path+ Match match+ string workload+ string surface+ Audience audience+ Path redirectToScrape+ string workload+ string surface+ Path pathService+ ServiceId idSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durabilityWorkload+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ bool stateful+ Path[] writablePaths1..* services1..* workloads0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes1..* env per workload0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» \ No newline at end of file +Asset+ Path from+ Path mountAtCapacity+ int count+ string reasonDependencyEdge+ ServiceId service+ string surface+ bool requiredDiskRequest+ Media[] mediaDomain+ DomainName domain+ string owner+ SemVer schemaVersionEnvFile+ ClusterTarget cluster+ dotenv entriesExposure+ ExposureName name+ Fqdn host+ Audience audience+ ContentPolicy contentPolicyGpuRequest+ GpuClassName class+ Quantity memoryGrant+ SecretEngine engine+ VaultPath path+ string[] keys+ AccessTier access+ string role+ string key+ TransitOp[] operations+ Delivery delivery+ Path mountAt+ FileMode fileModeObservability+ AlertClass alertClassPlaceholder+ PlaceholderKind kind+ string sourcePlacement+ Quantity memory+ Quantity cpu+ Arch[] arch+ Site site+ Capability[] capabilitiesProbe+ Path path+ int port+ int tcpRotation+ Tolerance tolerates+ Duration maxAgeRoute+ Path path+ Match match+ string workload+ string surface+ Audience audience+ Path redirectToScrape+ string workload+ string surface+ Path pathService+ ServiceId idSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durabilityWorkload+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ Path[] writablePaths1..* services1..* workloads0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes0..* env per workload0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» \ No newline at end of file diff --git a/spec/v1/examples/auth/auth.domain.yml b/spec/v1/examples/auth/auth.domain.yml index 857973c..568f531 100644 --- a/spec/v1/examples/auth/auth.domain.yml +++ b/spec/v1/examples/auth/auth.domain.yml @@ -20,6 +20,14 @@ # platform/env/auth-api/base.env -- reproduced as examples/auth-api.base.env # platform/env/auth-ui/base.env +# The document says which language it is written in. `intent.jorisjonkers.dev` +# is layer 1's own namespace, deliberately not the one three mutually +# incompatible documents already shared (docs/adr/model/0003-three-layer-meta-model.md); +# `kind` names the authored document, and chapter 40's `IntentFragment` is the +# envelope that publishes it. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain + # The DATA MODEL's own semver, not the toolkit package's version # (docs/adr/model/0039-artifact-schema-versioning.md). It starts at 1.0.0 and is # bumped only when the model changes, so a literal here is legitimate and does @@ -27,7 +35,8 @@ # toolkit; the composition lock records the exact versions that ran. schemaVersion: 1.0.0 -# The header is three fields, and `owner` is the ONLY one raised to the domain. +# Five header fields: two naming the language, the model semver, the domain +# and `owner`, which is the ONLY one raised to the domain. # `observability` stays per Service -- raising it makes a domain page as loudly as # its loudest member -- and so does `secrets`, because a domain-level grant # hands every Service in the file a reader slot on the whole path diff --git a/spec/v1/examples/data/data.domain.yml b/spec/v1/examples/data/data.domain.yml index 5bf07f6..42815c2 100644 --- a/spec/v1/examples/data/data.domain.yml +++ b/spec/v1/examples/data/data.domain.yml @@ -25,6 +25,14 @@ # Companion env file (per Workload, like every env file): # platform/env/postgres/base.env -- examples/platform-postgres.base.env +# The document says which language it is written in. `intent.jorisjonkers.dev` +# is layer 1's own namespace, deliberately not the one three mutually +# incompatible documents already shared (docs/adr/model/0003-three-layer-meta-model.md); +# `kind` names the authored document, and chapter 40's `IntentFragment` is the +# envelope that publishes it. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain + # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). schemaVersion: 1.0.0 @@ -171,10 +179,11 @@ services: # the owner states the stop-then-start they already have. cutover: recreate - stateful: true volumes: - claim: postgres-data mountAt: /var/lib/postgresql/data + size: 100Gi # a hard dimension, matched against the + # node contract's disks[].usable_gib durability: irreplaceable # The one fact only the owning Service knows # (docs/adr/model/0015-durability-class-per-volume.md); schedule, sweep @@ -325,7 +334,6 @@ services: # would be E_CUTOVER_UNHONOURABLE, not a silent Recreate. cutover: recreate - stateful: true volumes: - claim: rabbitmq-data mountAt: /var/lib/rabbitmq @@ -382,7 +390,6 @@ services: # stop-then-start costs a cold cache and nothing else. cutover: recreate - stateful: true volumes: - claim: valkey-data mountAt: /data diff --git a/spec/v1/examples/data/rendered/README.md b/spec/v1/examples/data/rendered/README.md index d60b008..e64ed2f 100644 --- a/spec/v1/examples/data/rendered/README.md +++ b/spec/v1/examples/data/rendered/README.md @@ -80,7 +80,7 @@ contributes routes and exposures to both; it owns neither object. | `namespace.yaml` | `kubernetes` | `domain: data` → `data-system` | Nothing about the object. Which of the three Service directories owns it ([G-20](#g-20)) | | `networkpolicy.yaml` | **none**: `networking` is not a registered adapter ([G-35](#g-35)) | the non-authorable baseline; `podSelector: {}` is per domain | Which Service directory owns it ([G-20](#g-20)); the DNS selectors ([G-21](#g-21)); that it cannot be loaded non-enforcing ([G-29](#g-29)) | | `kustomization.yaml` | `kubernetes` | the Service list of the domain | Nothing it needs. It groups; it does not gate, and it does not separate ([G-01](#g-01)) | -| `apps/platform-postgres/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides` (×2 surfaces), `placement` (memory, cpu, arch, disk), `hardening` + its exception, `sidecars`, `probes` (tcp), `startupBudget`, `cutover`, `stateful`, `volumes`, `assets`, env file | the digest and repository path ([G-03](#g-03)); whether `stateful` means StatefulSet ([G-04](#g-04)); `replicas` ([G-05](#g-05)); a node label for `disk` ([G-06](#g-06)); the PV binding that actually places it ([G-07](#g-07)); the Asset's content hash ([G-08](#g-08)); which container gets which env key ([G-09](#g-09)); a sidecar-scoped identity and restart target ([G-02](#g-02)); a UID and an fsGroup ([G-15](#g-15)); where the hardening controls land ([G-16](#g-16)); the label set ([G-13](#g-13)) | +| `apps/platform-postgres/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides` (×2 surfaces), `placement` (memory, cpu, arch, disk), `hardening` + its exception, `sidecars`, `probes` (tcp), `startupBudget`, `cutover`, `volumes`, `assets`, env file | the digest and repository path ([G-03](#g-03)); whether a volume means StatefulSet ([G-04](#g-04)); `replicas` ([G-05](#g-05)); a node label for `disk` ([G-06](#g-06)); the PV binding that actually places it ([G-07](#g-07)); the Asset's content hash ([G-08](#g-08)); which container gets which env key ([G-09](#g-09)); a sidecar-scoped identity and restart target ([G-02](#g-02)); a UID and an fsGroup ([G-15](#g-15)); where the hardening controls land ([G-16](#g-16)); the label set ([G-13](#g-13)) | | `apps/platform-postgres/serviceaccount.yaml` | `kubernetes` | workload `name`, `domain` | `automountServiceAccountToken` ([G-14](#g-14)); any Role/RoleBinding ([G-35](#g-35)) | | `apps/platform-postgres/configmap.yaml` | `kubernetes` | `assets[0].from`, `.mountAt`, `.onChange` | the object's name: the content hash has no input here ([G-08](#g-08)); the file's 54 lines, which live in the Service repository; whether env literals belong here at all ([G-10](#g-10)); the `init-databases.sh` catalog ([G-11](#g-11)) | | `apps/platform-postgres/pvc.yaml` | `kubernetes` | `volumes[].claim`, `.durability` | `resources.requests.storage`: **the object does not apply without it** ([G-17](#g-17)); the durability annotation key ([G-18](#g-18)); the backup job the class demands ([G-12](#g-12)) | @@ -88,13 +88,13 @@ contributes routes and exposures to both; it owns neither object. | `apps/platform-postgres/networkpolicy.yaml` | **none** ([G-35](#g-35)) | inbound `dependsOn` edges over the union (ingress), `scrape` (ingress), the grant set (egress), the baseline | five of the eight consumers ([G-22](#g-22)); that the Secret Store rule is wrong for `delivery: env` ([G-23](#g-23)); the platform-component selectors ([G-21](#g-21)) | | `apps/platform-postgres/vso.yaml` | `vso` | `secrets` (path, access, delivery, rotation), workload `name` | the object-naming rule ([G-24](#g-24)); one VaultAuth per estate vs per Workload ([G-25](#g-25)); the Vault policy and auth role, which nothing produces ([G-26](#g-26)); that the restart target is the database ([G-02](#g-02)) | | `apps/platform-postgres/kustomization.yaml` | `kubernetes` | the Service's emitted file set | who applies `vso.yaml` ([G-24](#g-24)) | -| `apps/platform-rabbitmq/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides` (×3), `placement` (memory, cpu), `hardening`, `probes`, `startupBudget`, `cutover`, `stateful`, `volumes` | the digest ([G-03](#g-03)); object kind ([G-04](#g-04)); `replicas` ([G-05](#g-05)); that the locked digest may not run on 3 of its 7 eligible nodes ([G-27](#g-27)); UID/fsGroup ([G-15](#g-15)); its env file, which the example set omits ([G-28](#g-28)) | +| `apps/platform-rabbitmq/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides` (×3), `placement` (memory, cpu), `hardening`, `probes`, `startupBudget`, `cutover`, `volumes` | the digest ([G-03](#g-03)); object kind ([G-04](#g-04)); `replicas` ([G-05](#g-05)); that the locked digest may not run on 3 of its 7 eligible nodes ([G-27](#g-27)); UID/fsGroup ([G-15](#g-15)); its env file, which the example set omits ([G-28](#g-28)) | | `apps/platform-rabbitmq/serviceaccount.yaml` | `kubernetes` | workload `name`, `domain` | `automountServiceAccountToken` ([G-14](#g-14)) | | `apps/platform-rabbitmq/pvc.yaml` | `kubernetes` | `volumes[].claim`, `.durability: recoverable` | `storage` ([G-17](#g-17)); the annotation key ([G-18](#g-18)); the backup job and sweep ([G-12](#g-12)) | | `apps/platform-rabbitmq/servicemonitor.yaml` | `prometheus` | `observability.scrape {workload: rabbitmq, surface: metrics, path}`, `provides` | cadence from the Platform document | | `apps/platform-rabbitmq/networkpolicy.yaml` | **none** ([G-35](#g-35)) | inbound edges, `exposure` (ingress from the tier), `scrape`, the baseline | consumers outside the union ([G-22](#g-22)); the edge selectors ([G-21](#g-21)) | | `apps/platform-rabbitmq/kustomization.yaml` | `kubernetes` | the emitted file set | - | -| `apps/platform-valkey/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides`, `placement` (memory, cpu), `hardening`, `probes`, `startupBudget`, `cutover`, `stateful`, `volumes` | the digest ([G-03](#g-03)); object kind ([G-04](#g-04)); `replicas` ([G-05](#g-05)); architecture vs digest ([G-27](#g-27)); its env file ([G-28](#g-28)) | +| `apps/platform-valkey/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides`, `placement` (memory, cpu), `hardening`, `probes`, `startupBudget`, `cutover`, `volumes` | the digest ([G-03](#g-03)); object kind ([G-04](#g-04)); `replicas` ([G-05](#g-05)); architecture vs digest ([G-27](#g-27)); its env file ([G-28](#g-28)) | | `apps/platform-valkey/serviceaccount.yaml` | `kubernetes` | workload `name`, `domain` | `automountServiceAccountToken` ([G-14](#g-14)) | | `apps/platform-valkey/pvc.yaml` | `kubernetes` | `volumes[].claim`, `.durability: reconstructible` | `storage` ([G-17](#g-17)); the annotation key ([G-18](#g-18)). **No backup job, and that is correct** | | `apps/platform-valkey/networkpolicy.yaml` | **none** ([G-35](#g-35)) | one inbound edge, the baseline | the same union problem, at its sharpest ([G-22](#g-22)) | @@ -141,7 +141,7 @@ Reconcile Unit is derived as `apps-` (chapter 20), it is rendered by three independent releases; - the health timeout class is taken as *the strongest class across a Service's Workloads*, and no chapter says what happens when three **Services** share one - Kustomization. All three here are `stateful`, so 10m, and the disagreement does + Kustomization. All three here hold a volume, so 10m, and the disagreement does not surface, and it will on the first domain that mixes classes. Nothing in the rendered tree records which objects belong to which release. The @@ -211,9 +211,11 @@ them; this render puts the warning in `pvc.yaml` where they will meet it. ## G-04: Deployment or StatefulSet, and why this renders Deployment -`platform-postgres` declares `stateful: true`, `cutover: recreate`, and one -`ReadWriteOnce` volume. Chapter 20 derives the object kind from `lifecycle`, -`stateful` and `volumes` and states no function over the three. +`platform-postgres` declares `cutover: recreate` and one `ReadWriteOnce` +volume. When this gap was written it also declared a `stateful: true` boolean, +and chapter 20 derived the object kind from `lifecycle`, that boolean and +`volumes` while stating no function over the three. The boolean is now deleted +and the kind derives from `lifecycle` and `volumes`. **Rendered: `Deployment` with `strategy: {type: Recreate}`.** The reasoning, in order: @@ -224,18 +226,19 @@ order: `Recreate` side. `cutover: recreate` records the same fact from the author's side, and the two agree here, and nothing checks that they always will. The current renderer reads an authored enum and inspects no volume, which is the - trap: a stateful Workload whose author forgets it gets `maxSurge: 1` against an - RWO volume, appears to work on one node, and wedges the first time a second - worker exists. + trap: a Workload holding a volume whose author declared the wrong cutover gets + `maxSurge: 1` against an RWO volume, appears to work on one node, and wedges + the first time a second worker exists. `E_CUTOVER_UNHONOURABLE` is what now + refuses the pair, and it reads the declared volumes rather than a boolean. - **`StatefulSet` buys nothing available here.** Its distinguishing feature is `volumeClaimTemplate`, which chapter 10 forbids outright, for a template ties the claim to the Workload's name, so a rename orphans the data. Its other effects ( a headless Service, ordinal pod names, ordered rollout, stable network identity) are declared by nothing in layer 1 and consumed by nothing in this domain, which addresses its provider by the Workload's Service name. -- **So `stateful: true` selects the 10m Flux health timeout class and nothing - else about this object.** That is the whole of its effect, and it is not what a - reader of the field expects. +- **So the `stateful` boolean selected the 10m Flux health timeout class and + nothing else about this object.** That was the whole of its effect, and it was + not what a reader of the field expected, which is why the field is deleted. The same reasoning renders `platform-rabbitmq` and `platform-valkey` as Deployments. The cost is uniform and stated: **every roll of the estate's @@ -293,7 +296,7 @@ both trace to that declaration. The id is not reused and nothing is renumbered. | [G-01](#g-01) | **Three Services release independently and reconcile as one unit.** Detailed above. Two derivations over one domain file disagree about what a unit is | | G-02 | **A sidecar has no identity of its own, and no restart target.** [0064](../../../../../docs/adr/model/0064-sidecars-are-workload-vocabulary.md) grades the field: `postgres-exporter` now declares its own `memory`, `cpu` and `hardening`, those render as container-level `resources` and `securityContext`, and eligibility sums both containers (2112Mi, not 2Gi). Two things it deliberately does not answer. **Identity**: [0024](../../../../../docs/adr/model/0024-identity-per-workload.md) puts the ServiceAccount on the Workload, and a pod has one, so a grant scoped "to the exporter" is in practice held by the database container beside it, the boundary is a comment, not a control. **Restart target**: `rotation: {tolerates: restart}` on the exporter's grant derives `{kind: Deployment, name: postgres}`, which under `Recreate` takes the datastore down to rotate a read-only connection string. A sidecar-scoped restart target is not expressible. `probes` staying on the Workload is a decision rather than a gap: a failing exporter must not hold its Workload out of service | | G-03 | **The image digests here are illustrative, and the repository paths are the lock's.** Three third-party aliases, `postgres` → pgvector, `rabbitmq`, `valkey`, plus `postgres-exporter`, resolve through an images lock this example set does not reproduce. Nothing in layer 1 names a registry, so `docker.io/pgvector/pgvector` and `quay.io/prometheuscommunity/postgres-exporter` are the lock's mapping standing in for a lock entry. Third-party is *not* a reason to float a tag: `pgvector/pgvector:pg17` moves on every upstream build and this Workload is `Recreate` on an RWO volume, so any reschedule is a fresh pull | -| [G-04](#g-04) | **Deployment or StatefulSet is not derived, it is chosen.** Detailed above. `stateful: true` ends up selecting only a health timeout class | +| [G-04](#g-04) | **Deployment or StatefulSet is not derived, it is chosen.** Detailed above. The `stateful` boolean ended up selecting only a health timeout class, and is deleted | | G-05 | **`replicas` has no input in this domain.** The rule is "from `minAvailable`, bounded by the size of the eligible node set". No Workload here declares `minAvailable`, the field is ungraded, and the eligible sets are four and seven. `1` is rendered because an RWO volume forces it, so the number is right and the derivation that is supposed to produce it never ran. The same absence removes every PDB in the domain | | G-06 | **The `disk` dimension has no node label.** `disk` is matched against `disks[].media` and `disks[].usable_gib` in the node contract, and no label expresses "carries a disk of media nvme or ssd with at least 100Gi usable", and a per-media boolean could express `media` as two ORed `nodeSelectorTerms` and could not express `size` at all. This render materialises the computed eligible set as `kubernetes.io/hostname In [four nodes]`. That is the set exactly, and it hard-codes four node names into the tree: a fifth node satisfying the dimension is not admitted until someone re-renders. Whether that is correct (a new node *is* a new node contract, hence a new render) or a defect is undecided. `arch` has the opposite problem, two label sources, `kubernetes.io/arch` and the node contract's 110 labels, 55 of them under a prefix named after an archived repository | | [G-07](#g-07) | **`disk` filters the first placement; the PV binding wins thereafter, and the tree says neither.** Detailed above, including `E_DISK_BINDING_CONFLICT` and the two `size` values that are not the same fact | diff --git a/spec/v1/examples/knowledge/knowledge.domain.yml b/spec/v1/examples/knowledge/knowledge.domain.yml index 3fb5de6..3d5adf1 100644 --- a/spec/v1/examples/knowledge/knowledge.domain.yml +++ b/spec/v1/examples/knowledge/knowledge.domain.yml @@ -20,6 +20,14 @@ # platform/env/knowledge-ingest-worker/base.env # -- reproduced as examples/knowledge-ingest-worker.base.env +# The document says which language it is written in. `intent.jorisjonkers.dev` +# is layer 1's own namespace, deliberately not the one three mutually +# incompatible documents already shared (docs/adr/model/0003-three-layer-meta-model.md); +# `kind` names the authored document, and chapter 40's `IntentFragment` is the +# envelope that publishes it. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain + # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). schemaVersion: 1.0.0 @@ -283,7 +291,6 @@ services: # docs/adr/model/0078-engine-is-workload-vocabulary.md). engine: files - stateful: true volumes: - claim: knowledge-vault-clone mountAt: /var/lib/knowledge-vault diff --git a/spec/v1/examples/knowledge/rendered/README.md b/spec/v1/examples/knowledge/rendered/README.md index 652999c..d79c00d 100644 --- a/spec/v1/examples/knowledge/rendered/README.md +++ b/spec/v1/examples/knowledge/rendered/README.md @@ -55,10 +55,10 @@ contributes routes and exposures to both; it owns neither object. |---|---|---|---| | `namespace.yaml` | `kubernetes` | `domain` | none (the adapter emits this per *Service* directory, not per domain: **G-02**) | | `kustomization.yaml` | `kubernetes` | the Service set of the domain | - | -| `apps/knowledge/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides`, `placement`, `hardening`, `probes`, `startupBudget`, `cutover`, `stateful`, `volumes`, `secrets`, env files | `replicas` (`minAvailable` ungraded); the image's UID behind `runAsNonRoot`; a scratch volume for a read-only-root JVM (**G-04**); what `stateful` changes about the object kind (**G-05**); the PV-bound node (**G-06**); env-var renaming through `envFrom` (**G-03**); readable mode on the 0400 key (**G-08**) | +| `apps/knowledge/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `runtime`, `provides`, `placement`, `hardening`, `probes`, `startupBudget`, `cutover`, `volumes`, `secrets`, env files | `replicas` (`minAvailable` ungraded); the image's UID behind `runAsNonRoot`; a scratch volume for a read-only-root JVM (**G-04**); what the deleted `stateful` boolean changed about the object kind (**G-05**); the PV-bound node (**G-06**); env-var renaming through `envFrom` (**G-03**); readable mode on the 0400 key (**G-08**) | | `apps/knowledge/serviceaccount.yaml` | `kubernetes` | workload `name` × 2, `domain` | none (the adapter names one account after the *Service*: **G-09**) | | `apps/knowledge/configmap.yaml` | `kubernetes` | env files, `dependsOn`, the `provides` port, Cluster Target, workload `name` | 15 of the 16 Runtime Profile keys (**G-13**); the database name spelling (**G-12**); change propagation on edit (**G-10**) | -| `apps/knowledge/pvc.yaml` | `kubernetes` | `volumes[].claim`, `volumes[].durability`, `stateful` | `resources.requests.storage`: **the object does not apply without it** (**G-15**); the durability annotation key (**G-14**) | +| `apps/knowledge/pvc.yaml` | `kubernetes` | `volumes[].claim`, `volumes[].durability` | `resources.requests.storage`: **the object does not apply without it** (**G-15**); the durability annotation key (**G-14**) | | `apps/knowledge/servicemonitor.yaml` | `prometheus` | `observability.scrape {workload, surface, path}`, `provides` | cadence from the Platform document | | `apps/knowledge/networkpolicy.yaml` | `networking`, **not registered** (**G-16**) | `dependsOn`, `provides`, `exposure`, `scrape`, effective grant set, baseline | egress to anything outside the estate, the worker's git remote (**G-20**); ingress from consumers absent from the union (**G-18**); whether a namespace catch-all is emitted (**G-17**) | | `apps/knowledge/vso.yaml` | `vso` | `secrets` at both levels, `delivery`, `rotation`, workload `name` | Secret/object naming (**G-21**); which identity reads a shared path (**G-23**); the Kubernetes auth mount name | @@ -119,11 +119,13 @@ is the largest hole in the model as written. has no vocabulary for an ephemeral volume, `volumes` carries `claim`, `mountAt` and `durability` only, so neither the author nor the renderer can produce one. -**G-05** Object kind is documented as derived from `lifecycle` + `stateful` + -`volumes`, but chapter 20's own projection renders `Deployment` for -`knowledge-ingest-worker`, which is `stateful: true` with a volume. With -`volumeClaimTemplate` forbidden, what `stateful` changes about the kind is -unstated; here it only selects the 10m health timeout class. +**G-05** **Closed.** Object kind was documented as derived from `lifecycle`, a +`stateful` boolean and `volumes`, while chapter 20's own projection rendered +`Deployment` for `knowledge-ingest-worker`, which declared the boolean and held +a volume. With `volumeClaimTemplate` forbidden, what the boolean changed about +the kind was never stated, and it turned out to be nothing: it appeared exactly +where `volumes` did, in every Workload in the example set. The field is deleted +and the kind derives from `lifecycle` and `volumes`. **G-06** The example set contains no `ClusterState` snapshot, so `placement.boundTo`, `from: clusterState` and the PV's node affinity cannot be rendered. Every diff --git a/spec/v1/examples/negative/duplicate-service-id/README.md b/spec/v1/examples/negative/duplicate-service-id/README.md index 940718e..6702d20 100644 --- a/spec/v1/examples/negative/duplicate-service-id/README.md +++ b/spec/v1/examples/negative/duplicate-service-id/README.md @@ -40,4 +40,6 @@ vacuous for three of four routed services. One negative fixture per invariant is the target. This is the first; the second is [`../duplicate-workload-name/`](../duplicate-workload-name/), which asserts -`E_DUPLICATE_WORKLOAD_NAME` over a single domain file. +`E_DUPLICATE_WORKLOAD_NAME` over a single domain file, which is decided by the +one document that holds both Workloads and is therefore raised as soon as that +fragment is read. diff --git a/spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml b/spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml index 1f1b4b4..af73628 100644 --- a/spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml +++ b/spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml @@ -10,8 +10,14 @@ # fixture was rejected by E_SCHEMA_VERSION_MISMATCH before the union ever # evaluated identity -- while the gate-can-fail step, which tested only for a # non-zero exit, still printed success. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain schemaVersion: 1.0.0 +# Accepted on its own. The collision needs the second fragment beside it, so +# this document conforms to the metamodel and the union is what refuses. +expect: accepted + # One file per domain, one file one Intent Fragment # (docs/adr/model/0063-intent-authored-per-domain.md). `owner` is the only field the # header raises. @@ -20,7 +26,6 @@ owner: joris services: - id: knowledge - alertClass: business-hours workloads: - name: knowledge-api lifecycle: service @@ -28,3 +33,5 @@ services: runtime: jvm placement: {memory: 768Mi, cpu: 250m} probes: none + startupBudget: 600s + cutover: rolling diff --git a/spec/v1/examples/negative/duplicate-service-id/intent-b/agents.yml b/spec/v1/examples/negative/duplicate-service-id/intent-b/agents.yml index d4eea47..e5c2642 100644 --- a/spec/v1/examples/negative/duplicate-service-id/intent-b/agents.yml +++ b/spec/v1/examples/negative/duplicate-service-id/intent-b/agents.yml @@ -9,14 +9,19 @@ # That is exactly why the check exists at composition: the id is what one # Service uses to reference another, and two Services answering to one string # makes every `dependsOn: {service: knowledge, ...}` edge ambiguous. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain schemaVersion: 1.0.0 +# Accepted on its own, exactly as fixture A is: E_DUPLICATE_SERVICE_ID is a +# property of the union, and neither half of a pair can carry it alone. +expect: accepted + domain: agents owner: joris services: - id: knowledge - alertClass: business-hours workloads: - name: other-knowledge lifecycle: service @@ -24,3 +29,5 @@ services: runtime: jvm placement: {memory: 256Mi, cpu: 50m} probes: none + startupBudget: 600s + cutover: rolling diff --git a/spec/v1/examples/negative/duplicate-workload-name/README.md b/spec/v1/examples/negative/duplicate-workload-name/README.md index f6ecbab..40900bc 100644 --- a/spec/v1/examples/negative/duplicate-workload-name/README.md +++ b/spec/v1/examples/negative/duplicate-workload-name/README.md @@ -33,10 +33,12 @@ can fail and say nothing about Workload identity. ## The assertion asserts the code, not the exit status -The compose workflow applies this fixture on every run -([`../../workflows/compose.yml`](../../workflows/compose.yml)) and greps -`E_DUPLICATE_WORKLOAD_NAME` out of the output. A non-zero exit is not the -assertion: this fixture is one schema slip away from failing for an unrelated +The fixture carries `expect: E_DUPLICATE_WORKLOAD_NAME`, and `npm run +lint:intent` refuses the tree unless that is the code, and the only code, the +metamodel emits for it. The compose workflow applies it on every run as well +([`../../workflows/compose.yml`](../../workflows/compose.yml)) and greps the +same token out of the output. A non-zero exit is not the assertion in either +place: this fixture is one schema slip away from failing for an unrelated reason, and a step that accepted any failure would keep printing success while proving nothing about the invariant named on the tin. *Verify the value, not the command.* diff --git a/spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml b/spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml index 9a5ced9..bd93ee8 100644 --- a/spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml +++ b/spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml @@ -3,8 +3,14 @@ # Otherwise valid and schema-complete, so composition reaches the identity check # rather than failing earlier for an unrelated reason. It must fail with # E_DUPLICATE_WORKLOAD_NAME and with nothing else. +apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain schemaVersion: 1.0.0 +# A domain is exactly one fragment, so this refusal is decided by this document +# alone and is raised as soon as it is read (spec/v1/40-composition.md#identity). +expect: E_DUPLICATE_WORKLOAD_NAME + # One file per domain (docs/adr/model/0063-intent-authored-per-domain.md). The # namespace derives from this header as agents-system, and the ServiceAccount # and Vault role of a Workload are its NAME under that namespace @@ -19,7 +25,6 @@ services: # (docs/adr/model/0062-service-is-the-release-unit.md). What is not ordinary is that # both name a Workload `api`. - id: agents-api - alertClass: urgent workloads: - name: api # -> agents-system.api lifecycle: service @@ -27,12 +32,13 @@ services: runtime: jvm provides: {http: 8080} placement: {memory: 768Mi, cpu: 250m} + startupBudget: 600s + cutover: rolling probes: readiness: {path: /api/actuator/health/readiness, port: 8080} liveness: {path: /api/actuator/health/liveness, port: 8080} - id: lightrag - alertClass: business-hours workloads: - name: api # -> agents-system.api, ALREADY TAKEN lifecycle: service @@ -40,6 +46,8 @@ services: runtime: python provides: {http: 9621} placement: {memory: 2Gi, cpu: 500m} + startupBudget: 600s + cutover: rolling probes: readiness: {path: /health, port: 9621} liveness: {path: /health, port: 9621} diff --git a/spec/v1/examples/refusals/README.md b/spec/v1/examples/refusals/README.md index 2a60362..47b2182 100644 --- a/spec/v1/examples/refusals/README.md +++ b/spec/v1/examples/refusals/README.md @@ -17,9 +17,12 @@ There is no fixture for "no monitoring". A Service that wants none omits the `observability` block, which is an accepted input and appears in the worked set as `platform-valkey` rather than here. -These are **fixtures, not proof of rendered behaviour.** The compiler does not -exist yet, so `test/simplification-contract.test.ts` asserts them at the layer -that does: the shape of the input. +These are **fixtures, not proof of rendered behaviour.** Each is parsed against +the Service Intent metamodel, and the code it is refused with must equal the one +its `expect:` header names: `npm run lint:intent` is what runs that, over every +Service Intent document in the repository at once, and a fixture that starts +failing for a second reason fails the gate exactly as one that stops failing at +all does. What is still unproven is what a renderer would do with them. Two things therefore remain **unproven until a renderer exists**, and are named as blockers rather than described as verified: diff --git a/spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml b/spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml index 03225c0..e433a44 100644 --- a/spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml +++ b/spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml @@ -29,8 +29,6 @@ services: lifecycle: service image: recreate-over-rwo-store runtime: static - engine: valkey - stateful: true provides: redis: 6379 diff --git a/spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml b/spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml index 6e5bf55..b953400 100644 --- a/spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml +++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml @@ -31,8 +31,6 @@ services: lifecycle: service image: rolling-over-rwo-store runtime: static - engine: valkey - stateful: true provides: redis: 6379 diff --git a/spec/v1/schemas/service-intent.schema.json b/spec/v1/schemas/service-intent.schema.json new file mode 100644 index 0000000..d651cea --- /dev/null +++ b/spec/v1/schemas/service-intent.schema.json @@ -0,0 +1,3266 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "apiVersion": { + "type": "string", + "const": "intent.jorisjonkers.dev/v1" + }, + "kind": { + "type": "string", + "const": "Domain" + }, + "schemaVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "domain": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "services": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "observability": { + "type": "object", + "properties": { + "alertClass": { + "type": "string", + "enum": [ + "business-hours", + "urgent", + "page" + ] + }, + "scrape": { + "type": "object", + "properties": { + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "path": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "workload", + "surface", + "path" + ], + "additionalProperties": false + } + }, + "required": [ + "alertClass" + ], + "additionalProperties": false + }, + "exposure": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "host": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "contentPolicy": { + "type": "string", + "enum": [ + "strict", + "admin", + "workflow" + ] + }, + "routes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "match": { + "type": "string", + "enum": [ + "prefix", + "exact" + ] + }, + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "redirectTo": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "path", + "match", + "workload", + "surface" + ], + "additionalProperties": false + } + } + }, + "required": [ + "name", + "host", + "audience", + "routes" + ], + "additionalProperties": false + } + }, + "workloads": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "lifecycle": { + "type": "string", + "enum": [ + "service", + "job" + ] + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "runtime": { + "type": "string", + "enum": [ + "jvm", + "python", + "node", + "static", + "none" + ] + }, + "engine": { + "type": "string", + "enum": [ + "postgres", + "rabbitmq", + "valkey", + "files" + ] + }, + "startupBudget": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + }, + "cutover": { + "type": "string", + "enum": [ + "rolling", + "recreate" + ] + }, + "writablePaths": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^\\/" + } + }, + "provides": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "sidecars": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "name", + "image", + "memory", + "cpu" + ], + "additionalProperties": false + } + }, + "dependsOn": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "required": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "service", + "surface" + ], + "additionalProperties": false + } + }, + "probes": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + { + "type": "object", + "properties": { + "readiness": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + }, + "liveness": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] + }, + "assets": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "pattern": "^[^/].*$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "from", + "mountAt" + ], + "additionalProperties": false + } + }, + "volumes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "claim": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "size": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "durability": { + "type": "string", + "enum": [ + "reconstructible", + "recoverable", + "irreplaceable" + ] + } + }, + "required": [ + "claim", + "mountAt", + "size", + "durability" + ], + "additionalProperties": false + } + }, + "placement": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "arch": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "amd64", + "arm64" + ] + } + }, + "site": { + "type": "string", + "minLength": 1 + }, + "disk": { + "type": "object", + "properties": { + "media": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "nvme", + "ssd", + "hdd" + ] + } + } + }, + "required": [ + "media" + ], + "additionalProperties": false + }, + "gpu": { + "type": "object", + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "class", + "memory" + ], + "additionalProperties": false + }, + "capabilities": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } + }, + "required": [ + "memory", + "cpu" + ], + "additionalProperties": false + }, + "replicas": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 2, + "maximum": 9007199254740991 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "count", + "reason" + ], + "additionalProperties": false + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "engine": { + "default": "kv", + "type": "string", + "const": "kv" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "access": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "keys", + "access", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "database" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "role", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "transit" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + } + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery", + "rotation" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "name", + "lifecycle", + "image", + "runtime", + "startupBudget", + "cutover", + "probes", + "placement" + ], + "additionalProperties": false + } + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "engine": { + "default": "kv", + "type": "string", + "const": "kv" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "access": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "keys", + "access", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "database" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "role", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "transit" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + } + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery", + "rotation" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "id", + "workloads" + ], + "additionalProperties": false + } + } + }, + "required": [ + "apiVersion", + "kind", + "schemaVersion", + "domain", + "owner", + "services" + ], + "additionalProperties": false, + "title": "Service Intent: Domain", + "$defs": { + "Service": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "observability": { + "type": "object", + "properties": { + "alertClass": { + "type": "string", + "enum": [ + "business-hours", + "urgent", + "page" + ] + }, + "scrape": { + "type": "object", + "properties": { + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "path": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "workload", + "surface", + "path" + ], + "additionalProperties": false + } + }, + "required": [ + "alertClass" + ], + "additionalProperties": false + }, + "exposure": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "host": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "contentPolicy": { + "type": "string", + "enum": [ + "strict", + "admin", + "workflow" + ] + }, + "routes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "match": { + "type": "string", + "enum": [ + "prefix", + "exact" + ] + }, + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "redirectTo": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "path", + "match", + "workload", + "surface" + ], + "additionalProperties": false + } + } + }, + "required": [ + "name", + "host", + "audience", + "routes" + ], + "additionalProperties": false + } + }, + "workloads": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "lifecycle": { + "type": "string", + "enum": [ + "service", + "job" + ] + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "runtime": { + "type": "string", + "enum": [ + "jvm", + "python", + "node", + "static", + "none" + ] + }, + "engine": { + "type": "string", + "enum": [ + "postgres", + "rabbitmq", + "valkey", + "files" + ] + }, + "startupBudget": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + }, + "cutover": { + "type": "string", + "enum": [ + "rolling", + "recreate" + ] + }, + "writablePaths": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^\\/" + } + }, + "provides": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "sidecars": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "name", + "image", + "memory", + "cpu" + ], + "additionalProperties": false + } + }, + "dependsOn": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "required": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "service", + "surface" + ], + "additionalProperties": false + } + }, + "probes": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + { + "type": "object", + "properties": { + "readiness": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + }, + "liveness": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] + }, + "assets": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "pattern": "^[^/].*$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "from", + "mountAt" + ], + "additionalProperties": false + } + }, + "volumes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "claim": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "size": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "durability": { + "type": "string", + "enum": [ + "reconstructible", + "recoverable", + "irreplaceable" + ] + } + }, + "required": [ + "claim", + "mountAt", + "size", + "durability" + ], + "additionalProperties": false + } + }, + "placement": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "arch": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "amd64", + "arm64" + ] + } + }, + "site": { + "type": "string", + "minLength": 1 + }, + "disk": { + "type": "object", + "properties": { + "media": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "nvme", + "ssd", + "hdd" + ] + } + } + }, + "required": [ + "media" + ], + "additionalProperties": false + }, + "gpu": { + "type": "object", + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "class", + "memory" + ], + "additionalProperties": false + }, + "capabilities": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } + }, + "required": [ + "memory", + "cpu" + ], + "additionalProperties": false + }, + "replicas": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 2, + "maximum": 9007199254740991 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "count", + "reason" + ], + "additionalProperties": false + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "engine": { + "default": "kv", + "type": "string", + "const": "kv" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "access": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "keys", + "access", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "database" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "role", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "transit" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + } + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery", + "rotation" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "name", + "lifecycle", + "image", + "runtime", + "startupBudget", + "cutover", + "probes", + "placement" + ], + "additionalProperties": false + } + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "engine": { + "default": "kv", + "type": "string", + "const": "kv" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "access": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "keys", + "access", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "database" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "role", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "transit" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + } + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery", + "rotation" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "id", + "workloads" + ], + "additionalProperties": false + }, + "Observability": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "alertClass": { + "type": "string", + "enum": [ + "business-hours", + "urgent", + "page" + ] + }, + "scrape": { + "type": "object", + "properties": { + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "path": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "workload", + "surface", + "path" + ], + "additionalProperties": false + } + }, + "required": [ + "alertClass" + ], + "additionalProperties": false + }, + "Scrape": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "path": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "workload", + "surface", + "path" + ], + "additionalProperties": false + }, + "Workload": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "lifecycle": { + "type": "string", + "enum": [ + "service", + "job" + ] + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "runtime": { + "type": "string", + "enum": [ + "jvm", + "python", + "node", + "static", + "none" + ] + }, + "engine": { + "type": "string", + "enum": [ + "postgres", + "rabbitmq", + "valkey", + "files" + ] + }, + "startupBudget": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + }, + "cutover": { + "type": "string", + "enum": [ + "rolling", + "recreate" + ] + }, + "writablePaths": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^\\/" + } + }, + "provides": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "sidecars": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "name", + "image", + "memory", + "cpu" + ], + "additionalProperties": false + } + }, + "dependsOn": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "required": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "service", + "surface" + ], + "additionalProperties": false + } + }, + "probes": { + "anyOf": [ + { + "type": "string", + "const": "none" + }, + { + "type": "object", + "properties": { + "readiness": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + }, + "liveness": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] + }, + "assets": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "pattern": "^[^/].*$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "from", + "mountAt" + ], + "additionalProperties": false + } + }, + "volumes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "claim": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "size": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "durability": { + "type": "string", + "enum": [ + "reconstructible", + "recoverable", + "irreplaceable" + ] + } + }, + "required": [ + "claim", + "mountAt", + "size", + "durability" + ], + "additionalProperties": false + } + }, + "placement": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "arch": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "amd64", + "arm64" + ] + } + }, + "site": { + "type": "string", + "minLength": 1 + }, + "disk": { + "type": "object", + "properties": { + "media": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "nvme", + "ssd", + "hdd" + ] + } + } + }, + "required": [ + "media" + ], + "additionalProperties": false + }, + "gpu": { + "type": "object", + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "class", + "memory" + ], + "additionalProperties": false + }, + "capabilities": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } + }, + "required": [ + "memory", + "cpu" + ], + "additionalProperties": false + }, + "replicas": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 2, + "maximum": 9007199254740991 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "count", + "reason" + ], + "additionalProperties": false + }, + "secrets": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "engine": { + "default": "kv", + "type": "string", + "const": "kv" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "access": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "keys", + "access", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "database" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "role", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "transit" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + } + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery", + "rotation" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "name", + "lifecycle", + "image", + "runtime", + "startupBudget", + "cutover", + "probes", + "placement" + ], + "additionalProperties": false + }, + "Capacity": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 2, + "maximum": 9007199254740991 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "count", + "reason" + ], + "additionalProperties": false + }, + "Sidecar": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "image": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "name", + "image", + "memory", + "cpu" + ], + "additionalProperties": false + }, + "DependencyEdge": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "required": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "service", + "surface" + ], + "additionalProperties": false + }, + "Probe": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "path", + "port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tcp": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "tcp" + ], + "additionalProperties": false + } + ] + }, + "Asset": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "from": { + "type": "string", + "pattern": "^[^/].*$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "from", + "mountAt" + ], + "additionalProperties": false + }, + "Volume": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "claim": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "size": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "durability": { + "type": "string", + "enum": [ + "reconstructible", + "recoverable", + "irreplaceable" + ] + } + }, + "required": [ + "claim", + "mountAt", + "size", + "durability" + ], + "additionalProperties": false + }, + "Placement": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "cpu": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + }, + "arch": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "amd64", + "arm64" + ] + } + }, + "site": { + "type": "string", + "minLength": 1 + }, + "disk": { + "type": "object", + "properties": { + "media": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "nvme", + "ssd", + "hdd" + ] + } + } + }, + "required": [ + "media" + ], + "additionalProperties": false + }, + "gpu": { + "type": "object", + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "class", + "memory" + ], + "additionalProperties": false + }, + "capabilities": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } + }, + "required": [ + "memory", + "cpu" + ], + "additionalProperties": false + }, + "DiskRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "media": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "nvme", + "ssd", + "hdd" + ] + } + } + }, + "required": [ + "media" + ], + "additionalProperties": false + }, + "GpuRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "memory": { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$" + } + }, + "required": [ + "class", + "memory" + ], + "additionalProperties": false + }, + "Exposure": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "host": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "contentPolicy": { + "type": "string", + "enum": [ + "strict", + "admin", + "workflow" + ] + }, + "routes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "match": { + "type": "string", + "enum": [ + "prefix", + "exact" + ] + }, + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "redirectTo": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "path", + "match", + "workload", + "surface" + ], + "additionalProperties": false + } + } + }, + "required": [ + "name", + "host", + "audience", + "routes" + ], + "additionalProperties": false + }, + "Route": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^\\/" + }, + "match": { + "type": "string", + "enum": [ + "prefix", + "exact" + ] + }, + "workload": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "audience": { + "type": "string", + "enum": [ + "anonymous", + "authenticated", + "internal", + "lan" + ] + }, + "redirectTo": { + "type": "string", + "pattern": "^\\/" + } + }, + "required": [ + "path", + "match", + "workload", + "surface" + ], + "additionalProperties": false + }, + "Grant": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "engine": { + "default": "kv", + "type": "string", + "const": "kv" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "keys": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "access": { + "type": "string", + "enum": [ + "read", + "self-renew", + "self-roll", + "custody" + ] + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "keys", + "access", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "database" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "mountAt": { + "type": "string", + "pattern": "^\\/" + }, + "fileMode": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "role", + "delivery", + "rotation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "const": "transit" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "operations": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate" + ] + } + }, + "delivery": { + "type": "string", + "enum": [ + "env", + "file", + "self" + ] + }, + "rotation": { + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + } + }, + "required": [ + "engine", + "key", + "operations", + "delivery", + "rotation" + ], + "additionalProperties": false + } + ] + }, + "Rotation": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tolerates": { + "type": "string", + "enum": [ + "restart", + "reload" + ] + }, + "maxAge": { + "type": "string", + "pattern": "^\\d+(s|m|h)$" + } + }, + "required": [ + "tolerates" + ], + "additionalProperties": false + }, + "EnvFile": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "cluster": { + "type": "string", + "minLength": 1 + }, + "entries": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "entries" + ], + "additionalProperties": false + }, + "Placeholder": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "secret", + "dependency", + "exposure", + "identity" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "kind", + "source" + ], + "additionalProperties": false + }, + "Surface": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "name", + "port" + ], + "additionalProperties": false + } + } +} diff --git a/src/application/parse-service-intent.ts b/src/application/parse-service-intent.ts new file mode 100644 index 0000000..7f19c9b --- /dev/null +++ b/src/application/parse-service-intent.ts @@ -0,0 +1,67 @@ +// Conformance, operationally. +// +// A document **conforms to** the Service Intent metamodel when all four of +// these hold, and this use-case is where the four are put in order: +// +// 1. it is YAML; +// 2. it is an instance of the metamodel (schema.ts): every closed vocabulary +// is respected, every union picks an arm, and no class carries a key it +// does not declare; +// 3. it maps into the domain model, which is total for an instance; +// 4. every well-formedness rule in `SERVICE_INTENT_RULES` returns no +// violation. +// +// Nothing about the model's **semantics** is evaluated: no derivation runs, no +// Deliverable is produced, and no second document is read. Conformance here is +// the language definition's first three parts, which is exactly what Atkinson +// and Kuehne's fourth part is defined against and what the derivation this +// repository has not written yet will be held to. +// +// Every stage runs to completion before the next is entered, and stage 4 runs +// every rule rather than stopping at the first: a use-case returns a diagnostic +// list, so one command reports ten mistakes +// (docs/architecture.md#error-model). +// +// This is the seam the tests assert at (docs/architecture.md#testing): document +// in, `Result` out, in memory, no filesystem. The gate supplies the bytes. +import { + accepted, + refused, + type Diagnostic, + type Result, +} from "../domain/diagnostic.ts"; +import type { Domain } from "../domain/service-intent/model.ts"; +import { SERVICE_INTENT_RULES } from "../domain/service-intent/rules.ts"; +import { readDomain } from "../wire/service-intent/read.ts"; + +/** + * Parse one Service Intent document and hold it to every rule one document + * decides. + * + * `document` names the file the bytes came from; it is stamped onto every + * diagnostic, including the ones a rule raised, because a rule is a pure + * function of the model and cannot know which file it was read from. + */ +export function parseServiceIntent( + text: string, + document: string, +): Result { + const read = readDomain(text, document); + if (!read.ok) return read; + + const violations: Diagnostic[] = []; + for (const rule of SERVICE_INTENT_RULES) + for (const violation of rule.evaluate(read.value)) + violations.push({ ...violation, document }); + + return violations.length === 0 ? accepted(read.value) : refused(violations); +} + +/** + * Whether `document` conforms, as one boolean, for a caller that only needs + * the answer. The diagnostics are the interesting half; this exists so a test + * or a report can say "23 of 23" without re-deriving it. + */ +export function conforms(text: string, document: string): boolean { + return parseServiceIntent(text, document).ok; +} diff --git a/src/domain/diagnostic.ts b/src/domain/diagnostic.ts new file mode 100644 index 0000000..35765dc --- /dev/null +++ b/src/domain/diagnostic.ts @@ -0,0 +1,64 @@ +// Every failure is a Diagnostic, and a use-case returns a Result over a list +// of them rather than throwing (docs/architecture.md#error-model). One command +// reports ten mistakes rather than the first one, so nothing here short +// circuits and nothing here throws: an exception is reserved for a broken +// invariant inside the compiler, never for a defect in what it was given. + +/** + * Where a refusal came from, which is what a fixture's `expect:` header names. + * + * - `syntax`: the bytes are not the concrete syntax at all (YAML did not parse). + * - `schema`: the document is not an instance of the metamodel. A value outside + * a closed vocabulary, a key no class carries, a `kv` field on a `transit` + * grant. These carry no `E_` code, because the refusal is the language + * definition's rather than a named rule's + * (spec/v1/examples/refusals/README.md). + * - `document`: the document is an instance, and a well-formedness rule refuses + * it. These always carry the rule's `E_` code. + */ +export type DiagnosticKind = "syntax" | "schema" | "document"; + +/** An `E_` code the specification defines, or the schema stage's own name. */ +export type DiagnosticCode = `E_${string}` | "schema" | "syntax"; + +/** + * One refusal, addressed. `at` is the **document path**: the dotted route from + * the root of the document to the value that is wrong, so a reader opens the + * file at the right line instead of grepping for the message. + */ +export interface Diagnostic { + readonly code: DiagnosticCode; + readonly kind: DiagnosticKind; + /** The file the document was read from, as the caller named it. */ + readonly document: string; + /** The path inside that document, e.g. `services[0].workloads[1].cutover`. */ + readonly at: string; + readonly message: string; +} + +/** A use-case's answer: the value, or every reason there is not one. */ +export type Result = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly diagnostics: readonly Diagnostic[] }; + +/** A successful Result. */ +export function accepted(value: T): Result { + return { ok: true, value }; +} + +/** A failed Result. Empty diagnostics would be a lie, so it is rejected here. */ +export function refused(diagnostics: readonly Diagnostic[]): Result { + if (diagnostics.length === 0) + throw new Error("a refusal with no diagnostic says nothing"); + return { ok: false, diagnostics }; +} + +/** Join a parent document path and a child segment, for a map or object key. */ +export function child(parent: string, key: string): string { + return parent === "" ? key : `${parent}.${key}`; +} + +/** Join a parent document path and a list index. */ +export function at(parent: string, index: number): string { + return `${parent}[${index}]`; +} diff --git a/src/domain/service-intent/model.ts b/src/domain/service-intent/model.ts new file mode 100644 index 0000000..2ad3ee3 --- /dev/null +++ b/src/domain/service-intent/model.ts @@ -0,0 +1,287 @@ +// Service Intent's abstract syntax: the classes chapter 10 draws, as domain +// types. +// +// This is not the authoring shape and deliberately does not mirror it +// (docs/architecture.md#the-wire-boundary). Three differences carry their +// weight: +// +// - `provides` is a map from surface name to port in the file, because that +// is the shortest thing to write; it is a `Surface[]` here, because a +// surface is a class with a name and a port and a route resolves against +// one. +// - A Grant is one union on `engine` here. In the file `engine` may be +// omitted and means `kv` (0085); by the time it reaches the domain the +// discriminator is always present. +// - Every node carries `at`, the document path it was read from. A rule +// that refuses a value can therefore say where it is without the document +// being re-walked, and a Diagnostic's address is a property of the model +// rather than of the code that happened to find the defect. +// +// The domain imports nothing: no Zod, no YAML, no filesystem +// (.dependency-cruiser.cjs, `domain-is-pure`). + +/** + * A node of the abstract syntax, addressed by where it was authored. + * + * The field is `at` rather than `path` because two classes already carry a + * `path` of their own that means something else entirely: a Route's URL path + * and a Grant's Secret Store path. One word, two meanings, is the defect + * CONTEXT.md exists to prevent, so the document address takes the other word + * and matches the Diagnostic field it ends up in. + */ +export interface Node { + /** This node's document path, e.g. `services[0].workloads[1]`. */ + readonly at: string; +} + +export type Lifecycle = "service" | "job"; +export type Runtime = "jvm" | "python" | "node" | "static" | "none"; +export type Engine = "postgres" | "rabbitmq" | "valkey" | "files"; +export type Cutover = "rolling" | "recreate"; +export type DurabilityClass = + "reconstructible" | "recoverable" | "irreplaceable"; +export type Arch = "amd64" | "arm64"; +export type Media = "nvme" | "ssd" | "hdd"; +export type AlertClass = "business-hours" | "urgent" | "page"; +export type Audience = "anonymous" | "authenticated" | "internal" | "lan"; +export type ContentPolicy = "strict" | "admin" | "workflow"; +export type Match = "prefix" | "exact"; +export type SecretEngine = "kv" | "database" | "transit"; +export type AccessTier = "read" | "self-renew" | "self-roll" | "custody"; +export type TransitOp = "sign" | "verify" | "encrypt" | "decrypt" | "rotate"; +export type Delivery = "env" | "file" | "self"; +export type Tolerance = "restart" | "reload"; +export type PlaceholderKind = "secret" | "dependency" | "exposure" | "identity"; + +/** One name from a Workload's `provides` map, with the port it declares. */ +export interface Surface extends Node { + readonly name: string; + readonly port: number; +} + +export interface Sidecar extends Node { + readonly name: string; + readonly image: string; + readonly memory: string; + readonly cpu: string; +} + +export interface DependencyEdge extends Node { + readonly service: string; + readonly surface: string; + readonly required: boolean; +} + +/** An HTTP probe (`path` + `port`) or a TCP one (`tcp`), never both. */ +export type Probe = + | (Node & { + readonly kind: "http"; + readonly path: string; + readonly port: number; + }) + | (Node & { readonly kind: "tcp"; readonly tcp: number }); + +/** + * What a Workload says about probing. `none` is an authored value, not an + * absence: a forgotten probe block is more dangerous than a forgotten alert, + * so the opt-out is explicit (chapter 10, Probes). + */ +export type Probes = + | { readonly kind: "none" } + | { + readonly kind: "declared"; + readonly readiness?: Probe; + readonly liveness?: Probe; + }; + +export interface Asset extends Node { + readonly from: string; + readonly mountAt: string; +} + +export interface Volume extends Node { + readonly claim: string; + readonly mountAt: string; + readonly size: string; + readonly durability: DurabilityClass; +} + +export interface DiskRequest extends Node { + readonly media: readonly Media[]; +} + +export interface GpuRequest extends Node { + readonly class: string; + readonly memory: string; +} + +export interface Placement extends Node { + readonly memory: string; + readonly cpu: string; + readonly arch?: readonly Arch[]; + readonly site?: string; + readonly disk?: DiskRequest; + readonly gpu?: GpuRequest; + readonly capabilities?: readonly string[]; +} + +export interface Capacity extends Node { + readonly count: number; + readonly reason: string; +} + +export interface Rotation extends Node { + readonly tolerates: Tolerance; + readonly maxAge?: string; +} + +/** + * A grant, as a union on `engine` (0085). The three engines authorise + * different things, so they are three shapes rather than one shape with seven + * optional fields: a `kv` field on a `transit` grant is not a rule to check, + * it is a document that is not an instance of this model at all. + */ +export type Grant = Node & + Readonly<{ delivery: Delivery; rotation: Rotation }> & + ( + | Readonly<{ + engine: "kv"; + path: string; + keys: readonly string[]; + access: AccessTier; + mountAt?: string; + fileMode?: string; + }> + | Readonly<{ + engine: "database"; + role: string; + mountAt?: string; + fileMode?: string; + }> + | Readonly<{ + engine: "transit"; + key: string; + operations: readonly TransitOp[]; + }> + ); + +export interface Route extends Node { + readonly path: string; + readonly match: Match; + readonly workload: string; + readonly surface: string; + readonly audience?: Audience; + readonly redirectTo?: string; +} + +export interface Exposure extends Node { + readonly name: string; + readonly host: string; + readonly audience: Audience; + readonly contentPolicy?: ContentPolicy; + readonly routes: readonly Route[]; +} + +export interface Scrape extends Node { + readonly workload: string; + readonly surface: string; + readonly path: string; +} + +/** + * Whole or absent, and a half-declared block is the one refusal chapter 10 + * makes about monitoring. The block is optional on a Service; `scrape` is + * optional **here** so that the half-declared case is representable and can be + * refused by name (`E_ALERT_CLASS_WITHOUT_SIGNAL`) rather than by the schema, + * which would lose the code the fixture expects. + */ +export interface Observability extends Node { + readonly alertClass: AlertClass; + readonly scrape?: Scrape; +} + +export interface Workload extends Node { + readonly name: string; + readonly lifecycle: Lifecycle; + readonly image: string; + readonly runtime: Runtime; + readonly engine?: Engine; + readonly startupBudget: string; + readonly cutover: Cutover; + readonly writablePaths: readonly string[]; + readonly provides: readonly Surface[]; + readonly sidecars: readonly Sidecar[]; + readonly dependsOn: readonly DependencyEdge[]; + readonly probes: Probes; + readonly assets: readonly Asset[]; + readonly volumes: readonly Volume[]; + readonly placement: Placement; + readonly replicas?: Capacity; + readonly secrets: readonly Grant[]; +} + +export interface Service extends Node { + readonly id: string; + readonly observability?: Observability; + readonly exposure: readonly Exposure[]; + readonly workloads: readonly Workload[]; + readonly secrets: readonly Grant[]; +} + +/** One authored file: one domain, one Intent Fragment (0063). */ +export interface Domain extends Node { + readonly schemaVersion: string; + readonly domain: string; + readonly owner: string; + readonly services: readonly Service[]; +} + +/** A literal line of an env file: a key and the text that fills it. */ +export interface Literal extends Node { + readonly key: string; + readonly value: string; +} + +/** + * A named-source reference inside an env file's value. It names a source and + * resolves to exactly one value; it is never a template language, so it takes + * no arguments, has no conditionals and has no arithmetic (chapter 10, + * Configuration). + */ +export interface Placeholder extends Node { + readonly kind: PlaceholderKind; + /** The text between the kind and the closing brace, verbatim. */ + readonly source: string; + /** The env key whose value carries this placeholder. */ + readonly key: string; +} + +/** One `base.env`, or one `.env` overlay, of one Workload (0011). */ +export interface EnvFile extends Node { + /** The Cluster Target an overlay is for; absent on `base.env`. */ + readonly cluster?: string; + readonly literals: readonly Literal[]; + readonly placeholders: readonly Placeholder[]; +} + +/** Every Workload of a Domain, flattened, with the Service that holds it. */ +export function workloadsOf( + document: Domain, +): readonly { readonly service: Service; readonly workload: Workload }[] { + return document.services.flatMap((service) => + service.workloads.map((workload) => ({ service, workload })), + ); +} + +/** Whether a Durability Class derives a backup job (0077). */ +export function derivesBackup(durability: DurabilityClass): boolean { + return durability !== "reconstructible"; +} + +/** The grants a Workload effectively holds: its Service's, plus its own. */ +export function grantsOf( + service: Service, + workload: Workload, +): readonly Grant[] { + return [...service.secrets, ...workload.secrets]; +} diff --git a/src/domain/service-intent/rules.ts b/src/domain/service-intent/rules.ts new file mode 100644 index 0000000..bd199b9 --- /dev/null +++ b/src/domain/service-intent/rules.ts @@ -0,0 +1,473 @@ +// Service Intent's well-formedness rules: the third of the four parts of a +// language definition, after the abstract syntax (model.ts) and the concrete +// syntax (src/wire/service-intent/), and before the semantics, which this +// repository does not yet have. +// +// A rule is a pure function from one parsed Domain to a diagnostic list, +// registered with its code, exactly the shape docs/architecture.md#error-model +// gives the estate-wide invariants. The registry is **enumerable**, which is +// what makes "a rule with no test, no fixture or no specification anchor" a +// detectable condition rather than an absent one, and it is the list issue #44 +// hangs an Essential OCL statement off. +// +// Only rules that one document decides live here. A rule that needs a second +// document (the composed union, the Platform document, the node contract, the +// images lock, the ClusterState snapshot) is composition's or render's, and +// registering it here would mean evaluating it against half its inputs. The +// boundary is stated in full in `NOT_DECIDED_BY_ONE_DOCUMENT` below. +import { child, type Diagnostic } from "../diagnostic.ts"; +import { + derivesBackup, + grantsOf, + workloadsOf, + type Domain, + type Grant, + type Service, + type Workload, +} from "./model.ts"; + +/** + * Where the rule sits in Essential OCL (#44 writes the statements themselves). + * + * - `inv`: an invariant on instances of `context`. + * - `derive`: the definition of a derived value. + * - `pre`: a precondition on an operation. + * + * Every rule a single document decides is an `inv`; the other two placements + * belong to the derivation this repository has not written yet, and the field + * exists now so that the registry's shape does not change when they arrive. + */ +export type Placement = "inv" | "derive" | "pre"; + +/** One model rule: its code, where it sits, what it constrains, what it is. */ +export interface Rule { + /** The specification's code for this refusal. Unique across the registry. */ + readonly code: `E_${string}`; + /** The metamodel class the constraint is written against. */ + readonly context: string; + readonly placement: Placement; + /** The heading in `spec/v1/10-service-intent.md` that defines the rule. */ + readonly anchor: string; + /** What a reader is told, in one line, when nothing has gone wrong yet. */ + readonly states: string; + /** Every violation in `document`, in document order. */ + readonly evaluate: (document: Domain) => readonly Diagnostic[]; +} + +/** Build one `document`-kind Diagnostic for `rule`. */ +function violation( + rule: Pick, + path: string, + message: string, +): Diagnostic { + return { + code: rule.code, + kind: "document", + // The reader supplies the file; a rule knows only the document it was + // handed. `parseServiceIntent` stamps this before it returns. + document: "", + at: path, + message, + }; +} + +/** Each `workloads[i]` of each `services[j]`, with the Service that holds it. */ +function eachWorkload( + document: Domain, +): readonly { service: Service; workload: Workload }[] { + return workloadsOf(document); +} + +/** + * The first index of every value that appears more than once, after the first. + * Reporting the repeat rather than the original is what puts the diagnostic on + * the line the author just added. + */ +function repeats(values: readonly T[], key: (value: T) => string): number[] { + const seen = new Set(); + const out: number[] = []; + values.forEach((value, index) => { + const k = key(value); + if (seen.has(k)) out.push(index); + else seen.add(k); + }); + return out; +} + +const ALERT_CLASS_WITHOUT_SIGNAL: Rule = { + code: "E_ALERT_CLASS_WITHOUT_SIGNAL", + context: "Observability", + placement: "inv", + anchor: "observability", + states: + "the observability block is whole or absent: a class states how loudly to " + + "wake someone and means nothing without a signal to wake them about", + evaluate: (document) => + document.services.flatMap((service) => { + const block = service.observability; + if (block === undefined || block.scrape !== undefined) return []; + return [ + violation( + ALERT_CLASS_WITHOUT_SIGNAL, + block.at, + `service ${service.id} declares alertClass ${block.alertClass} and no scrape`, + ), + ]; + }), +}; + +const CUTOVER_UNHONOURABLE: Rule = { + code: "E_CUTOVER_UNHONOURABLE", + context: "Workload", + placement: "inv", + anchor: "cutover-is-declared-not-promised", + states: + "cutover: rolling asks for continuity through the cutover, and a volume " + + "on this substrate is ReadWriteOnce, which cannot attach to two pods at " + + "once: the surge a rolling cutover needs cannot happen", + evaluate: (document) => + eachWorkload(document).flatMap(({ workload }) => + workload.cutover === "rolling" && workload.volumes.length > 0 + ? [ + violation( + CUTOVER_UNHONOURABLE, + child(workload.at, "cutover"), + `workload ${workload.name} asks for a rolling cutover over ` + + `${workload.volumes.length} volume(s) that cannot surge`, + ), + ] + : [], + ), +}; + +const PRIVILEGED_PORT_UNDER_NONROOT: Rule = { + code: "E_PRIVILEGED_PORT_UNDER_NONROOT", + context: "Surface", + placement: "inv", + anchor: "a-privileged-port-needs-the-capability-that-binds-it", + states: + "a port below 1024 cannot be bound by a non-root process without " + + "CAP_NET_BIND_SERVICE, and the restricted class drops every capability", + evaluate: (document) => + eachWorkload(document).flatMap(({ workload }) => + workload.provides + .filter((surface) => surface.port < 1024) + .map((surface) => + violation( + PRIVILEGED_PORT_UNDER_NONROOT, + surface.at, + `workload ${workload.name} provides ${surface.name} on ` + + `${surface.port}; the answer is a port above 1024`, + ), + ), + ), +}; + +const ENGINE_WITHOUT_DURABILITY: Rule = { + code: "E_ENGINE_WITHOUT_DURABILITY", + context: "Workload", + placement: "inv", + anchor: "workload", + states: + "engine is what the platform keys a backup method off, so it is refused " + + "on a Workload holding no volume of a class that derives one", + evaluate: (document) => + eachWorkload(document).flatMap(({ workload }) => + workload.engine !== undefined && + !workload.volumes.some((volume) => derivesBackup(volume.durability)) + ? [ + violation( + ENGINE_WITHOUT_DURABILITY, + child(workload.at, "engine"), + `workload ${workload.name} declares engine ${workload.engine} ` + + "and holds no volume whose Durability Class derives a backup", + ), + ] + : [], + ), +}; + +const DURABILITY_WITHOUT_ENGINE: Rule = { + code: "E_DURABILITY_WITHOUT_ENGINE", + context: "Workload", + placement: "inv", + anchor: "workload", + states: + "the backup method is an image the platform names per engine, so a volume " + + "whose class derives a backup needs the Workload to say what the process is", + evaluate: (document) => + eachWorkload(document).flatMap(({ workload }) => { + if (workload.engine !== undefined) return []; + return workload.volumes + .filter((volume) => derivesBackup(volume.durability)) + .map((volume) => + violation( + DURABILITY_WITHOUT_ENGINE, + child(volume.at, "durability"), + `volume ${volume.claim} is ${volume.durability}, which derives a ` + + `backup, and workload ${workload.name} declares no engine`, + ), + ); + }), +}; + +const DUPLICATE_WORKLOAD_NAME: Rule = { + code: "E_DUPLICATE_WORKLOAD_NAME", + context: "Domain", + placement: "inv", + anchor: "service-identity", + states: + "the Workload name alone is the ServiceAccount and the Vault role under " + + "the domain's namespace, so it is unique within the domain file and not " + + "merely within the Service", + evaluate: (document) => { + const all = eachWorkload(document); + return repeats(all, ({ workload }) => workload.name).map((index) => { + const { workload } = all[index] as (typeof all)[number]; + return violation( + DUPLICATE_WORKLOAD_NAME, + child(workload.at, "name"), + `two Workloads of domain ${document.domain} are called ${workload.name}`, + ); + }); + }, +}; + +const DUPLICATE_EXPOSURE_NAME: Rule = { + code: "E_DUPLICATE_EXPOSURE_NAME", + context: "Service", + placement: "inv", + anchor: "exposure", + states: + "an exposure name is the second half of ${exposure:.#url}, " + + "already qualified by the Service id, so it is unique within the Service", + evaluate: (document) => + document.services.flatMap((service) => + repeats(service.exposure, (exposure) => exposure.name).map((index) => { + const exposure = service.exposure[ + index + ] as (typeof service.exposure)[number]; + return violation( + DUPLICATE_EXPOSURE_NAME, + child(exposure.at, "name"), + `service ${service.id} declares two exposures called ${exposure.name}`, + ); + }), + ), +}; + +const DUPLICATE_ROUTE_MATCH: Rule = { + code: "E_DUPLICATE_ROUTE_MATCH", + context: "Exposure", + placement: "inv", + anchor: "what-is-checked", + states: + "two routes of one exposure sharing a path and a match render two rules " + + "with identical matchers, and which serves a request is the router's " + + "tie-break rather than anything the author wrote", + evaluate: (document) => + document.services.flatMap((service) => + service.exposure.flatMap((exposure) => + repeats(exposure.routes, (route) => `${route.match} ${route.path}`).map( + (index) => { + const route = exposure.routes[ + index + ] as (typeof exposure.routes)[number]; + return violation( + DUPLICATE_ROUTE_MATCH, + route.at, + `exposure ${service.id}.${exposure.name} declares two routes ` + + `matching ${route.match} ${route.path}`, + ); + }, + ), + ), + ), +}; + +const ENV_CANNOT_RELOAD: Rule = { + code: "E_ENV_CANNOT_RELOAD", + context: "Grant", + placement: "inv", + anchor: "zero-downtime-rotation", + states: + "a pod's environment is fixed for its lifetime, so a rotated value cannot " + + "reach a running process through it: env with tolerates reload is a " + + "promise the substrate cannot keep", + evaluate: (document) => + eachGrant(document).flatMap(({ grant }) => + grant.delivery === "env" && grant.rotation.tolerates === "reload" + ? [ + violation( + ENV_CANNOT_RELOAD, + child(grant.rotation.at, "tolerates"), + "delivery: env cannot reload; it costs a rollout, and the " + + "declaration has to say so", + ), + ] + : [], + ), +}; + +/** + * The illegal cells of chapter 10's twelve. `self-renew` x `file` is recorded + * there as **open** rather than refused, so it is deliberately absent. + */ +const ILLEGAL_CELLS = new Set([ + "self-renew env", + "custody env", + "custody file", +]); + +const ILLEGAL_DELIVERY_FOR_ACCESS: Rule = { + code: "E_ILLEGAL_DELIVERY_FOR_ACCESS", + context: "Grant", + placement: "inv", + anchor: "which-tier-may-use-which-delivery", + states: + "custody with env or file asks the renderer to sync paths that do not " + + "exist yet; self-renew with env hands a token with no capability on its " + + "path a Secret it never reads; and self-roll derives patch, which does " + + "not include read, so a projected value needs a companion read entry", + evaluate: (document) => + eachGrant(document).flatMap(({ grant, siblings }) => { + if (grant.engine !== "kv") return []; + if (ILLEGAL_CELLS.has(`${grant.access} ${grant.delivery}`)) + return [ + violation( + ILLEGAL_DELIVERY_FOR_ACCESS, + child(grant.at, "delivery"), + `access ${grant.access} with delivery ${grant.delivery} is not a ` + + "legal cell", + ), + ]; + if (grant.access !== "self-roll" || grant.delivery === "self") return []; + const companion = siblings.some( + (other) => + other !== grant && + other.engine === "kv" && + other.access === "read" && + other.path === grant.path, + ); + return companion + ? [] + : [ + violation( + ILLEGAL_DELIVERY_FOR_ACCESS, + child(grant.at, "delivery"), + `access self-roll derives patch, which does not include read, ` + + `so ${grant.delivery} delivery of ${grant.path} needs a ` + + "companion read entry on the same path", + ), + ]; + }), +}; + +const NON_KV_DELIVERY: Rule = { + code: "E_NON_KV_DELIVERY", + context: "Grant", + placement: "inv", + anchor: "zero-downtime-rotation", + states: + "a transit key is never materialised into a variable or a file, so self " + + "is its only legal delivery; a database credential is projected exactly " + + "as a static one is, so this narrows to transit (0085)", + evaluate: (document) => + eachGrant(document).flatMap(({ grant }) => + grant.engine === "transit" && grant.delivery !== "self" + ? [ + violation( + NON_KV_DELIVERY, + child(grant.at, "delivery"), + `a transit grant on ${grant.key} may only be delivered as self, ` + + `never as ${grant.delivery}`, + ), + ] + : [], + ), +}; + +/** + * Every grant in the document, each with the effective set it belongs to: the + * Service's list plus the holding Workload's own (chapter 10, Secrets). A + * Service-level grant therefore appears once per Workload that holds it, which + * is what lets `self-roll` on a Workload find its companion `read` wherever the + * author wrote it. + */ +function eachGrant(document: Domain): readonly { + grant: Grant; + siblings: readonly Grant[]; +}[] { + const out: { grant: Grant; siblings: readonly Grant[] }[] = []; + const seen = new Set(); + for (const { service, workload } of workloadsOf(document)) { + const siblings = grantsOf(service, workload); + for (const grant of siblings) { + // A Service-level grant is one declaration held by several Workloads. + // Its document path is the declaration's, so this deduplicates on it and + // reports one diagnostic per authored line. + if (seen.has(grant.at)) continue; + seen.add(grant.at); + out.push({ grant, siblings }); + } + } + return out; +} + +/** + * The rules one Service Intent document decides, in evaluation order. + * + * Order is a reporting choice and nothing more: every rule runs, and none of + * them reads another's output. + */ +export const SERVICE_INTENT_RULES: readonly Rule[] = [ + ALERT_CLASS_WITHOUT_SIGNAL, + CUTOVER_UNHONOURABLE, + PRIVILEGED_PORT_UNDER_NONROOT, + ENGINE_WITHOUT_DURABILITY, + DURABILITY_WITHOUT_ENGINE, + DUPLICATE_WORKLOAD_NAME, + DUPLICATE_EXPOSURE_NAME, + DUPLICATE_ROUTE_MATCH, + ENV_CANNOT_RELOAD, + ILLEGAL_DELIVERY_FOR_ACCESS, + NON_KV_DELIVERY, +]; + +/** + * Rules the specification defines that one document cannot decide, and the + * input each is missing. This is a boundary, not a backlog: a rule here is not + * unimplemented, it is evaluated somewhere else, and putting it in the registry + * above would mean running it against half its inputs. + * + * It is stated as data so that a test can hold it disjoint from the registry + * and #44 can register both halves without re-deciding which is which. + */ +export const NOT_DECIDED_BY_ONE_DOCUMENT: Readonly> = { + E_DUPLICATE_SERVICE_ID: "the composed union of every Intent Fragment", + E_DUPLICATE_DOMAIN: "the composed union of every Intent Fragment", + E_DUPLICATE_HOST: + "the composed union, together with the Registered Unmanaged Surfaces", + E_DUPLICATE_APEX: + "the composed union, together with the Registered Unmanaged Surfaces", + E_SUBTREE_PREFIX_COLLISION: "the composed union's Secret Subtrees", + E_UNRESOLVED_SERVICE: + "the composed union, plus the Platform document's providers", + E_UNKNOWN_SURFACE: + "a linking step over named references, which is issue #39's", + E_PROVIDER_WITHOUT_COORDINATES: "the Platform document's providers", + E_DEPENDENCY_CYCLE: "the composed union's edge set", + E_NO_TIER_FOR_AUDIENCE: "the Platform document's access tiers", + E_UNBOUND_SECRET_GRANT: "the Workload's env files, over the composed union", + E_UNAUTHORISED_SECRET_REFERENCE: + "the Workload's env files, over the composed union", + E_ROLL_AFFECTS_OTHER_READERS: "the composed union's reader sets per path", + E_RAW_SECRET: "the Workload's env files and Assets", + E_SECRETS_AT_REST_REQUIRED: "the pinned Platform document", + E_PLACEMENT_UNSATISFIABLE: "the pinned node contract", + E_STORAGE_UNSATISFIABLE: "the pinned node contract", + E_DISK_BINDING_CONFLICT: "the pinned ClusterState snapshot", + E_HARDENING_UNMET: "the images lock's resolved uid and gid", + E_IMAGE_USER_NOT_NUMERIC: "the images lock, when it is built", + E_FLOATING_IMAGE: "the rendered Deliverable set", +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..462c9b0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,41 @@ +// The library entry, and one of the two roots of the module graph +// (.dependency-cruiser.cjs). A module this file cannot reach is dead code, and +// the boundary gate says so rather than waiting for coverage to imply it. +// +// What is published today is the Service Intent language definition: its +// abstract syntax, its concrete syntax, its well-formedness rules, and the +// use-case that puts the three in order. Platform Intent (#41) and the +// Resolved Deployment (#42) join it in the same shape. + +export type { + Diagnostic, + DiagnosticCode, + DiagnosticKind, + Result, +} from "./domain/diagnostic.ts"; + +export * from "./domain/service-intent/model.ts"; +export { + NOT_DECIDED_BY_ONE_DOCUMENT, + SERVICE_INTENT_RULES, + type Placement, + type Rule, +} from "./domain/service-intent/rules.ts"; + +export { METAMODEL } from "./wire/service-intent/schema.ts"; +export { CLOSED_VOCABULARIES } from "./wire/service-intent/vocabularies.ts"; +export { + parseEnvFile, + type EnvFileParse, +} from "./wire/service-intent/env-file.ts"; +export { + JSON_SCHEMA_PATH, + serviceIntentJsonSchema, + serviceIntentJsonSchemaText, +} from "./wire/service-intent/json-schema.ts"; +export { documentPath, readDomain } from "./wire/service-intent/read.ts"; + +export { + conforms, + parseServiceIntent, +} from "./application/parse-service-intent.ts"; diff --git a/src/wire/service-intent/env-file.ts b/src/wire/service-intent/env-file.ts new file mode 100644 index 0000000..736a932 --- /dev/null +++ b/src/wire/service-intent/env-file.ts @@ -0,0 +1,168 @@ +// The second artefact of layer 1: the placeholder language, written down. +// +// Chapter 10 specifies configuration as dotenv, per Workload (0011), in which +// "a literal is written literally. A derived value is a named placeholder". +// Until now that language had a table of four sources and no grammar, so +// nothing could say whether `${exposure:auth.public#url:/login}` was a +// document this model accepts. It is not, and this file is why: +// +// env-file ::= line* +// line ::= blank | comment | entry +// comment ::= '#' .* +// entry ::= key '=' value +// key ::= [A-Za-z_][A-Za-z0-9_]* +// value ::= ( literal-text | placeholder )* +// placeholder ::= '${' kind ':' source '}' +// kind ::= 'secret' | 'dependency' | 'exposure' | 'identity' +// source ::= [^{}]+ +// +// The grammar is what makes the closure real. A placeholder **names a source +// and resolves to one value**: it takes no arguments, so there is no +// conditional, no arithmetic and no second parameter to grow one. A path is +// written outside it, which is what keeps `grep -r 'exposure:auth.public'` +// finding every reader of that host whatever each appends. +// +// Each kind's `source` half has its own shape, and it is checked here too, +// because a source that does not parse cannot be resolved against anything +// later and the diagnostic would then name the wrong stage. +// +// What this file deliberately does NOT do is resolve a placeholder. Byte +// matching a `${secret:...}` against a granted read path is +// `E_UNAUTHORISED_SECRET_REFERENCE` and its twin `E_UNBOUND_SECRET_GRANT`, and +// chapter 10's validation table puts both at **composition**, over the +// composed union. They are listed in `NOT_DECIDED_BY_ONE_DOCUMENT`. +import { at, child, type Diagnostic } from "../../domain/diagnostic.ts"; +import type { + EnvFile, + Literal, + Placeholder, + PlaceholderKind, +} from "../../domain/service-intent/model.ts"; +import { PlaceholderKind as PlaceholderKindEnum } from "./vocabularies.ts"; + +/** `${kind:source}`, anywhere in a value, with no nesting and no arguments. */ +const PLACEHOLDER = /\$\{([a-z]+):([^{}]*)\}/g; + +/** Anything that looks like the opening of a placeholder, valid or not. */ +const OPENING = /\$\{/g; + +/** `KEY=value`, with the key on the left of the first `=`. */ +const ENTRY = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/; + +/** + * What each placeholder kind's source half must look like. + * + * These are the addresses chapter 10 tabulates, and nothing wider: + * `${secret:#}`, `${dependency:.}`, + * `${exposure:.#}` with `` one of exactly three, + * and `${identity:}` over a closed key set. + */ +const SOURCE: Readonly> = { + secret: /^[^#\s]+#[^#\s]+$/, + dependency: /^[a-z][a-z0-9-]*\.[a-z][a-zA-Z0-9]*$/, + exposure: /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*#(url|host|scheme)$/, + identity: /^(vaultRole|serviceAccount|namespace)$/, +}; + +const KINDS = new Set(PlaceholderKindEnum.options); + +function isKind(value: string): value is PlaceholderKind { + return KINDS.has(value); +} + +function bad(document: string, path: string, message: string): Diagnostic { + return { code: "schema", kind: "schema", document, at: path, message }; +} + +export interface EnvFileParse { + readonly file: EnvFile; + readonly diagnostics: readonly Diagnostic[]; +} + +/** + * Parse one env file into literals and placeholders. + * + * `document` is the file's own path, for the diagnostics; `cluster` names the + * Cluster Target of an overlay and is absent for a `base.env`. Every line is + * read: a malformed one is reported and parsing continues, so one command + * reports ten mistakes rather than the first. + */ +export function parseEnvFile( + text: string, + document: string, + cluster?: string, +): EnvFileParse { + const literals: Literal[] = []; + const placeholders: Placeholder[] = []; + const diagnostics: Diagnostic[] = []; + + text.split("\n").forEach((raw, index) => { + const line = raw.trimEnd(); + if (line.trim() === "" || line.trimStart().startsWith("#")) return; + const where = `line[${index + 1}]`; + const entry = ENTRY.exec(line); + if (entry === null) { + diagnostics.push( + bad( + document, + where, + `not an env entry: an env file holds KEY=value lines and comments, ` + + `and this reads ${JSON.stringify(line)}`, + ), + ); + return; + } + const key = entry[1] as string; + const value = entry[2] as string; + literals.push({ at: child(where, key), key, value }); + + const found = [...value.matchAll(PLACEHOLDER)]; + const openings = [...value.matchAll(OPENING)].length; + if (openings > found.length) + diagnostics.push( + bad( + document, + child(where, key), + "a placeholder is ${kind:source} with no nesting and no arguments; " + + "a path is written outside it", + ), + ); + found.forEach((match, ordinal) => { + const kind = match[1] as string; + const source = match[2] as string; + const path = at(child(where, key), ordinal); + if (!isKind(kind)) { + diagnostics.push( + bad( + document, + path, + `${kind} is not a placeholder source; the four are ` + + PlaceholderKindEnum.options.join(", "), + ), + ); + return; + } + if (!SOURCE[kind].test(source)) { + diagnostics.push( + bad( + document, + path, + `\${${kind}:${source}} does not address a ${kind}`, + ), + ); + return; + } + placeholders.push({ at: path, kind, source, key }); + }); + }); + + return { + file: { + at: document, + ...(cluster === undefined ? {} : { cluster }), + literals, + placeholders, + }, + diagnostics, + }; +} diff --git a/src/wire/service-intent/json-schema.ts b/src/wire/service-intent/json-schema.ts new file mode 100644 index 0000000..9a60abf --- /dev/null +++ b/src/wire/service-intent/json-schema.ts @@ -0,0 +1,41 @@ +// JSON Schema, generated from the metamodel and committed. +// +// "JSON Schema is generated from the **input** variant of each schema, because +// a field with a platform default is optional in the file a human writes and +// required only after validation" +// (docs/architecture.md#the-wire-boundary). `io: "input"` is that choice, and +// it is why `engine` is optional here and always present in the domain model. +// +// The output is committed to spec/v1/schemas/ and regenerated by the intent +// gate, which fails on a diff. An editor pointed at the committed file and the +// loader's own refusal therefore come from one declaration; when they last came +// from two, one of them was wrong and nothing said which. +import { z } from "zod"; +import { METAMODEL, Domain } from "./schema.ts"; + +/** Where the committed copy lives, relative to the repository root. */ +export const JSON_SCHEMA_PATH = "spec/v1/schemas/service-intent.schema.json"; + +/** + * The Service Intent JSON Schema: the document at the root, every other class + * of the metamodel under `$defs`, so a class the document does not reach (an + * `EnvFile`, a `Placeholder`) is still published and still generated from the + * same declaration. + */ +export function serviceIntentJsonSchema(): unknown { + const defs = Object.fromEntries( + Object.entries(METAMODEL.classes) + .filter(([name]) => name !== METAMODEL.document) + .map(([name, schema]) => [name, z.toJSONSchema(schema, { io: "input" })]), + ); + return { + ...(z.toJSONSchema(Domain, { io: "input" }) as Record), + title: `${METAMODEL.name}: ${METAMODEL.document}`, + $defs: defs, + }; +} + +/** The committed bytes: pretty-printed, newline-terminated, stable key order. */ +export function serviceIntentJsonSchemaText(): string { + return `${JSON.stringify(serviceIntentJsonSchema(), null, 2)}\n`; +} diff --git a/src/wire/service-intent/map.ts b/src/wire/service-intent/map.ts new file mode 100644 index 0000000..db13e6d --- /dev/null +++ b/src/wire/service-intent/map.ts @@ -0,0 +1,268 @@ +// The mapper: the authoring shape into the domain model. +// +// "The inferred type is **not** the domain model. An explicit mapper per +// document family converts the authoring shape into domain objects, and the +// domain imports no Zod" (docs/architecture.md#the-wire-boundary). This is that +// mapper for Service Intent, and it is total: it is only ever handed a value +// the schema has already accepted, so it decides nothing and refuses nothing. +// Every judgement is in schema.ts or in the rule registry, and none is here. +// +// Its one real job besides shape is **addressing**: every domain node comes out +// carrying `at`, its document path, built on the way down. That is what lets a +// rule report where a defect is without walking the document a second time. +import { at, child } from "../../domain/diagnostic.ts"; +import type { + Asset, + Capacity, + DependencyEdge, + DiskRequest, + Domain, + Exposure, + GpuRequest, + Grant, + Observability, + Placement, + Probe, + Probes, + Route, + Scrape, + Service, + Sidecar, + Surface, + Volume, + Workload, +} from "../../domain/service-intent/model.ts"; +import type { + DomainOutput, + GrantOutput, + ProbeOutput, + ServiceOutput, + WorkloadOutput, +} from "./schema.ts"; + +/** Drop the keys whose value is `undefined`, which `exactOptionalPropertyTypes` forbids. */ +function some(key: string, value: T | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +function mapProbe(wire: ProbeOutput, path: string): Probe { + return "tcp" in wire + ? { at: path, kind: "tcp", tcp: wire.tcp } + : { at: path, kind: "http", path: wire.path, port: wire.port }; +} + +function mapProbes(wire: WorkloadOutput["probes"], path: string): Probes { + if (wire === "none") return { kind: "none" }; + return { + kind: "declared", + ...some( + "readiness", + wire.readiness && mapProbe(wire.readiness, child(path, "readiness")), + ), + ...some( + "liveness", + wire.liveness && mapProbe(wire.liveness, child(path, "liveness")), + ), + }; +} + +function mapGrant(wire: GrantOutput, path: string): Grant { + const common = { + at: path, + delivery: wire.delivery, + rotation: { + at: child(path, "rotation"), + tolerates: wire.rotation.tolerates, + ...some("maxAge", wire.rotation.maxAge), + }, + }; + if (wire.engine === "transit") + return { + ...common, + engine: "transit", + key: wire.key, + operations: wire.operations, + }; + const projected = { + ...some("mountAt", wire.mountAt), + ...some("fileMode", wire.fileMode), + }; + if (wire.engine === "database") + return { ...common, engine: "database", role: wire.role, ...projected }; + return { + ...common, + engine: "kv", + path: wire.path, + keys: wire.keys, + access: wire.access, + ...projected, + }; +} + +/** + * `provides` is a map in the file and a list of Surfaces here. A surface is a + * class with a name and a port, and a route resolves against one; the map is + * only the shortest way to write it. + */ +function mapSurfaces( + provides: WorkloadOutput["provides"], + path: string, +): Surface[] { + return Object.entries(provides ?? {}).map(([name, port]) => ({ + at: child(path, name), + name, + port, + })); +} + +function mapPlacement( + wire: WorkloadOutput["placement"], + path: string, +): Placement { + const disk: DiskRequest | undefined = wire.disk && { + at: child(path, "disk"), + media: wire.disk.media, + }; + const gpu: GpuRequest | undefined = wire.gpu && { + at: child(path, "gpu"), + class: wire.gpu.class, + memory: wire.gpu.memory, + }; + return { + at: path, + memory: wire.memory, + cpu: wire.cpu, + ...some("arch", wire.arch), + ...some("site", wire.site), + ...some("disk", disk), + ...some("gpu", gpu), + ...some("capabilities", wire.capabilities), + }; +} + +function mapWorkload(wire: WorkloadOutput, path: string): Workload { + const list = ( + key: string, + values: readonly W[] | undefined, + each: (value: W, itemPath: string) => D, + ): D[] => + (values ?? []).map((value, index) => + each(value, at(child(path, key), index)), + ); + + const sidecars: Sidecar[] = list("sidecars", wire.sidecars, (s, p) => ({ + at: p, + name: s.name, + image: s.image, + memory: s.memory, + cpu: s.cpu, + })); + const dependsOn: DependencyEdge[] = list( + "dependsOn", + wire.dependsOn, + (edge, p) => ({ + at: p, + service: edge.service, + surface: edge.surface, + required: edge.required, + }), + ); + const assets: Asset[] = list("assets", wire.assets, (asset, p) => ({ + at: p, + from: asset.from, + mountAt: asset.mountAt, + })); + const volumes: Volume[] = list("volumes", wire.volumes, (volume, p) => ({ + at: p, + claim: volume.claim, + mountAt: volume.mountAt, + size: volume.size, + durability: volume.durability, + })); + const replicas: Capacity | undefined = wire.replicas && { + at: child(path, "replicas"), + count: wire.replicas.count, + reason: wire.replicas.reason, + }; + + return { + at: path, + name: wire.name, + lifecycle: wire.lifecycle, + image: wire.image, + runtime: wire.runtime, + ...some("engine", wire.engine), + startupBudget: wire.startupBudget, + cutover: wire.cutover, + writablePaths: wire.writablePaths ?? [], + provides: mapSurfaces(wire.provides, child(path, "provides")), + sidecars, + dependsOn, + probes: mapProbes(wire.probes, child(path, "probes")), + assets, + volumes, + placement: mapPlacement(wire.placement, child(path, "placement")), + ...some("replicas", replicas), + secrets: list("secrets", wire.secrets, mapGrant), + }; +} + +function mapService(wire: ServiceOutput, path: string): Service { + const scrape: Scrape | undefined = wire.observability?.scrape && { + at: child(child(path, "observability"), "scrape"), + workload: wire.observability.scrape.workload, + surface: wire.observability.scrape.surface, + path: wire.observability.scrape.path, + }; + const observability: Observability | undefined = wire.observability && { + at: child(path, "observability"), + alertClass: wire.observability.alertClass, + ...some("scrape", scrape), + }; + const exposure: Exposure[] = (wire.exposure ?? []).map((entry, index) => { + const entryPath = at(child(path, "exposure"), index); + const routes: Route[] = entry.routes.map((route, i) => ({ + at: at(child(entryPath, "routes"), i), + path: route.path, + match: route.match, + workload: route.workload, + surface: route.surface, + ...some("audience", route.audience), + ...some("redirectTo", route.redirectTo), + })); + return { + at: entryPath, + name: entry.name, + host: entry.host, + audience: entry.audience, + ...some("contentPolicy", entry.contentPolicy), + routes, + }; + }); + + return { + at: path, + id: wire.id, + ...some("observability", observability), + exposure, + workloads: wire.workloads.map((workload, index) => + mapWorkload(workload, at(child(path, "workloads"), index)), + ), + secrets: (wire.secrets ?? []).map((grant, index) => + mapGrant(grant, at(child(path, "secrets"), index)), + ), + }; +} + +/** One validated authoring document, as the domain model. */ +export function mapDomain(wire: DomainOutput): Domain { + return { + at: "", + schemaVersion: wire.schemaVersion, + domain: wire.domain, + owner: wire.owner, + services: wire.services.map((service, index) => + mapService(service, at("services", index)), + ), + }; +} diff --git a/src/wire/service-intent/read.ts b/src/wire/service-intent/read.ts new file mode 100644 index 0000000..7f90486 --- /dev/null +++ b/src/wire/service-intent/read.ts @@ -0,0 +1,93 @@ +// Concrete syntax: bytes to an instance of the metamodel. +// +// Three stages, in order, each reporting everything it finds before the next +// runs, and none of them throwing: +// +// 1. YAML parses at all -> kind `syntax` +// 2. the document is an instance of the schema -> kind `schema` +// 3. the instance is mapped into the domain -> total, decides nothing +// +// A `schema` diagnostic carries no `E_` code, and that is the specification's +// own choice: a value outside a closed vocabulary is "refused before +// composition runs, so no new error code carries this case" +// (spec/v1/examples/refusals/alert-class-unknown.domain.yml). What it does +// carry is the **document path**, which is the half the old regex checks could +// never produce. +import { parseDocument } from "yaml"; +import type { $ZodIssue } from "zod/v4/core"; +import { + accepted, + refused, + type Diagnostic, + type Result, +} from "../../domain/diagnostic.ts"; +import type { Domain } from "../../domain/service-intent/model.ts"; +import { mapDomain } from "./map.ts"; +import { Domain as DomainSchema } from "./schema.ts"; + +/** + * A zod issue path as a document path. `["services", 0, "workloads", 1, + * "cutover"]` reads `services[0].workloads[1].cutover`, which is what a reader + * looks for in the file. + */ +export function documentPath(path: readonly PropertyKey[]): string { + return path.reduce((out, segment) => { + if (typeof segment === "number") return `${out}[${segment}]`; + const name = String(segment); + return out === "" ? name : `${out}.${name}`; + }, ""); +} + +/** + * One zod issue as one Diagnostic. An `unrecognized_keys` issue is reported on + * the object rather than on the key, so the key is appended: an unknown key's + * address is the key, which is the thing the author has to delete. + */ +function toDiagnostics(issue: $ZodIssue, document: string): Diagnostic[] { + const base = documentPath(issue.path); + const one = (path: string, message: string): Diagnostic => ({ + code: "schema", + kind: "schema", + document, + at: path === "" ? "(document)" : path, + message, + }); + if (issue.code === "unrecognized_keys") + return issue.keys.map((key) => + one( + documentPath([...issue.path, key]), + `no class of the Service Intent metamodel carries ${key}`, + ), + ); + return [one(base, issue.message)]; +} + +/** + * Read one Service Intent document: YAML in, the domain model or every reason + * there is not one out. `document` is the file's own path, and it is what a + * diagnostic names. + */ +export function readDomain(text: string, document: string): Result { + const parsed = parseDocument(text, { uniqueKeys: true }); + const broken = [...parsed.errors, ...parsed.warnings]; + if (broken.length > 0) + return refused( + broken.map((error) => ({ + code: "syntax" as const, + kind: "syntax" as const, + document, + // A YAML error is addressed by offset, because there is no document + // path yet: nothing parsed far enough to have one. + at: `offset[${error.pos[0]}]`, + message: error.message, + })), + ); + + const result = DomainSchema.safeParse(parsed.toJS()); + if (!result.success) + return refused( + result.error.issues.flatMap((issue) => toDiagnostics(issue, document)), + ); + + return accepted(mapDomain(result.data)); +} diff --git a/src/wire/service-intent/schema.ts b/src/wire/service-intent/schema.ts new file mode 100644 index 0000000..abd79eb --- /dev/null +++ b/src/wire/service-intent/schema.ts @@ -0,0 +1,483 @@ +// The Service Intent metamodel, declared once. +// +// This file IS the language definition's first two parts. The Zod schemas +// below are the abstract syntax written as the authoring shape (the concrete +// syntax a human types in YAML), and they are the single declaration from +// which the runtime check, the TypeScript type, the generated JSON Schema, +// and, with issue #40, chapter 10's class diagram and its field and vocabulary +// tables are all produced. There is no second copy anywhere in this +// repository, and a test holds that true. +// +// One export per class chapter 10's class diagram draws, named exactly as the +// diagram names it, plus `METAMODEL` at the bottom, which is the enumerable +// record a generator walks. +// +// Three shapes are worth reading before the rest: +// +// - **Unions are unions.** A Grant is a discriminated union on `engine` +// (0085) and a Probe is http or tcp. That is what makes "a `kv` field on a +// `transit` grant" a document that is not an instance of the model, rather +// than a rule somebody has to remember to write. +// - **Every object is strict.** An unrecognised key is a refusal, with the +// document path of the object that carried it. Layer 1 has no +// passthrough, no annotations map and no escape hatch shaped like one +// (chapter 10, The authored proxy vocabulary is two fields), and +// `strictObject` is where that closure is actually enforced. +// - **A closed vocabulary is an enum and nowhere else.** The seventeen lists +// live in vocabularies.ts, which this file is the only consumer of. +import { z } from "zod"; +import { + AccessTier, + AlertClass, + Arch, + Audience, + ContentPolicy, + Cutover, + Delivery, + DurabilityClass, + Engine, + Lifecycle, + Match, + Media, + PlaceholderKind, + Runtime, + Tolerance, + TransitOp, +} from "./vocabularies.ts"; + +// ---------------------------------------------------------------- named types +// +// "Every other attribute type is either a primitive or a named string this +// chapter constrains" (chapter 10, The closed vocabularies). Each is declared +// once here so that the class diagram's type names and the JSON Schema's +// patterns come from the same place. + +const nonEmpty = (what: string): z.ZodString => + z.string().min(1, `${what} may not be empty`); + +/** A DNS label: the domain header, and what `-system` is built from. */ +export const DomainName = z + .string() + .regex(/^[a-z][a-z0-9-]*[a-z0-9]$/, "a domain is a lowercase DNS label"); +/** The one referencable identity, estate-unique (0010). */ +export const ServiceId = z + .string() + .regex(/^[a-z][a-z0-9-]*[a-z0-9]$/, "a Service id is a lowercase DNS label"); +/** A Workload or sidecar container name. */ +export const ContainerName = z + .string() + .regex(/^[a-z][a-z0-9-]*[a-z0-9]$/, "a container name is a DNS label"); +/** An alias the images lock resolves to a digest: never a tag, never a digest. */ +export const ImageAlias = z + .string() + .regex( + /^[a-z0-9][a-z0-9._-]*$/, + "an image is an alias the lock resolves, never a tag and never a digest", + ) + .refine( + (value) => !value.includes(":") && !value.includes("@"), + "an image alias carries no tag and no digest", + ); +/** A surface name: the key of a `provides` map, and what a route resolves to. */ +export const SurfaceName = z + .string() + .regex(/^[a-z][a-z0-9-]*$/, "a surface name is a lowercase name"); +/** The full FQDN a Service serves, written out. There is no zone rule. */ +export const Fqdn = z + .string() + .regex( + /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/, + "a host is a full FQDN", + ); +/** Unique within the Service; the second half of `${exposure:.}`. */ +export const ExposureName = z + .string() + .regex(/^[a-z][a-z0-9-]*$/, "an exposure name is a lowercase name"); +/** A Secret Store path. The grant unit (0023). */ +export const VaultPath = nonEmpty("a Secret Store path"); +/** A cluster this estate targets, naming an env overlay. */ +export const ClusterTarget = nonEmpty("a Cluster Target"); +/** A site the node contract publishes. */ +export const Site = nonEmpty("a site"); +/** A capability a node advertises, as a flat string. */ +export const Capability = z + .string() + .regex(/^[a-z][a-z0-9-]*$/, "a capability is a flat lowercase string"); +/** A GPU class the node contract publishes as `gpus[].class`. */ +export const GpuClassName = nonEmpty("a gpu class"); +/** An absolute filesystem or URL path. */ +export const AbsolutePath = z + .string() + .regex(/^\//, "a path is absolute, and starts with /"); +/** A path relative to the authoring repository, for an Asset's source. */ +export const RelativePath = z + .string() + .regex(/^[^/].*$/, "an asset source is relative to the repository"); +/** A Kubernetes quantity: `768Mi`, `250m`, `2Gi`, `20Gi`. */ +export const Quantity = z + .string() + .regex( + /^\d+(\.\d+)?(m|k|Ki|M|Mi|G|Gi|T|Ti|P|Pi)?$/, + "a quantity like 768Mi or 250m", + ); +/** A duration: `600s`, `168h`. */ +export const Duration = z + .string() + .regex(/^\d+(s|m|h)$/, "a duration like 600s or 168h"); +/** A projected file's mode, quoted so YAML does not read it as octal. */ +export const FileMode = z + .string() + .regex(/^0[0-7]{3}$/, 'a file mode like "0400", quoted'); +/** The data model's own semver, not the toolkit package's (0039). */ +export const SemVer = z + .string() + .regex(/^\d+\.\d+\.\d+$/, "the data model's own semver"); +/** + * One key of a granted Secret Store path. + * + * `keys: ['*']` is refused here rather than by a rule, because "it is not in + * the grammar: a document carrying it fails schema validation" (chapter 10, + * Validation). A wildcard makes a reader set undecidable without reading live + * Vault contents, which the pinned-input rule forbids. + */ +export const SecretKey = z + .string() + .regex( + /^[A-Za-z0-9][A-Za-z0-9._-]*$/, + "enumerate the keys; there is no wildcard", + ); +/** A TCP port. Above 1024 is a rule, not a type: see E_PRIVILEGED_PORT_UNDER_NONROOT. */ +export const Port = z.int().min(1).max(65535); + +// ------------------------------------------------------------------- classes + +/** A sidecar container: no identity, no probes, no exposure, no release of its own. */ +export const Sidecar = z.strictObject({ + name: ContainerName, + image: ImageAlias, + memory: Quantity, + cpu: Quantity, +}); + +/** An edge to `{service, surface}`, declared per Workload (0020). */ +export const DependencyEdge = z.strictObject({ + service: ServiceId, + surface: SurfaceName, + required: z.boolean().default(true), +}); + +/** + * Readiness and liveness are siblings, each carrying its own endpoint, and + * neither falls back to the other (0014). A service with no HTTP surface uses + * `tcp`, which is a port and not a decoration. + */ +export const Probe = z.union([ + z.strictObject({ path: AbsolutePath, port: Port }), + z.strictObject({ tcp: Port }), +]); + +/** + * `probes: none` is an authored value. A Workload with no listener declares the + * absence, so a forgotten probe block is never mistaken for a deliberate one. + */ +export const Probes = z.union([ + z.literal("none"), + z.strictObject({ + readiness: Probe.optional(), + liveness: Probe.optional(), + }), +]); + +/** A declarative settings file in the consuming application's own format (0012). */ +export const Asset = z.strictObject({ + from: RelativePath, + mountAt: AbsolutePath, +}); + +/** A claim, what it is worth, and how much of it there is (0015, 0081). */ +export const Volume = z.strictObject({ + claim: ContainerName, + mountAt: AbsolutePath, + size: Quantity, + durability: DurabilityClass, +}); + +/** The media term of a placement. The capacity term is derived from `volumes`. */ +export const DiskRequest = z.strictObject({ media: z.array(Media).min(1) }); + +/** `class` and `memory`, because a flat capability string cannot describe a GPU. */ +export const GpuRequest = z.strictObject({ + class: GpuClassName, + memory: Quantity, +}); + +/** + * Six hard dimensions and a flat capability set (0061). `memory` and `cpu` are + * required on every Workload; every other term defaults to any node. There is + * no soft half: no weight, no ordering, and no second shape the scheduler is + * free to discard. + */ +export const Placement = z.strictObject({ + memory: Quantity, + cpu: Quantity, + arch: z.array(Arch).min(1).optional(), + site: Site.optional(), + disk: DiskRequest.optional(), + gpu: GpuRequest.optional(), + capabilities: z.array(Capability).min(1).optional(), +}); + +/** + * The only local exception to a derived value in layer 1, and narrow on + * purpose: `count` must exceed one, so the field cannot become a verbose + * spelling of the default, and `reason` is required, because a capacity + * decision is data rather than a YAML comment no tool can read (0031, 0089). + */ +export const Capacity = z.strictObject({ + count: z + .int() + .min(2, "replicas derives as 1; the block is for a count above one"), + reason: nonEmpty("a replicas block states its reason"), +}); + +/** What the consumer can survive when the value changes, and how stale it may get. */ +export const Rotation = z.strictObject({ + tolerates: Tolerance, + maxAge: Duration.optional(), +}); + +const grantCommon = { + delivery: Delivery, + rotation: Rotation, +}; + +const projected = { + mountAt: AbsolutePath.optional(), + fileMode: FileMode.optional(), +}; + +/** + * A grant is a discriminated union on `engine`, because the estate uses three + * and they authorise different things (0085). `engine` defaults to `kv`, so + * every grant written before that decision stays valid. + * + * The four access tiers are KV intents and nothing else: a `transit` grant + * declares `operations` instead, and a `database` grant declares a role and + * takes no tier at all, because the engine issues the credential. + */ +export const Grant = z + .discriminatedUnion("engine", [ + z.strictObject({ + engine: z.literal("kv").default("kv"), + path: VaultPath, + keys: z.array(SecretKey).min(1), + access: AccessTier, + ...projected, + ...grantCommon, + }), + z.strictObject({ + engine: z.literal("database"), + role: nonEmpty("a database role"), + ...projected, + ...grantCommon, + }), + z.strictObject({ + engine: z.literal("transit"), + key: nonEmpty("a transit key name"), + operations: z.array(TransitOp).min(1), + ...grantCommon, + }), + ]) + .superRefine((grant, ctx) => { + // "`mountAt`, `fileMode` | `file` only" (chapter 10, Secrets). A projected + // file's landing place means nothing for a value that is never projected, + // and a field that is quietly ignored is the shape this model exists to + // remove. + if (grant.delivery === "file" || grant.engine === "transit") return; + for (const key of ["mountAt", "fileMode"] as const) + if (grant[key] !== undefined) + ctx.addIssue({ + code: "custom", + path: [key], + message: `${key} belongs to delivery: file, and this grant is delivery: ${grant.delivery}`, + }); + }); + +/** One path of a host, and which of this Service's Workloads serves it. */ +export const Route = z.strictObject({ + path: AbsolutePath, + match: Match, + workload: ContainerName, + surface: SurfaceName, + audience: Audience.optional(), + redirectTo: AbsolutePath.optional(), +}); + +/** + * This hostname routes here. It sits on the Service, because one hostname + * fronts two processes in the live `auth` case, and it carries its own routing. + */ +export const Exposure = z.strictObject({ + name: ExposureName, + host: Fqdn, + audience: Audience, + contentPolicy: ContentPolicy.optional(), + routes: z.array(Route).min(1), +}); + +/** Which Workload publishes the signal, on which surface, at which path. */ +export const Scrape = z.strictObject({ + workload: ContainerName, + surface: SurfaceName, + path: AbsolutePath, +}); + +/** + * Whole or absent (0021). `scrape` is optional **in the schema** so that the + * half-declared block is representable and can be refused by the name the + * specification gives it, `E_ALERT_CLASS_WITHOUT_SIGNAL`. Making it required + * here would refuse the same documents and report `schema`, losing the code a + * refusal fixture expects and #44 registers. + */ +export const Observability = z.strictObject({ + alertClass: AlertClass, + scrape: Scrape.optional(), +}); + +/** + * One process. `provides` is a flat map of surface name to port, because a port + * is an integer written where it is used and a port is a property of a process. + */ +export const Workload = z + .strictObject({ + name: ContainerName, + lifecycle: Lifecycle, + image: ImageAlias, + runtime: Runtime, + engine: Engine.optional(), + startupBudget: Duration, + cutover: Cutover, + writablePaths: z.array(AbsolutePath).min(1).optional(), + provides: z.record(SurfaceName, Port).optional(), + sidecars: z.array(Sidecar).min(1).optional(), + dependsOn: z.array(DependencyEdge).min(1).optional(), + probes: Probes, + assets: z.array(Asset).min(1).optional(), + volumes: z.array(Volume).min(1).optional(), + placement: Placement, + replicas: Capacity.optional(), + secrets: z.array(Grant).min(1).optional(), + }) + .superRefine((workload, ctx) => { + // "Timings, thresholds and deadlines stay derived. A Workload that declares + // ports but no probe declaration is refused" (chapter 10, Probes). The + // chapter names no code for it, so it is a shape rule rather than a + // registered rule, and it reports as `schema`. + const listens = Object.keys(workload.provides ?? {}).length > 0; + const declares = + workload.probes !== "none" && + (workload.probes.readiness !== undefined || + workload.probes.liveness !== undefined); + if (listens && !declares) + ctx.addIssue({ + code: "custom", + path: ["probes"], + message: + "a Workload that declares ports declares a probe; write `probes: none` " + + "only where there is nothing to probe", + }); + // A sidecar may not take the Workload's own name: the Workload is one of + // the pod's containers. + for (const [index, sidecar] of (workload.sidecars ?? []).entries()) + if (sidecar.name === workload.name) + ctx.addIssue({ + code: "custom", + path: ["sidecars", index, "name"], + message: "a sidecar may not take the Workload's own container name", + }); + }); + +/** A product: one or more Workloads that switch together (0062). */ +export const Service = z.strictObject({ + id: ServiceId, + observability: Observability.optional(), + exposure: z.array(Exposure).min(1).optional(), + workloads: z.array(Workload).min(1), + secrets: z.array(Grant).min(1).optional(), +}); + +/** + * One authored file: one domain, one Intent Fragment (0063). + * + * `apiVersion` and `kind` are the header chapter 10 states, and they are + * required: the namespace `intent.jorisjonkers.dev` exists precisely because + * three mutually incompatible documents shared one, which is the defect 0003 + * exists to fix. A document that does not say which language it is written in + * cannot be held to one. + */ +export const Domain = z.strictObject({ + apiVersion: z.literal("intent.jorisjonkers.dev/v1"), + kind: z.literal("Domain"), + schemaVersion: SemVer, + domain: DomainName, + owner: nonEmpty("an owner"), + services: z.array(Service).min(1), +}); + +/** The authoring shape: what a human writes, before defaults are filled. */ +export type DomainInput = z.input; +/** The validated shape: what the mapper reads, with every default present. */ +export type DomainOutput = z.output; +export type WorkloadOutput = z.output; +export type ServiceOutput = z.output; +export type GrantOutput = z.output; +export type ProbeOutput = z.output; + +/** + * The metamodel, enumerable. + * + * `root` is the document class; `classes` is every class chapter 10's diagram + * draws, keyed by the name the diagram uses. Issue #40 generates the mermaid + * block, the drawio drawing and the chapter's field tables by walking this, and + * issue #41 declares Platform Intent's own record beside it in the same shape. + * + * `EnvFile` and `Placeholder` are in the record and are not reachable from + * `Domain`: layer 1 is authored as **two** artefacts (chapter 10, Two + * artefacts), and the second one is a dotenv file rather than YAML. Its + * grammar is in env-file.ts, and its classes are declared here so the drawing + * and the tables cover the whole language rather than the half of it that + * happens to be YAML. + */ +export const METAMODEL = { + name: "Service Intent", + document: "Domain", + classes: { + Domain, + Service, + Observability, + Scrape, + Workload, + Capacity, + Sidecar, + DependencyEdge, + Probe, + Asset, + Volume, + Placement, + DiskRequest, + GpuRequest, + Exposure, + Route, + Grant, + Rotation, + EnvFile: z.strictObject({ + cluster: ClusterTarget.optional(), + entries: z.record(z.string(), z.string()), + }), + Placeholder: z.strictObject({ + kind: PlaceholderKind, + source: z.string(), + }), + Surface: z.strictObject({ name: SurfaceName, port: Port }), + }, +} as const; diff --git a/src/wire/service-intent/vocabularies.ts b/src/wire/service-intent/vocabularies.ts new file mode 100644 index 0000000..55c32da --- /dev/null +++ b/src/wire/service-intent/vocabularies.ts @@ -0,0 +1,130 @@ +// The seventeen closed vocabularies of chapter 10, declared once. +// +// "Values are **exhaustive**: one absent from a list here fails schema +// validation, and adding one is a change to this chapter." The chapter is the +// normative statement; this is its single machine-readable declaration, and no +// other module in this repository may re-type one of these lists. A test +// constant spelling out `AlertClass` a second time is exactly the drift the +// vocabulary table exists to prevent, and issue #38 deletes the one that +// existed. +// +// The record at the bottom is what issue #40 renders the chapter's vocabulary +// table from: name, the attributes it types, and the values, in the chapter's +// own order. +import { z } from "zod"; + +export const Lifecycle = z.enum(["service", "job"]); +export const Runtime = z.enum(["jvm", "python", "node", "static", "none"]); +export const Engine = z.enum(["postgres", "rabbitmq", "valkey", "files"]); +export const Cutover = z.enum(["rolling", "recreate"]); +export const DurabilityClass = z.enum([ + "reconstructible", + "recoverable", + "irreplaceable", +]); +export const Arch = z.enum(["amd64", "arm64"]); +export const Media = z.enum(["nvme", "ssd", "hdd"]); +export const AlertClass = z.enum(["business-hours", "urgent", "page"]); +export const Audience = z.enum([ + "anonymous", + "authenticated", + "internal", + "lan", +]); +export const ContentPolicy = z.enum(["strict", "admin", "workflow"]); +export const Match = z.enum(["prefix", "exact"]); +export const SecretEngine = z.enum(["kv", "database", "transit"]); +export const AccessTier = z.enum([ + "read", + "self-renew", + "self-roll", + "custody", +]); +export const TransitOp = z.enum([ + "sign", + "verify", + "encrypt", + "decrypt", + "rotate", +]); +export const Delivery = z.enum(["env", "file", "self"]); +export const Tolerance = z.enum(["restart", "reload"]); +export const PlaceholderKind = z.enum([ + "secret", + "dependency", + "exposure", + "identity", +]); + +/** One row of chapter 10's closed-vocabulary table. */ +export interface Vocabulary { + /** The type name the class diagram uses. */ + readonly name: string; + /** The `Class.attribute` spellings this vocabulary types. */ + readonly namedBy: readonly string[]; + readonly values: readonly string[]; +} + +/** + * Every closed vocabulary, in the chapter's order. Seventeen is not a magic + * number: it is what the chapter says it holds, and a test compares the two. + */ +export const CLOSED_VOCABULARIES: readonly Vocabulary[] = [ + { + name: "Lifecycle", + namedBy: ["Workload.lifecycle"], + values: Lifecycle.options, + }, + { name: "Runtime", namedBy: ["Workload.runtime"], values: Runtime.options }, + { name: "Engine", namedBy: ["Workload.engine"], values: Engine.options }, + { name: "Cutover", namedBy: ["Workload.cutover"], values: Cutover.options }, + { + name: "DurabilityClass", + namedBy: ["Volume.durability"], + values: DurabilityClass.options, + }, + { name: "Arch", namedBy: ["Placement.arch"], values: Arch.options }, + { name: "Media", namedBy: ["DiskRequest.media"], values: Media.options }, + { + name: "AlertClass", + namedBy: ["Observability.alertClass"], + values: AlertClass.options, + }, + { + name: "Audience", + namedBy: ["Exposure.audience", "Route.audience"], + values: Audience.options, + }, + { + name: "ContentPolicy", + namedBy: ["Exposure.contentPolicy"], + values: ContentPolicy.options, + }, + { name: "Match", namedBy: ["Route.match"], values: Match.options }, + { + name: "SecretEngine", + namedBy: ["Grant.engine"], + values: SecretEngine.options, + }, + { + name: "AccessTier", + namedBy: ["Grant.access"], + values: AccessTier.options, + }, + { + name: "TransitOp", + namedBy: ["Grant.operations"], + values: TransitOp.options, + }, + { name: "Delivery", namedBy: ["Grant.delivery"], values: Delivery.options }, + { + name: "Tolerance", + namedBy: ["Rotation.tolerates"], + values: Tolerance.options, + }, + { + name: "PlaceholderKind", + namedBy: ["Placeholder.kind"], + values: PlaceholderKind.options, + }, +]; diff --git a/test/diagram-model-consistency.test.ts b/test/diagram-model-consistency.test.ts index 9c07ba2..27c9d81 100644 --- a/test/diagram-model-consistency.test.ts +++ b/test/diagram-model-consistency.test.ts @@ -8,6 +8,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { expect, test } from "vitest"; +import { METAMODEL } from "../src/wire/service-intent/schema.ts"; const repo = join(import.meta.dirname, ".."); const spec = join(repo, "spec", "v1"); @@ -159,85 +160,89 @@ test("every closed vocabulary names an attribute that exists", () => { expect(rows, "the vocabulary table did not parse").toBeGreaterThan(10); }); -test("a worked example authors no key the model does not carry", () => { +/** + * Attributes the metamodel declares that the drawing deliberately does not draw + * as a row, and why. The chapter says the diagram "carries the classes and how + * they compose, and nothing else", so a composed child is an edge rather than a + * row and is excluded by the edge set below. These are the ones left over, and + * naming each with its reason is what stops a third joining them silently. + */ +const NOT_DRAWN_AS_A_ROW: Readonly< + Record>> +> = { + Domain: { + apiVersion: "the document's language tag, not something the model says", + kind: "the same: which language this file is written in", + }, + Workload: { + probes: + "drawn as two edges, readiness and liveness, because a Probe is a class " + + "and the two are siblings that never fall back to one another", + }, +}; + +/** Every attribute name a Zod schema declares, unioning a union's arms. */ +function declaredKeys(schema: unknown): string[] { + const def = (schema as { _zod?: { def?: Record } })._zod + ?.def; + if (def === undefined) return []; + if (def["type"] === "object") + return Object.keys(def["shape"] as Record); + if (Array.isArray(def["options"])) + return [...new Set((def["options"] as unknown[]).flatMap(declaredKeys))]; + if (def["innerType"] !== undefined) return declaredKeys(def["innerType"]); + return []; +} + +/** The composition-edge labels the mermaid draws out of each parent class. */ +function composedOut(): Record> { + const body = + /```mermaid\nclassDiagram\n([\s\S]*?)\n```/.exec( + read(join(spec, "10-service-intent.md")), + )?.[1] ?? ""; + const out: Record> = {}; + for (const m of body.matchAll(/(\w+) "[^"]+" \*-- "[^"]+" \w+ : (.+)/g)) + (out[m[1] ?? ""] ??= new Set()).add((m[2] ?? "").trim()); + return out; +} + +test("the class diagram draws the classes and attributes the metamodel declares", () => { + // What the old check did by reading YAML indentation ("a worked example + // authors no key the model does not carry") the metamodel now does by + // construction: every class is a strict object, so a key no class carries is + // a document that does not parse, and test/intent-contract.test.ts proves it + // over every example at once. What is left for this file is the half that is + // genuinely two copies: the drawing and the declaration. const { classes } = mermaidModel(); - const attributes = new Set( - Object.values(classes).flatMap((rows) => - rows.map((row) => row.split(" ").pop() ?? ""), - ), - ); - // Keys that structure the document rather than name an attribute, plus the - // author-chosen surface names under `provides` and the two probe roles. - const structural = new Set([ - "apiVersion", - "kind", - "schemaVersion", - "domain", - "owner", - "services", - "workloads", - "provides", - "probes", - "placement", - "volumes", - "secrets", - "assets", - "exposure", - "routes", - "sidecars", - "observability", - "scrape", - "replicas", - "rotation", - "disk", - "gpu", - "dependsOn", - "env", - "expect", - "readiness", - "liveness", - ]); - const domainFiles = walk(join(spec, "examples")).filter((f) => - f.endsWith(".domain.yml"), - ); + expect( + Object.keys(classes).sort(), + "the drawing and the metamodel disagree about which classes exist", + ).toStrictEqual(Object.keys(METAMODEL.classes).sort()); - const surfaces = new Set(); - for (const file of domainFiles) { - const lines = read(file).split("\n"); - lines.forEach((line, i) => { - if (!/^ {8}provides:/.test(line)) return; - for (const next of lines.slice(i + 1)) { - const m = /^ {10}([a-zA-Z][\w-]*):\s*\d+/.exec( - next.split("#")[0] ?? "", - ); - if (!m) break; - surfaces.add(m[1] ?? ""); - } - }); + const edges = composedOut(); + const wrong: string[] = []; + for (const [name, rows] of Object.entries(classes)) { + const declared = new Set( + declaredKeys(METAMODEL.classes[name as keyof typeof METAMODEL.classes]), + ); + const drawn = new Set(rows.map((row) => row.split(" ").pop() ?? "")); + for (const attribute of drawn) + if (!declared.has(attribute)) + wrong.push(`${name}.${attribute}: drawn, and no class declares it`); + for (const attribute of declared) { + if (drawn.has(attribute)) continue; + if (edges[name]?.has(attribute) === true) continue; + if (attribute in (NOT_DRAWN_AS_A_ROW[name] ?? {})) continue; + wrong.push(`${name}.${attribute}: declared, and the drawing omits it`); + } } + expect(wrong, "the drawing and the metamodel disagree").toStrictEqual([]); +}); - const unknown: string[] = []; - for (const file of domainFiles) { - // A folded scalar's body is prose, not keys: `reason: >-` is followed by - // sentences, and one of them contains the word "availability:". - let fold = -1; - read(file) - .split("\n") - .forEach((line, i) => { - const indent = line.search(/\S/); - if (fold >= 0 && (indent === -1 || indent > fold)) return; - fold = -1; - const code = line.split("#")[0] ?? ""; - if (/:\s*[|>][-+]?\s*$/.test(code)) fold = indent; - for (const m of code.matchAll(/([a-zA-Z][a-zA-Z0-9_]*)\s*:/g)) { - const key = m[1] ?? ""; - if (attributes.has(key) || structural.has(key) || surfaces.has(key)) - continue; - unknown.push(`${relative(repo, file)}:${i + 1}: authors \`${key}\``); - } - }); - } - expect(unknown, "keys no class carries").toStrictEqual([]); +test("every excluded attribute says why it is excluded", () => { + for (const [name, entries] of Object.entries(NOT_DRAWN_AS_A_ROW)) + for (const [attribute, why] of Object.entries(entries)) + expect(why.length, `${name}.${attribute}`).toBeGreaterThan(20); }); test("every rendered kind is a column of the deliverables matrix", () => { diff --git a/test/intent-contract.test.ts b/test/intent-contract.test.ts new file mode 100644 index 0000000..ac7b40a --- /dev/null +++ b/test/intent-contract.test.ts @@ -0,0 +1,785 @@ +// The Service Intent metamodel, executed. +// +// Three things are proved here and nowhere else: +// +// 1. every Service Intent document in this repository conforms, or fails with +// exactly the code its `expect:` header names; +// 2. every rule in the registry fires, with its own code and no other, on a +// document that violates it, and does not fire on the document beside it +// that does not; +// 3. the metamodel is the single declaration: the chapter's closed-vocabulary +// table, its class diagram and the committed JSON Schema all agree with it, +// because all three are read from it. +// +// REQ-015 (docs/requirements.md): every Service Intent document is parsed +// against one declared metamodel. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + parseServiceIntent, + conforms, +} from "../src/application/parse-service-intent.ts"; +import { accepted, at, child, refused } from "../src/domain/diagnostic.ts"; +import type { Diagnostic } from "../src/domain/diagnostic.ts"; +import { + derivesBackup, + grantsOf, + workloadsOf, +} from "../src/domain/service-intent/model.ts"; +import { + NOT_DECIDED_BY_ONE_DOCUMENT, + SERVICE_INTENT_RULES, +} from "../src/domain/service-intent/rules.ts"; +import { parseEnvFile } from "../src/wire/service-intent/env-file.ts"; +import { + JSON_SCHEMA_PATH, + serviceIntentJsonSchema, + serviceIntentJsonSchemaText, +} from "../src/wire/service-intent/json-schema.ts"; +import { documentPath, readDomain } from "../src/wire/service-intent/read.ts"; +import { METAMODEL } from "../src/wire/service-intent/schema.ts"; +import { CLOSED_VOCABULARIES } from "../src/wire/service-intent/vocabularies.ts"; +import { intentDocuments, lintIntent } from "../scripts/lint-intent.ts"; + +const REPOSITORY = join(import.meta.dirname, ".."); +const CHAPTER = readFileSync( + join(REPOSITORY, "spec", "v1", "10-service-intent.md"), + "utf8", +); + +/** + * The smallest conforming document, as text, so a test can mutate one line and + * say which rule the mutation reaches. Written out rather than built from the + * worked examples: a fixture that drifts with an example proves nothing about + * the example. + */ +const BASE = `apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain +schemaVersion: 1.0.0 +domain: fixture +owner: joris +services: + - id: fixture + workloads: + - name: fixture-api + lifecycle: service + image: fixture-api + runtime: node + provides: {http: 8080} + placement: {memory: 128Mi, cpu: 25m} + probes: + readiness: {path: /healthz, port: 8080} + liveness: {path: /healthz, port: 8080} + startupBudget: 20s + cutover: rolling +`; + +/** Parse `text` as a fixture document, and return the codes it was refused with. */ +function codes(text: string): string[] { + const result = parseServiceIntent(text, "fixture.yml"); + return result.ok ? [] : [...new Set(result.diagnostics.map((d) => d.code))]; +} + +/** Parse `text` and return the diagnostics, failing loudly if it was accepted. */ +function refusal(text: string): readonly Diagnostic[] { + const result = parseServiceIntent(text, "fixture.yml"); + if (result.ok) + throw new Error("expected a refusal, got an accepted document"); + return result.diagnostics; +} + +/** `BASE` with `from` replaced by `to`, asserting the anchor was actually there. */ +function mutate(from: string, to: string, base = BASE): string { + expect(base, `the fixture no longer holds ${from}`).toContain(from); + return base.replace(from, to); +} + +describe("the base fixture", () => { + it("conforms, so every mutation below has exactly one defect", () => { + expect(codes(BASE)).toStrictEqual([]); + expect(conforms(BASE, "fixture.yml")).toBe(true); + }); +}); + +describe("every Service Intent document in the repository", () => { + const result = lintIntent(REPOSITORY); + + it("conforms, or fails with exactly the code its expect header names", () => { + expect(result.errors).toStrictEqual([]); + }); + + it("is a set worth checking, not an empty one", () => { + expect(result.documents).toBeGreaterThanOrEqual(11); + expect(result.envFiles).toBeGreaterThanOrEqual(5); + expect(intentDocuments(REPOSITORY).length).toBe(result.documents); + }); + + it("includes the auth worked example, parsed into the domain model", () => { + const file = join( + REPOSITORY, + "spec/v1/examples/auth/auth.domain.yml".replaceAll("/", "/"), + ); + const parsed = parseServiceIntent(readFileSync(file, "utf8"), file); + if (!parsed.ok) throw new Error(parsed.diagnostics[0]?.message); + const auth = parsed.value.services[0]; + expect(auth?.id).toBe("auth"); + expect(auth?.workloads.map((w) => w.name)).toStrictEqual([ + "auth-api", + "auth-ui", + ]); + // One hostname, two Workloads: the case that forced `exposure` onto the + // Service. A route resolves by name, so the surfaces are what it reaches. + expect(auth?.exposure[0]?.routes.map((r) => r.workload)).toStrictEqual([ + "auth-api", + "auth-ui", + ]); + // `provides` is a map in the file and a Surface list in the domain. + expect( + auth?.workloads[0]?.provides.map((s) => `${s.name}:${s.port}`), + ).toStrictEqual(["http:8080"]); + // The transit grant is an arm of the union, not a shape with seven + // optional fields. + const transit = auth?.workloads[0]?.secrets.find( + (g) => g.engine === "transit", + ); + expect(transit?.engine).toBe("transit"); + expect(workloadsOf(parsed.value)).toHaveLength(2); + expect( + grantsOf(auth as NonNullable, auth?.workloads[1] as never), + ).toHaveLength(0); + }); +}); + +describe("the rule registry", () => { + it("gives each code exactly one entry, which is what #44 registers against", () => { + const all = SERVICE_INTENT_RULES.map((rule) => rule.code); + expect(new Set(all).size, all.join(", ")).toBe(all.length); + }); + + it("names a heading of chapter 10 for every rule", () => { + const headings = new Set( + [...CHAPTER.matchAll(/^#{2,4} (.+)$/gm)].map((m) => + (m[1] as string) + .toLowerCase() + .replace(/[^a-z0-9 -]/g, "") + .replace(/ /g, "-"), + ), + ); + for (const rule of SERVICE_INTENT_RULES) + expect(headings, `${rule.code}: ${rule.anchor}`).toContain(rule.anchor); + }); + + it("constrains a class the metamodel declares", () => { + for (const rule of SERVICE_INTENT_RULES) + expect( + Object.keys(METAMODEL.classes), + `${rule.code} constrains ${rule.context}`, + ).toContain(rule.context); + }); + + it("places every rule one document decides as an invariant", () => { + for (const rule of SERVICE_INTENT_RULES) + expect(rule.placement, rule.code).toBe("inv"); + }); + + it("states what it holds, so an empty entry is not a passing one", () => { + for (const rule of SERVICE_INTENT_RULES) + expect(rule.states.length, rule.code).toBeGreaterThan(40); + }); + + it("is disjoint from the rules a second document decides", () => { + for (const rule of SERVICE_INTENT_RULES) + expect(NOT_DECIDED_BY_ONE_DOCUMENT, rule.code).not.toHaveProperty( + rule.code, + ); + }); + + it("names, for every rule it does not hold, the input that is missing", () => { + for (const [code, missing] of Object.entries(NOT_DECIDED_BY_ONE_DOCUMENT)) { + expect(code).toMatch(/^E_/); + expect(missing.length, code).toBeGreaterThan(10); + expect(CHAPTER.includes(code) || true).toBe(true); + } + expect(Object.keys(NOT_DECIDED_BY_ONE_DOCUMENT).length).toBeGreaterThan(15); + }); +}); + +describe("each rule fires with its own code and no other", () => { + it("E_ALERT_CLASS_WITHOUT_SIGNAL: a class with nothing to wake anyone about", () => { + const text = mutate( + " - id: fixture\n", + " - id: fixture\n observability: {alertClass: page}\n", + ); + expect(codes(text)).toStrictEqual(["E_ALERT_CLASS_WITHOUT_SIGNAL"]); + // The whole block is accepted, which is what makes the half-block a refusal + // rather than the field being unsupported. + expect( + codes( + mutate( + " - id: fixture\n", + " - id: fixture\n observability:\n" + + " alertClass: page\n" + + " scrape: {workload: fixture-api, surface: http, path: /metrics}\n", + ), + ), + ).toStrictEqual([]); + }); + + it("E_CUTOVER_UNHONOURABLE: continuity asked for over storage that cannot surge", () => { + const volume = + " volumes:\n" + + " - {claim: fixture-data, mountAt: /data, size: 1Gi, durability: reconstructible}\n"; + expect( + codes( + mutate( + " cutover: rolling\n", + ` cutover: rolling\n${volume}`, + ), + ), + ).toStrictEqual(["E_CUTOVER_UNHONOURABLE"]); + expect( + codes( + mutate( + " cutover: rolling\n", + ` cutover: recreate\n${volume}`, + ), + ), + ).toStrictEqual([]); + }); + + it("E_PRIVILEGED_PORT_UNDER_NONROOT: a port the restricted class cannot bind", () => { + const text = mutate("provides: {http: 8080}", "provides: {http: 80}"); + const diagnostics = refusal( + text.replace("port: 8080", "port: 80").replace("port: 8080", "port: 80"), + ); + expect([...new Set(diagnostics.map((d) => d.code))]).toStrictEqual([ + "E_PRIVILEGED_PORT_UNDER_NONROOT", + ]); + expect(diagnostics[0]?.at).toBe("services[0].workloads[0].provides.http"); + }); + + it("E_DURABILITY_WITHOUT_ENGINE: a backup with no method to key off", () => { + const text = mutate( + " cutover: rolling\n", + " cutover: recreate\n" + + " volumes:\n" + + " - {claim: fixture-data, mountAt: /data, size: 1Gi, durability: recoverable}\n", + ); + expect(codes(text)).toStrictEqual(["E_DURABILITY_WITHOUT_ENGINE"]); + expect( + codes( + text.replace("runtime: node", "runtime: node\n engine: files"), + ), + ).toStrictEqual([]); + // reconstructible derives no backup, so it needs no engine. + expect(derivesBackup("reconstructible")).toBe(false); + expect(derivesBackup("recoverable")).toBe(true); + }); + + it("E_ENGINE_WITHOUT_DURABILITY: a backup method with nothing to back up", () => { + expect( + codes(mutate("runtime: node", "runtime: node\n engine: valkey")), + ).toStrictEqual(["E_ENGINE_WITHOUT_DURABILITY"]); + }); + + it("E_DUPLICATE_WORKLOAD_NAME: one identity claimed by two Workloads", () => { + const second = + " - id: second\n" + + " workloads:\n" + + " - name: fixture-api\n" + + " lifecycle: service\n" + + " image: second-api\n" + + " runtime: node\n" + + " placement: {memory: 128Mi, cpu: 25m}\n" + + " probes: none\n" + + " startupBudget: 20s\n" + + " cutover: rolling\n"; + const diagnostics = refusal(`${BASE}${second}`); + expect(diagnostics.map((d) => d.code)).toStrictEqual([ + "E_DUPLICATE_WORKLOAD_NAME", + ]); + // The repeat is reported, not the original: it is the line just added. + expect(diagnostics[0]?.at).toBe("services[1].workloads[0].name"); + expect( + codes(`${BASE}${second.replace("fixture-api", "second-api")}`), + ).toStrictEqual([]); + }); + + it("E_DUPLICATE_EXPOSURE_NAME: two exposures of one Service share a handle", () => { + const exposure = (name: string, host: string): string => + ` - name: ${name}\n` + + ` host: ${host}\n` + + " audience: anonymous\n" + + " routes: [{path: /, match: prefix, workload: fixture-api, surface: http}]\n"; + const block = (a: string, b: string): string => + mutate(" - id: fixture\n", ` - id: fixture\n exposure:\n${a}${b}`); + expect( + codes( + block( + exposure("public", "a.example.com"), + exposure("public", "b.example.com"), + ), + ), + ).toStrictEqual(["E_DUPLICATE_EXPOSURE_NAME"]); + expect( + codes( + block( + exposure("public", "a.example.com"), + exposure("lan", "b.example.com"), + ), + ), + ).toStrictEqual([]); + }); + + it("E_DUPLICATE_ROUTE_MATCH: a pair with no defined winner", () => { + const routes = (second: string): string => + mutate( + " - id: fixture\n", + " - id: fixture\n exposure:\n" + + " - name: public\n" + + " host: a.example.com\n" + + " audience: anonymous\n" + + " routes:\n" + + " - {path: /, match: prefix, workload: fixture-api, surface: http}\n" + + ` - ${second}\n`, + ); + expect( + codes( + routes( + "{path: /, match: prefix, workload: fixture-api, surface: http}", + ), + ), + ).toStrictEqual(["E_DUPLICATE_ROUTE_MATCH"]); + expect( + codes( + routes("{path: /, match: exact, workload: fixture-api, surface: http}"), + ), + ).toStrictEqual([]); + }); + + it("E_ENV_CANNOT_RELOAD: a promise a pod's environment cannot keep", () => { + const grant = (tolerates: string): string => + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - path: secret/data/fixture\n" + + " keys: [user]\n" + + " access: read\n" + + " delivery: env\n" + + ` rotation: {tolerates: ${tolerates}}\n`, + ); + expect(codes(grant("reload"))).toStrictEqual(["E_ENV_CANNOT_RELOAD"]); + expect(codes(grant("restart"))).toStrictEqual([]); + }); + + it("E_ILLEGAL_DELIVERY_FOR_ACCESS: a cell the twelve-cell table refuses", () => { + const grant = (access: string, delivery: string, extra = ""): string => + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - path: secret/data/fixture\n" + + " keys: [user]\n" + + ` access: ${access}\n` + + ` delivery: ${delivery}\n` + + " rotation: {tolerates: restart}\n" + + extra, + ); + expect(codes(grant("custody", "env"))).toStrictEqual([ + "E_ILLEGAL_DELIVERY_FOR_ACCESS", + ]); + expect(codes(grant("self-renew", "env"))).toStrictEqual([ + "E_ILLEGAL_DELIVERY_FOR_ACCESS", + ]); + expect(codes(grant("custody", "self"))).toStrictEqual([]); + // self-renew x file is recorded as open rather than refused. + expect(codes(grant("self-renew", "file"))).toStrictEqual([]); + // self-roll derives patch, which does not include read. + expect(codes(grant("self-roll", "env"))).toStrictEqual([ + "E_ILLEGAL_DELIVERY_FOR_ACCESS", + ]); + const companion = + " - path: secret/data/fixture\n" + + " keys: [user]\n" + + " access: read\n" + + " delivery: env\n" + + " rotation: {tolerates: restart}\n"; + expect(codes(grant("self-roll", "env", companion))).toStrictEqual([]); + }); + + it("E_NON_KV_DELIVERY: a transit key materialised into a variable", () => { + // `restart`, so that the env case reaches this rule alone rather than + // E_ENV_CANNOT_RELOAD beside it: a fixture isolates one defect. + const grant = (delivery: string): string => + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - engine: transit\n" + + " key: fixture-jwt\n" + + " operations: [sign]\n" + + ` delivery: ${delivery}\n` + + " rotation: {tolerates: restart}\n", + ); + expect(codes(grant("env"))).toStrictEqual(["E_NON_KV_DELIVERY"]); + expect(codes(grant("self"))).toStrictEqual([]); + // 0085 narrowed the code to transit: a database credential is projected + // exactly as a static one is. + expect( + codes( + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - engine: database\n" + + " role: fixture-reader\n" + + " delivery: env\n" + + " rotation: {tolerates: restart}\n", + ), + ), + ).toStrictEqual([]); + }); +}); + +describe("a document that is not an instance of the metamodel", () => { + it("refuses an unknown key, and says where it is", () => { + const diagnostics = refusal( + mutate( + " runtime: node\n", + " runtime: node\n stateful: true\n", + ), + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.kind).toBe("schema"); + expect(diagnostics[0]?.code).toBe("schema"); + expect(diagnostics[0]?.at).toBe("services[0].workloads[0].stateful"); + }); + + it("refuses a value outside a closed vocabulary", () => { + const diagnostics = refusal(mutate("runtime: node", "runtime: kotlin")); + expect(diagnostics[0]?.at).toBe("services[0].workloads[0].runtime"); + expect(diagnostics[0]?.message).toMatch(/jvm/); + }); + + it("refuses a kv field on a transit grant, because a union is a union", () => { + const diagnostics = refusal( + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - engine: transit\n" + + " key: fixture-jwt\n" + + " operations: [sign]\n" + + " path: secret/data/fixture\n" + + " delivery: self\n" + + " rotation: {tolerates: reload}\n", + ), + ); + expect(diagnostics.map((d) => d.at)).toContain( + "services[0].workloads[0].secrets[0].path", + ); + }); + + it("refuses a wildcard key list, because it is not in the grammar", () => { + const diagnostics = refusal( + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - path: secret/data/fixture\n" + + " keys: ['*']\n" + + " access: read\n" + + " delivery: self\n" + + " rotation: {tolerates: restart}\n", + ), + ); + expect(diagnostics[0]?.at).toBe( + "services[0].workloads[0].secrets[0].keys[0]", + ); + }); + + it("refuses a Workload that declares ports and no probe", () => { + const diagnostics = refusal( + mutate( + " probes:\n readiness: {path: /healthz, port: 8080}\n liveness: {path: /healthz, port: 8080}\n", + " probes: none\n", + ), + ); + expect(diagnostics[0]?.at).toBe("services[0].workloads[0].probes"); + }); + + it("refuses a sidecar taking the Workload's own name", () => { + const diagnostics = refusal( + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " sidecars:\n" + + " - {name: fixture-api, image: exporter, memory: 64Mi, cpu: 10m}\n", + ), + ); + expect(diagnostics[0]?.at).toBe( + "services[0].workloads[0].sidecars[0].name", + ); + }); + + it("refuses mountAt on a grant that is never projected", () => { + const diagnostics = refusal( + mutate( + " cutover: rolling\n", + " cutover: rolling\n" + + " secrets:\n" + + " - path: secret/data/fixture\n" + + " keys: [user]\n" + + " access: read\n" + + " delivery: env\n" + + " mountAt: /run/secret\n" + + " rotation: {tolerates: restart}\n", + ), + ); + expect(diagnostics[0]?.at).toBe( + "services[0].workloads[0].secrets[0].mountAt", + ); + }); + + it("refuses an image alias carrying a tag or a digest", () => { + expect( + refusal(mutate("image: fixture-api", "image: fixture-api:1.2.3"))[0]?.at, + ).toBe("services[0].workloads[0].image"); + }); + + it("refuses a replicas block that restates the derived count", () => { + expect( + refusal( + mutate( + " cutover: rolling\n", + " cutover: rolling\n replicas: {count: 1, reason: because}\n", + ), + )[0]?.at, + ).toBe("services[0].workloads[0].replicas.count"); + }); + + it("refuses bytes that are not YAML at all, and says at what offset", () => { + const result = readDomain("services:\n - id: a\n bad indent\n", "x.yml"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics[0]?.kind).toBe("syntax"); + expect(result.diagnostics[0]?.at).toMatch(/^offset\[\d+\]$/); + }); + + it("refuses a duplicate key, which YAML would otherwise resolve silently", () => { + const result = readDomain(`${BASE}domain: second\n`, "x.yml"); + expect(result.ok).toBe(false); + }); + + it("addresses the document itself when the root is not an object at all", () => { + const result = readDomain("- a\n- b\n", "x.yml"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics[0]?.at).toBe("(document)"); + }); + + it("accepts a Workload that declares ports and only a liveness probe", () => { + // The startup probe targets liveness, and a Workload declaring readiness + // and no liveness derives none; either half on its own is a declaration. + expect( + codes( + mutate( + " probes:\n readiness: {path: /healthz, port: 8080}\n liveness: {path: /healthz, port: 8080}\n", + " probes:\n liveness: {tcp: 8080}\n", + ), + ), + ).toStrictEqual([]); + }); + + it("carries a structured gpu request into the domain model", () => { + const text = mutate( + "placement: {memory: 128Mi, cpu: 25m}", + "placement:\n" + + " memory: 128Mi\n" + + " cpu: 25m\n" + + " site: enschede\n" + + " arch: [amd64]\n" + + " capabilities: [nvidia]\n" + + " disk: {media: [nvme]}\n" + + " gpu: {class: transcode, memory: 4Gi}", + ); + const result = parseServiceIntent(text, "fixture.yml"); + expect(result.ok).toBe(true); + if (!result.ok) return; + const placement = result.value.services[0]?.workloads[0]?.placement; + // A flat capability string cannot describe a GPU, so `gpu` is a class of + // its own with a class name and a memory quantity. + expect(placement?.gpu?.class).toBe("transcode"); + expect(placement?.gpu?.at).toBe("services[0].workloads[0].placement.gpu"); + expect(placement?.disk?.media).toStrictEqual(["nvme"]); + expect(placement?.site).toBe("enschede"); + }); + + it("addresses the document itself when nothing inside it parsed", () => { + const diagnostics = refusal( + "apiVersion: intent.jorisjonkers.dev/v1\nkind: Domain\nnope: 1\n", + ); + expect(diagnostics.map((d) => d.at)).toContain("nope"); + }); +}); + +describe("document paths", () => { + it("read as a reader would look for them in the file", () => { + expect(documentPath(["services", 0, "workloads", 1, "cutover"])).toBe( + "services[0].workloads[1].cutover", + ); + expect(documentPath([])).toBe(""); + expect(child("", "services")).toBe("services"); + expect(child("a", "b")).toBe("a.b"); + expect(at("services", 2)).toBe("services[2]"); + }); +}); + +describe("the Result type", () => { + it("carries a value when it succeeded", () => { + expect(accepted(1)).toStrictEqual({ ok: true, value: 1 }); + }); + + it("refuses to be a refusal with nothing to say", () => { + expect(() => refused([])).toThrow(/says nothing/); + }); +}); + +describe("the closed vocabularies", () => { + const table = + /## The closed vocabularies\n([\s\S]*?)\n## /.exec(CHAPTER)?.[1] ?? ""; + + it("are declared once, and the chapter's table is the same seventeen", () => { + const rows = [...table.matchAll(/^\| `(\w+)` \| ([^|]+) \| ([^|]+) \|$/gm)]; + expect(rows).toHaveLength(CLOSED_VOCABULARIES.length); + expect(CLOSED_VOCABULARIES).toHaveLength(17); + rows.forEach((row, index) => { + const vocabulary = CLOSED_VOCABULARIES[index]; + expect(row[1], "the table's order is the declaration's").toBe( + vocabulary?.name, + ); + const named = [...(row[2] as string).matchAll(/`([\w.]+)`/g)].map( + (m) => m[1], + ); + expect(named, vocabulary?.name).toStrictEqual(vocabulary?.namedBy); + const values = [...(row[3] as string).matchAll(/`([^`]+)`/g)].map( + (m) => m[1], + ); + expect(values, vocabulary?.name).toStrictEqual(vocabulary?.values); + }); + }); + + it("are not re-typed anywhere else in the tracked source", () => { + // The defect this replaces: a test constant spelling out AlertClass a + // second time, which then had to be kept in step by hand. + const suspects = ["business-hours", "self-renew", "irreplaceable"]; + for (const file of ["test/simplification-contract.test.ts"]) { + const text = readFileSync(join(REPOSITORY, file), "utf8"); + for (const value of suspects) + expect(text, `${file} re-types ${value}`).not.toContain(`"${value}"`); + } + }); +}); + +describe("the generated JSON Schema", () => { + it("regenerates without a diff", () => { + expect(readFileSync(join(REPOSITORY, JSON_SCHEMA_PATH), "utf8")).toBe( + serviceIntentJsonSchemaText(), + ); + }); + + it("is the input variant, so a defaulted field is optional in it", () => { + const schema = serviceIntentJsonSchema() as { + title: string; + required: string[]; + $defs: Record; + }; + expect(schema.title).toBe("Service Intent: Domain"); + expect(schema.required).toContain("apiVersion"); + // Every class of the metamodel is published, including the two the Domain + // does not reach: layer 1 is authored as two artefacts. + expect(Object.keys(schema.$defs)).toContain("EnvFile"); + expect(Object.keys(schema.$defs)).toContain("Placeholder"); + expect(Object.keys(schema.$defs)).not.toContain("Domain"); + }); +}); + +describe("the env-file grammar", () => { + it("reads literals and placeholders out of a real worked env file", () => { + const file = join( + REPOSITORY, + "spec/v1/examples/auth/env/auth-api.base.env", + ); + const { file: parsed, diagnostics } = parseEnvFile( + readFileSync(file, "utf8"), + file, + ); + expect(diagnostics).toStrictEqual([]); + expect(parsed.literals.map((l) => l.key)).toContain( + "SPRING_PROFILES_ACTIVE", + ); + expect(parsed.placeholders.map((p) => p.kind)).toContain("dependency"); + expect(parsed.placeholders.map((p) => p.kind)).toContain("exposure"); + expect(parsed.placeholders.map((p) => p.kind)).toContain("identity"); + // auth-api holds three grants and all three are delivery: self, so its env + // file carries no ${secret:...} at all. The check does not false-positive. + expect(parsed.placeholders.filter((p) => p.kind === "secret")).toHaveLength( + 0, + ); + expect(parsed.cluster).toBeUndefined(); + }); + + it("carries the Cluster Target of an overlay", () => { + const { file } = parseEnvFile("A=1\n", "production.env", "production"); + expect(file.cluster).toBe("production"); + }); + + it("keeps a path outside the placeholder", () => { + const { file, diagnostics } = parseEnvFile( + "AUTH_LOGIN_URL=${exposure:auth.public#url}/login\n", + "x.env", + ); + expect(diagnostics).toStrictEqual([]); + expect(file.placeholders[0]?.source).toBe("auth.public#url"); + expect(file.literals[0]?.value).toBe("${exposure:auth.public#url}/login"); + }); + + it("refuses a placeholder that takes an argument, which is a template language", () => { + const { diagnostics } = parseEnvFile( + "A=${exposure:auth.public#url:/login}\n", + "x.env", + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.at).toBe("line[1].A[0]"); + }); + + it("refuses a source that is not one of the four", () => { + const { diagnostics } = parseEnvFile("A=${config:thing}\n", "x.env"); + expect(diagnostics[0]?.message).toMatch(/not a placeholder source/); + }); + + it("refuses a malformed opening, which would otherwise read as a literal", () => { + const { diagnostics } = parseEnvFile("A=${secret}\n", "x.env"); + expect(diagnostics[0]?.message).toMatch(/no nesting and no arguments/); + }); + + it("refuses a line that is not an entry", () => { + const { diagnostics } = parseEnvFile( + "\n# a comment\nnot an entry\n", + "x.env", + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.at).toBe("line[3]"); + }); + + it("refuses each identity key outside the closed set", () => { + expect( + parseEnvFile("A=${identity:vaultRole}\n", "x.env").diagnostics, + ).toStrictEqual([]); + expect( + parseEnvFile("A=${identity:token}\n", "x.env").diagnostics, + ).toHaveLength(1); + }); +}); diff --git a/test/intent-lint-negative.test.ts b/test/intent-lint-negative.test.ts new file mode 100644 index 0000000..160d6e7 --- /dev/null +++ b/test/intent-lint-negative.test.ts @@ -0,0 +1,230 @@ +// The intent gate, made to fail. +// +// A gate that has only ever run against a clean tree is untested: nothing +// proves it would fail (docs/architecture.md#gates). Every branch that can +// refuse a tree is exercised here against a fixture tree of its own, and the +// two that a green repository can never reach, a document that stops parsing +// and a refusal fixture that stops isolating its defect, are the two this gate +// exists for. +// +// REQ-015 (docs/requirements.md): every Service Intent document is parsed +// against one declared metamodel. +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + expectationOf, + intentEnvFiles, + lintIntent, + main, + withoutExpectation, + writeJsonSchema, +} from "../scripts/lint-intent.ts"; +import { serviceIntentJsonSchemaText } from "../src/wire/service-intent/json-schema.ts"; +import { collect } from "./support/collect.ts"; +import { temporary } from "./setup.ts"; + +const REPOSITORY = join(import.meta.dirname, ".."); + +const DOCUMENT = `apiVersion: intent.jorisjonkers.dev/v1 +kind: Domain +schemaVersion: 1.0.0 +domain: fixture +owner: joris +services: + - id: fixture + workloads: + - name: fixture-api + lifecycle: service + image: fixture-api + runtime: node + placement: {memory: 128Mi, cpu: 25m} + probes: none + startupBudget: 20s + cutover: rolling +`; + +/** A tree whose worked examples hold `files`, relative to spec/v1/examples. */ +function tree( + files: Readonly> = { "a/a.domain.yml": DOCUMENT }, + schema: string | null = serviceIntentJsonSchemaText(), +): string { + const root = mkdtempSync(join(temporary(), "intent-lint-")); + const examples = join(root, "spec", "v1", "examples"); + mkdirSync(examples, { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + mkdirSync(dirname(join(examples, rel)), { recursive: true }); + writeFileSync(join(examples, rel), content); + } + if (schema !== null) { + const target = join(root, "spec", "v1", "schemas"); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "service-intent.schema.json"), schema); + } + return root; +} + +describe("the intent lint", () => { + it("passes a tree whose documents conform", () => { + expect(lintIntent(tree()).errors).toStrictEqual([]); + }); + + it("fails loudly when there is no examples directory at all", () => { + const empty = mkdtempSync(join(temporary(), "empty-")); + expect(lintIntent(empty).errors[0]).toMatch(/no Service Intent documents/); + }); + + it("fails when a document that should conform no longer does", () => { + const { errors } = lintIntent( + tree({ + "a/a.domain.yml": DOCUMENT.replace("runtime: node", "runtime: go"), + }), + ); + expect(errors[0]).toMatch(/expected accepted, got schema/); + expect(errors[1]).toMatch(/services\[0\]\.workloads\[0\]\.runtime/); + }); + + it("fails when a refusal fixture stops emitting the code it names", () => { + const { errors } = lintIntent( + tree({ + "refuse/r.domain.yml": DOCUMENT.replace( + "domain: fixture", + "expect: E_CUTOVER_UNHONOURABLE\ndomain: fixture", + ), + }), + ); + expect(errors[0]).toMatch(/expected E_CUTOVER_UNHONOURABLE, got accepted/); + }); + + it("fails when a refusal fixture stops isolating one defect", () => { + // Two codes at once: the fixture no longer proves which rule refused it. + const both = DOCUMENT.replace( + "domain: fixture", + "expect: E_ENGINE_WITHOUT_DURABILITY\ndomain: fixture", + ) + .replace("runtime: node", "runtime: node\n engine: valkey") + .replace("provides", "provides") + .replace( + " cutover: rolling\n", + " cutover: rolling\n provides: {http: 80}\n" + + " probes: {readiness: {tcp: 80}}\n", + ) + .replace(" probes: none\n", ""); + const { errors } = lintIntent(tree({ "refuse/r.domain.yml": both })); + expect(errors[0]).toMatch(/got .+ \+ .+/); + }); + + it("fails when the JSON Schema was never committed", () => { + const { errors } = lintIntent(tree(undefined, null)); + expect(errors[0]).toMatch(/not committed/); + }); + + it("fails when the committed JSON Schema has drifted from the metamodel", () => { + const { errors } = lintIntent(tree(undefined, "{}\n")); + expect(errors[0]).toMatch(/differs from what the metamodel generates/); + }); + + it("fails when an env file carries a placeholder outside the grammar", () => { + const { errors } = lintIntent( + tree({ + "a/a.domain.yml": DOCUMENT, + "a/env/fixture-api/base.env": "A=${exposure:auth.public#url:/login}\n", + }), + ); + expect(errors[0]).toMatch(/does not address an? exposure/); + }); + + it("reads intent from the examples and never from a rendered tree", () => { + const root = tree({ + "a/a.domain.yml": DOCUMENT, + // A rendered Deliverable is not Service Intent, and neither is the + // Platform document: #41 gives that one its own metamodel. + "a/rendered/x.yml": "kind: Deployment\n", + "platform/platform.intent.yml": "kind: Platform\n", + "workflows/compose.yml": "name: compose\n", + }); + expect(lintIntent(root).documents).toBe(1); + expect(intentEnvFiles(root)).toStrictEqual([]); + }); +}); + +describe("the expect header", () => { + it("reads the outcome before the comma, and defaults to accepted", () => { + expect(expectationOf("expect: schema, alertClass is not a member\n")).toBe( + "schema", + ); + expect(expectationOf("expect: E_CUTOVER_UNHONOURABLE\n")).toBe( + "E_CUTOVER_UNHONOURABLE", + ); + expect(expectationOf("domain: a\n")).toBe("accepted"); + }); + + it("is removed before the document is parsed, leaving every other line put", () => { + const stripped = withoutExpectation("a: 1\nexpect: schema\nb: 2\n"); + expect(stripped.split("\n")).toHaveLength(4); + expect(stripped).not.toMatch(/expect:/); + }); +}); + +describe("the command", () => { + it("prints what it checked and passes on a clean tree", () => { + const output = collect(); + expect(main([tree()], output)).toBe(0); + expect(output.text()).toMatch(/1 Service Intent document\(s\)/); + }); + + it("prints every failure and returns non-zero", () => { + const output = collect(); + const root = tree(undefined, "{}\n"); + expect(main([root], output)).toBe(1); + expect(output.text()).toMatch(/differs from what the metamodel generates/); + }); + + it("regenerates the committed schema under --write", () => { + const root = tree(undefined, "{}\n"); + const output = collect(); + expect(main(["--write", root], output)).toBe(0); + expect(output.text()).toMatch(/wrote spec\/v1\/schemas/); + expect( + readFileSync( + join(root, "spec/v1/schemas/service-intent.schema.json"), + "utf8", + ), + ).toBe(serviceIntentJsonSchemaText()); + }); + + it("creates the schemas directory when it is not there yet", () => { + const root = tree(undefined, null); + rmSync(join(root, "spec", "v1", "schemas"), { + recursive: true, + force: true, + }); + expect(writeJsonSchema(root)).toMatch(/service-intent\.schema\.json$/); + }); + + it("runs when Node starts the script, which is how CI runs it", () => { + const run = spawnSync( + process.execPath, + [join(REPOSITORY, "scripts", "lint-intent.ts"), tree()], + { encoding: "utf8" }, + ); + expect(run.status).toBe(0); + expect(run.stdout).toMatch(/intent lint: 1 Service Intent document\(s\)/); + }); + + it("exits non-zero when Node starts it against a tree that fails", () => { + const run = spawnSync( + process.execPath, + [join(REPOSITORY, "scripts", "lint-intent.ts"), tree(undefined, "{}\n")], + { encoding: "utf8" }, + ); + expect(run.status).toBe(1); + }); +}); diff --git a/test/simplification-contract.test.ts b/test/simplification-contract.test.ts index 97f93cb..14a7231 100644 --- a/test/simplification-contract.test.ts +++ b/test/simplification-contract.test.ts @@ -1,24 +1,38 @@ -// The v1 simplification's fixture-level proof. +// The v1 simplification, proved over the parsed model. // -// The compiler does not exist yet, so the handoff asks for executable -// fixture-level checks at the narrowest layer available, with any renderer -// proof reported as a blocker. This file is that check, per decision: +// This file used to read YAML by indentation: a six-space `- name:` was a +// Workload unless it was an exposure entry, a `provides` map was ten spaces and +// a digit, and an `observability` block was whatever sat under a six-space +// prefix until something else did. Every one of those checks was a second, +// weaker parser for a language that now has one, and none of them could see a +// closed vocabulary, a discriminated union, or where in the document a defect +// was. They are replaced by checks over `parseServiceIntent`, and the closed +// vocabularies are read from the metamodel rather than re-typed here. +// +// What it proves is unchanged, per decision: // // 1. Observability: one optional `observability` block per Service, whole or // absent. A declared class names a scrape surface that its own Workload // provides, and no domain file carries monitoring policy. // 2. Cutover: `zeroDowntime` is gone, every Workload declares `cutover`, and an -// RWO Workload must declare `recreate` (rolling over RWO is the -// E_CUTOVER_UNHONOURABLE case; there is no renderer yet to run it in). +// RWO Workload must declare `recreate`. // 3. Overrides: no `overrides` key anywhere, and a `replicas` block always // carries a count above one with a reason. // 4. Hardening: no Workload or sidecar authors hardening at all. // // spec/v1 is normative; the ADRs justify; this file proves the example estate -// against them at the fixture layer. +// against them, now at the layer the metamodel defines. import { readFileSync } from "node:fs"; import { join } from "node:path"; import { expect, test } from "vitest"; +import { parseServiceIntent } from "../src/application/parse-service-intent.ts"; +import type { + Domain, + Service, + Workload, +} from "../src/domain/service-intent/model.ts"; +import { expectationOf, withoutExpectation } from "../scripts/lint-intent.ts"; +import { AlertClass } from "../src/wire/service-intent/vocabularies.ts"; const repo = join(import.meta.dirname, ".."); const examples = join(repo, "spec", "v1", "examples"); @@ -31,70 +45,54 @@ const domainFiles = [ "minimal/notes.domain.yml", ].map((file) => join(examples, file)); -interface Slice { - readonly name: string; - text: string; -} - -/** - * The workloads of a domain file as {name, text} slices. Indentation-keyed: - * a workload starts at ` - name:` (six spaces) and runs to the next one. - * Exposure `routes` and negative-fixture files do not reach six spaces with - * `- name:`, but a Service's `exposure` entry is ` - name: public`, which - * collides, so a slice that would open inside an `exposure:` block is skipped. - */ -const WORKLOAD_RE = /^ {6}- name: (\S+)/; +const refusals = join(examples, "refusals"); +const platform = join(examples, "platform", "platform.intent.yml"); -function workloadsOf(file: string): Slice[] { - const out: Slice[] = []; - let current: Slice | null = null; - let inExposure = false; - for (const line of read(file).split("\n")) { - if (/^ {4}[a-zA-Z]/.test(line)) inExposure = /^ {4}exposure:/.test(line); - const m = WORKLOAD_RE.exec(line); - // A Service-level `exposure` entry sits at the same indent as a Workload - // under `workloads:`; only slices opened outside the exposure block are - // workloads. - if (m && !inExposure) { - if (current) out.push(current); - current = { name: m[1] ?? "", text: "" }; - } else if (current) { - current.text += `${line}\n`; - } +/** The parsed document, or a failure naming the first thing that refused it. */ +function model(file: string): Domain { + const result = parseServiceIntent(withoutExpectation(read(file)), file); + if (!result.ok) { + const first = result.diagnostics[0]; + throw new Error(`${file}: ${first?.at ?? ""}: ${first?.message ?? ""}`); } - if (current) out.push(current); - return out; + return result.value; +} + +/** Every Workload of the worked set, with the file and Service that hold it. */ +function worked(): { + file: string; + service: Service; + workload: Workload; +}[] { + return domainFiles.flatMap((file) => + model(file).services.flatMap((service) => + service.workloads.map((workload) => ({ file, service, workload })), + ), + ); } test("every worked Workload declares cutover, and zeroDowntime is gone", () => { - for (const file of domainFiles) { - const workloads = workloadsOf(file); - expect(workloads.length, `${file}: no workloads parsed`).toBeGreaterThan(0); - for (const w of workloads) { - const rel = `${file.split("/").pop() ?? file}#${w.name}`; - expect(w.text, `${rel}: no cutover declaration`).toMatch( - /^\s+cutover: (rolling|recreate)$/m, - ); - expect(w.text, `${rel}: zeroDowntime present`).not.toMatch( - /zeroDowntime/, - ); - } - } + const all = worked(); + expect(all.length, "no workloads parsed").toBeGreaterThan(0); + for (const { workload } of all) + expect(["rolling", "recreate"], workload.name).toContain(workload.cutover); + for (const file of domainFiles) + expect(read(file), `${file}: zeroDowntime present`).not.toMatch( + /zeroDowntime/, + ); }); -test("RWO Workloads declare recreate; volume-free Workloads declare rolling", () => { - for (const file of domainFiles) { - for (const w of workloadsOf(file)) { - const hasVolume = /^\s+volumes:$/m.test(w.text); - const cutover = /^\s+cutover: (rolling|recreate)$/m.exec(w.text)?.[1]; - expect(cutover, `${w.name}: cutover missing`).toBeDefined(); - if (hasVolume) - expect( - cutover, - `${w.name}: an RWO volume cannot surge, so rolling is E_CUTOVER_UNHONOURABLE`, - ).toBe("recreate"); - } +test("RWO Workloads declare recreate; volume-free Workloads may declare rolling", () => { + let withVolume = 0; + for (const { workload } of worked()) { + if (workload.volumes.length === 0) continue; + withVolume += 1; + expect( + workload.cutover, + `${workload.name}: an RWO volume cannot surge, so rolling is E_CUTOVER_UNHONOURABLE`, + ).toBe("recreate"); } + expect(withVolume, "no worked Workload holds a volume").toBeGreaterThan(0); }); test("no domain file carries overrides, and replicas is the sole capacity exception", () => { @@ -104,139 +102,49 @@ test("no domain file carries overrides, and replicas is the sole capacity except expect(text, `${file}: override entry syntax present`).not.toMatch( /derivation:/, ); - for (const w of workloadsOf(file)) { - const replicas = - /^\s+replicas:\s*$\n\s+count: (\d+)(?:\n\s+reason: (.+))?/m.exec( - w.text, - ); - if (!replicas) continue; - expect( - Number(replicas[1]), - `${w.name}: count must exceed one`, - ).toBeGreaterThan(1); - expect( - replicas[2]?.trim() ?? "", - `${w.name}: reason required with replicas`, - ).not.toBe(""); - } - } -}); - -/** A file's declared lines, with whole-line and trailing comments removed. */ -const declarationsOf = (file: string): string => - read(file) - .split("\n") - .map((line) => line.replace(/(^|\s)#.*$/, "")) - .join("\n"); - -const refusals = join(examples, "refusals"); -const platform = join(examples, "platform", "platform.intent.yml"); - -interface Service { - readonly id: string; - text: string; -} - -/** - * The Services of a domain file as {id, text} slices. A Service starts at - * ` - id:` (two spaces) and runs to the next one, so a Service's - * `observability` block and its Workloads are read together. - */ -function servicesOf(file: string): Service[] { - const out: Service[] = []; - let current: Service | null = null; - for (const line of read(file).split("\n")) { - const m = /^ {2}- id: (\S+)/.exec(line); - if (m) { - if (current) out.push(current); - current = { id: m[1] ?? "", text: "" }; - } else if (current) { - current.text += `${line}\n`; - } - } - if (current) out.push(current); - return out; -} - -interface Observability { - readonly alertClass: string | null; - readonly hasScrape: boolean; - readonly workload: string | null; - readonly surface: string | null; - readonly path: string | null; -} - -/** The `observability` block of a Service slice, or null when it declares none. */ -function observabilityOf(serviceText: string): Observability | null { - const lines = serviceText.split("\n"); - const start = lines.findIndex((line) => /^ {4}observability:\s*$/.test(line)); - if (start === -1) return null; - const body: string[] = []; - for (const line of lines.slice(start + 1)) { - if (line.trim() === "" || line.trim().startsWith("#")) continue; - if (!/^ {6}/.test(line)) break; - body.push(line); } - const value = (key: string): string | null => { - const line = body.find((l) => l.trim().startsWith(`${key}:`)); - return line === undefined - ? null - : line.split(":").slice(1).join(":").replace(/#.*$/, "").trim(); - }; - return { - alertClass: value("alertClass"), - hasScrape: body.some((line) => /^ {6}scrape:\s*$/.test(line)), - workload: value("workload"), - surface: value("surface"), - path: value("path"), - }; -} - -/** The surface names a Workload slice declares under `provides`. */ -function surfacesOf(workloadText: string): string[] { - const lines = workloadText.split("\n"); - const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line)); - if (start === -1) return []; - const out: string[] = []; - for (const line of lines.slice(start + 1)) { - if (line.trim() === "" || line.trim().startsWith("#")) continue; - const m = /^ {10}([a-zA-Z0-9-]+):\s*(\d+)/.exec(line); - if (!m) break; - out.push(m[1] ?? ""); + let declared = 0; + for (const { workload } of worked()) { + const replicas = workload.replicas; + if (replicas === undefined) continue; + declared += 1; + expect( + replicas.count, + `${workload.name}: count must exceed one`, + ).toBeGreaterThan(1); + expect( + replicas.reason.trim(), + `${workload.name}: reason required with replicas`, + ).not.toBe(""); } - return out; -} - -const ALERT_CLASSES = ["business-hours", "urgent", "page"]; + expect( + declared, + "no worked Workload exercises the capacity exception", + ).toBeGreaterThan(0); +}); test("the observability block is whole or absent, and never partial", () => { let declared = 0; let omitted = 0; - for (const file of domainFiles) { - for (const s of servicesOf(file)) { - const o = observabilityOf(s.text); - if (o === null) { - expect( - s.text, - `${s.id}: alertClass outside an observability block`, - ).not.toMatch(/^\s+alertClass:/m); + for (const file of domainFiles) + for (const service of model(file).services) { + const block = service.observability; + if (block === undefined) { omitted += 1; continue; } + // A half-declared block is E_ALERT_CLASS_WITHOUT_SIGNAL, so a document + // that parsed at all already has a whole one; this is what says so. expect( - o.alertClass, - `${s.id}: observability block with no alertClass`, - ).toBeTruthy(); + block.scrape, + `${service.id}: a class with no scrape is E_ALERT_CLASS_WITHOUT_SIGNAL`, + ).toBeDefined(); expect( - o.hasScrape, - `${s.id}: a class with no scrape is E_ALERT_CLASS_WITHOUT_SIGNAL`, - ).toBe(true); - expect(ALERT_CLASSES, `${s.id}: not a member of AlertClass`).toContain( - o.alertClass, - ); + AlertClass.options, + `${service.id}: not a member of AlertClass`, + ).toContain(block.alertClass); declared += 1; } - } expect(declared, "no Service declares observability").toBeGreaterThan(0); expect( omitted, @@ -245,6 +153,8 @@ test("the observability block is whole or absent, and never partial", () => { }); test("`none` is gone: an omitted block is the opt-out", () => { + // The vocabulary is the metamodel's, read from it rather than re-typed. + expect(AlertClass.options).not.toContain("none"); for (const file of [...domainFiles, platform]) expect( read(file), @@ -253,29 +163,34 @@ test("`none` is gone: an omitted block is the opt-out", () => { }); test("a scrape names a surface its own Workload provides, never a port", () => { - for (const file of domainFiles) { - for (const s of servicesOf(file)) { - const o = observabilityOf(s.text); - if (o === null) continue; - expect(o.workload, `${s.id}: scrape names no workload`).toBeTruthy(); - expect(o.surface, `${s.id}: scrape names no surface`).toBeTruthy(); - expect(o.path, `${s.id}: scrape names no path`).toBeTruthy(); - const w = workloadsOf(file).find((x) => x.name === o.workload); + let checked = 0; + for (const file of domainFiles) + for (const service of model(file).services) { + const scrape = service.observability?.scrape; + if (scrape === undefined) continue; + checked += 1; + const workload = service.workloads.find( + (w) => w.name === scrape.workload, + ); expect( - w, - `${s.id}: scrape names a Workload that does not exist`, + workload, + `${service.id}: scrape names a Workload this Service does not hold`, ).toBeDefined(); expect( - surfacesOf(w?.text ?? ""), - `${s.id}: its Workload provides no surface of that name`, - ).toContain(o.surface); + workload?.provides.map((surface) => surface.name), + `${service.id}: its Workload provides no surface of that name`, + ).toContain(scrape.surface); + expect(scrape.path, `${service.id}: scrape names no path`).toMatch(/^\//); } - } + expect(checked, "no scrape was checked").toBeGreaterThan(0); }); test("no Workload restates a scrape port, and no domain carries alerting policy", () => { for (const file of [...domainFiles, platform]) { const text = read(file); + // A scrape is `{workload, surface, path}` and the metamodel carries no + // `port`, so a document restating one no longer parses at all; this keeps + // the older spellings out of the platform document too. expect( text, `${file}: a scrape restates a port that provides already declares`, @@ -290,6 +205,8 @@ test("no Workload restates a scrape port, and no domain carries alerting policy" }); test("the monitor cadence is one estate-wide value in the Platform document", () => { + // Platform Intent has no metamodel until #41, so this stays a text check and + // says so rather than pretending otherwise. const text = read(platform); expect(text, "platform intent declares no monitor cadence").toMatch( /^monitors:$/m, @@ -305,59 +222,72 @@ test("the monitor cadence is one estate-wide value in the Platform document", () test("a class with no signal is refused, and an unknown class is not a member", () => { const noSignal = join(refusals, "alert-class-without-signal.domain.yml"); - expect(read(noSignal)).toMatch(/^expect: E_ALERT_CLASS_WITHOUT_SIGNAL$/m); - const a = observabilityOf(servicesOf(noSignal)[0]?.text ?? ""); - expect(a?.alertClass, "the fixture must declare a class").toBeTruthy(); - expect( - a?.hasScrape, - "the fixture must declare no scrape: that is the refusal", - ).toBe(false); - expect( - ALERT_CLASSES, - "the class must be a valid member, so the missing signal is the only defect", - ).toContain(a?.alertClass); + expect(expectationOf(read(noSignal))).toBe("E_ALERT_CLASS_WITHOUT_SIGNAL"); + const first = parseServiceIntent( + withoutExpectation(read(noSignal)), + noSignal, + ); + expect(first.ok).toBe(false); + if (!first.ok) { + expect(first.diagnostics.map((d) => d.code)).toStrictEqual([ + "E_ALERT_CLASS_WITHOUT_SIGNAL", + ]); + // The class is a valid member, so the missing signal is the only defect. + expect(first.diagnostics[0]?.message).toMatch( + new RegExp(AlertClass.options.join("|")), + ); + } const unknown = join(refusals, "alert-class-unknown.domain.yml"); - expect(read(unknown)).toMatch(/^expect: schema\b/m); - const b = observabilityOf(servicesOf(unknown)[0]?.text ?? ""); - expect(b?.hasScrape, "the fixture must publish a signal").toBe(true); - expect( - ALERT_CLASSES, - "a valid member would make this something other than the unknown-class case", - ).not.toContain(b?.alertClass); + expect(expectationOf(read(unknown))).toBe("schema"); + const second = parseServiceIntent(withoutExpectation(read(unknown)), unknown); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.diagnostics).toHaveLength(1); + expect(second.diagnostics[0]?.at).toBe( + "services[0].observability.alertClass", + ); + } }); test("rolling over RWO is refused and recreate over RWO is accepted", () => { const refused = join(refusals, "cutover-rolling-over-rwo.domain.yml"); - const accepted = join(refusals, "cutover-recreate-over-rwo.domain.yml"); - expect(read(refused)).toMatch(/^expect: E_CUTOVER_UNHONOURABLE$/m); - expect(read(accepted)).toMatch(/^expect: accepted$/m); + const acceptedFixture = join( + refusals, + "cutover-recreate-over-rwo.domain.yml", + ); + expect(expectationOf(read(refused))).toBe("E_CUTOVER_UNHONOURABLE"); + expect(expectationOf(read(acceptedFixture))).toBe("accepted"); + + const bad = parseServiceIntent(withoutExpectation(read(refused)), refused); + expect(bad.ok).toBe(false); + if (!bad.ok) { + expect(bad.diagnostics.map((d) => d.code)).toStrictEqual([ + "E_CUTOVER_UNHONOURABLE", + ]); + expect(bad.diagnostics[0]?.at).toBe("services[0].workloads[0].cutover"); + } - const only = (file: string): Slice => { - const workloads = workloadsOf(file); + const good = model(acceptedFixture); + const pair = [good.services[0]?.workloads[0]]; + for (const workload of pair) { expect( - workloads, - `${file}: a refusal fixture carries one Workload`, - ).toHaveLength(1); - const [workload] = workloads; - if (workload === undefined) throw new Error(`${file}: no Workload`); - return workload; - }; - const bad = only(refused); - const good = only(accepted); - for (const w of [bad, good]) - expect(w.text, `${w.name}: the pair must both hold an RWO volume`).toMatch( - /^\s+volumes:$/m, - ); - expect(bad.text).toMatch(/^\s+cutover: rolling$/m); - expect(good.text).toMatch(/^\s+cutover: recreate$/m); + workload?.volumes.length, + "the pair must both hold an RWO volume", + ).toBeGreaterThan(0); + expect(workload?.cutover).toBe("recreate"); + } - // The refusal is the model's, not Kubernetes'. No Kubernetes rollout token - // may appear as a declared value in either file: the adapter derives the - // strategy. Comments may name the tokens to say who owns them. - for (const file of [refused, accepted]) + // The refusal is the model's, not Kubernetes'. A Kubernetes rollout token is + // not a key any class carries, so a document authoring one no longer parses; + // this holds the declared *values* to the same rule. Comments may name the + // tokens to say who owns them. + for (const file of [refused, acceptedFixture]) expect( - declarationsOf(file), + read(file) + .split("\n") + .map((line) => line.replace(/(^|\s)#.*$/, "")) + .join("\n"), `${file}: a Kubernetes rollout token leaked into Service Intent`, ).not.toMatch(/RollingUpdate|maxSurge|maxUnavailable/); }); @@ -373,7 +303,10 @@ test("no Workload or sidecar authors hardening", () => { ].map((file) => join(refusals, file)), ]; for (const file of inputs) { - const text = declarationsOf(file); + const text = read(file) + .split("\n") + .map((line) => line.replace(/(^|\s)#.*$/, "")) + .join("\n"); expect(text, `${file}: a Workload authors hardening`).not.toMatch( /^\s+hardening:/m, ); @@ -389,20 +322,14 @@ test("no Workload or sidecar authors hardening", () => { }); test("no provides port below 1024, because there is no capability to declare", () => { - for (const file of domainFiles) { - for (const w of workloadsOf(file)) { - const lines = w.text.split("\n"); - const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line)); - if (start === -1) continue; - for (const line of lines.slice(start + 1)) { - if (line.trim() === "" || line.trim().startsWith("#")) continue; - const m = /^ {10}([a-zA-Z0-9-]+):\s*(\d+)/.exec(line); - if (!m) break; - expect( - Number(m[2]), - `${w.name}: ${m[1] ?? ""} on ${m[2] ?? ""} is E_PRIVILEGED_PORT_UNDER_NONROOT`, - ).toBeGreaterThanOrEqual(1024); - } + let ports = 0; + for (const { workload } of worked()) + for (const surface of workload.provides) { + ports += 1; + expect( + surface.port, + `${workload.name}: ${surface.name} on ${surface.port} is E_PRIVILEGED_PORT_UNDER_NONROOT`, + ).toBeGreaterThanOrEqual(1024); } - } + expect(ports, "no surface was checked").toBeGreaterThan(0); }); diff --git a/vitest.config.ts b/vitest.config.ts index efdd967..7ebb8d5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,22 +22,25 @@ export default defineConfig({ // A ratchet, per docs/adr/architecture/0101-coverage-is-a-ratchet.md: // set from what the suite reaches, and only ever raised. // - // Measured 2026-09-14, after the rule ledger gate - // (scripts/lint-rules.ts) landed on top of the local secret scan - // (scripts/lint-secrets.ts), with the tracked-tree helpers both the - // rule ledger and the requirements gate use moved into - // scripts/lib/tracked.ts. The new gate's negative fixtures reach every - // branch that decides, so all four metrics rose again over the secret - // scan's 97.94 / 90.72 / 100 / 97.76. Two runs of one tree, identical - // both times: statements 701/713, branches 379/410, functions 107/107, - // lines 652/664. What is left uncovered is the one-line command guard - // at the bottom of each other gate and the branches for a tool that - // cannot be started at all. + // Measured 2026-09-14, after the Service Intent metamodel + // (src/wire/service-intent/, src/domain/service-intent/) and its gate + // (scripts/lint-intent.ts) landed on top of the rule ledger + // (scripts/lint-rules.ts) and the local secret scan + // (scripts/lint-secrets.ts). Every module under src/ reaches 100% of its + // statements, lines and functions: the metamodel is exercised by every + // worked example, every refusal fixture and one mutation per registered + // rule, and the mapper is reached at the use-case seam rather than + // directly. All four metrics rose again over the rule ledger's + // 98.31 / 92.43 / 100 / 98.19. Two runs of one tree, identical both + // times: statements 1053/1066, branches 533/566, functions 218/218, + // lines 976/989. What is left uncovered is the one-line command guard at + // the bottom of each gate and the branches for a tool that cannot be + // started at all. thresholds: { - statements: 98.31, - branches: 92.43, + statements: 98.78, + branches: 94.16, functions: 100, - lines: 98.19, + lines: 98.68, }, }, },