From 7c1bad8a0e3ebc8c7131442c8c00416370f1597a Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 12:07:04 +0200 Subject: [PATCH 1/2] docs: make the model chapters agree with themselves Wording fixes for rules the model has already decided, so a reader of any one chapter meets the rule in force. No model change. - Chapter 20 said hardening was "one platform posture plus declared exceptions" and derived `securityContext` "from `hardening` and its declared exceptions", above its own "Hardening has no exception surface either". 0016 deleted the exception vocabulary; the authority row and the derived-mechanics bullet now say so and name E_HARDENING_UNMET, and CONTEXT.md's Hardening Class loses its "named exceptions". - "a Context", "the pinned context" and "a context" meaning the Platform Intent are gone from chapters 10, 16 and 60. - Chapter 10 carried a truncated copy of "Writing a derived value as a literal is a build error" in front of the sentence. Removed. - Chapter 60's onboarding step said an Application carries `alertClass`. It carries an optional `observability` block, whole or absent. Its platform-facts paragraph listed an observability policy the Platform document does not have; it now lists the monitor cadence. The worked platform example's README named the same non-existent block, and now names `monitors`. - Chapter 10 pointed at chapter 14's probe policy for the scrape cadence. The monitor cadence has its own section. - CONTEXT.md's Alert Class said how an alert "should be delivered". Delivery is routing, which belongs to the monitoring stack. It states urgency. - Chapter 16's derivation-map mermaid labelled the Platform Intent a layer-2 input and carried a Flux health timeout class, a Flux Kustomization, a reachability entry and edge-catalog nodes the drawn SVGs do not. The catalogs reach the ConfigMap, as the deliverables SVG draws it, and the Kubernetes Service node the rename had relabelled "Application" is "Service" again. - The file layout and the worked examples disagreed. The project file is `platform/.project.yml`, the suffix naming its `kind`, which every example and refusal fixture already spells; the chapter, chapter 14's foundation files, the publish workflow and the examples' header comments now say it too. A Process's env files live in a directory named for the Process, so the cluster overlay has somewhere to sit: four flat example env files move, and data's is filed under its Process `postgres`. - Chapter 00's example table and its overview diagram named a `projects/` directory, flat env files and a `platform.yml` that do not exist. --- CONTEXT.md | 11 +++++--- spec/v1/00-overview.md | 8 +++--- spec/v1/10-project-intent.md | 26 ++++++++++++------- spec/v1/14-platform-intent.md | 4 +-- spec/v1/16-dependencies.md | 22 +++++----------- spec/v1/20-resolved-deployment.md | 11 +++++--- spec/v1/60-setup.md | 18 ++++++++----- .../00-overview-meta-model.drawio.svg | 2 +- spec/v1/examples/auth/auth.project.yml | 4 +-- .../{auth-api.base.env => auth-api/base.env} | 4 +-- spec/v1/examples/auth/rendered/README.md | 2 +- spec/v1/examples/data/data.project.yml | 4 +-- .../base.env} | 4 +-- spec/v1/examples/data/rendered/README.md | 4 +-- .../base.env} | 4 +-- .../base.env} | 2 +- .../examples/knowledge/knowledge.project.yml | 6 ++--- spec/v1/examples/platform/README.md | 2 +- .../workflows/project-publish-fragment.yml | 6 ++--- 19 files changed, 77 insertions(+), 67 deletions(-) rename spec/v1/examples/auth/env/{auth-api.base.env => auth-api/base.env} (97%) rename spec/v1/examples/data/env/{platform-postgres.base.env => postgres/base.env} (87%) rename spec/v1/examples/knowledge/env/{knowledge-api.base.env => knowledge-api/base.env} (97%) rename spec/v1/examples/knowledge/env/{knowledge-ingest-worker.base.env => knowledge-ingest-worker/base.env} (95%) diff --git a/CONTEXT.md b/CONTEXT.md index 98997f0..bcf39d0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,15 +101,18 @@ bin-packing ([0061](docs/adr/model/0061-placement-is-hard-dimensions.md)). **Capability**: a named node property a Process may require. -**Hardening Class**: the pod security posture a Process takes, with named -exceptions each carrying a reason -([0016](docs/adr/model/0016-pod-hardening.md)). +**Hardening Class**: the pod security posture every Process takes, declared +once by the platform and authored by none of them. It has no exception surface: +a Process states the paths it must write, and an image that cannot meet the +class is refused ([0016](docs/adr/model/0016-pod-hardening.md)). **Runtime Profile**: the profile selected by `runtime`, from which observability and runtime environment variables are derived. Writing one of its keys by hand is a build error. -**Alert Class**: how an alert on this Application should be delivered +**Alert Class**: how urgently a signal about this Application should wake +someone. Urgency only: which receiver, which channel and which severity mapping +belong to the monitoring stack that reads the projection, never to this model ([0021](docs/adr/model/0021-observability-scrape-and-alert-class.md)). **Grant**: declared access to a Secret Store path, its keys, its access tier diff --git a/spec/v1/00-overview.md b/spec/v1/00-overview.md index 8e36a38..8f3af13 100644 --- a/spec/v1/00-overview.md +++ b/spec/v1/00-overview.md @@ -235,8 +235,8 @@ parse-checked in CI. | path | what it shows | |---|---| -| `examples/projects/{auth,knowledge,data}.yml` | Project Intent, one file per project: two-level secret grants, `probes: none` stated explicitly, TCP probes, `placement` dimensions, declared `writablePaths`, `durability` per volume, and the `auth` pair as two Processes of one Application | -| `examples/{knowledge-api,knowledge-ingest-worker,auth-api,platform-postgres}.base.env` | env files, one set **per Process**, threaded with `${dependency:…}` and `${secret:#}` placeholders whose paths byte-match a granted path | +| `examples/{auth,knowledge,data,minimal}/.project.yml` | Project Intent, one file per project: two-level secret grants, `probes: none` stated explicitly, TCP probes, `placement` dimensions, declared `writablePaths`, `durability` per volume, and the `auth` pair as two Processes of one Application | +| `examples/{auth,knowledge,data,minimal}/env//base.env` | env files, one set **per Process** in a directory named for it, threaded with `${dependency:…}` and `${secret:#}` placeholders whose paths byte-match a granted path | | `examples/workflows/project-publish-fragment.yml` | publish on merge, `oras push` then `oras resolve`, read back | | `examples/workflows/compose.yml` | pull participants, assert the estate-wide invariants, **prove the gate can fail** | | `examples/negative/duplicate-application-id/` | a negative fixture, so an invariant that stops running is detectable | @@ -418,10 +418,10 @@ an ADR. ```mermaid flowchart TB subgraph AUTH["layer 1, hand-authored: Project Intent in each owning repository, Platform Intent in the platform's"] - a1["projects/<project>.yml
applications, processes, placement, hardening,
durability, probes, exposure, secrets"] + a1["platform/<project>.project.yml
applications, processes, placement,
writable paths, durability, probes,
exposure, observability, secrets"] a2["env/<process>/*.env
one set per Process"] a3["assets
declarative, never executable"] - a5["platform.yml
tiers, durability policy, engines,
receivers, cadences, providers, bootstrap set"] + a5["platform.intent.yml
substrate facts, bootstrap set,
tiers, durability policy, hardening,
cadences, engines, providers"] end a1 --> FR["Intent Fragments
every authored document, published by digest"] diff --git a/spec/v1/10-project-intent.md b/spec/v1/10-project-intent.md index b53207d..6108a82 100644 --- a/spec/v1/10-project-intent.md +++ b/spec/v1/10-project-intent.md @@ -37,9 +37,17 @@ Layer 1 is authored as two kinds of file: | file | owns | |---|---| -| `platform/.yml` | one project: its `owner`, and every Application in it: processes, surfaces, dependencies, exposure, probes, volumes, placement, hardening, and secret **access** | +| `platform/.project.yml` | one project: its `owner`, and every Application in it: processes, surfaces, dependencies, exposure, probes, volumes, placement, hardening, and secret **access** | | `platform/env//base.env` + `platform/env//.env` | every environment variable that **that Process** receives | +Both paths are normative, and the worked examples follow them. The +`.project.yml` suffix names the `kind` the file carries, `Project`, so a +`platform/` tree holding several project files beside its `env/` tree says which +files a publish step reads without a convention nobody wrote down. A Process's +env files live in **a directory named for the Process**, never in one file per +Process named after it: the overlay `.env` has to sit beside its +`base.env`, and a flat `.base.env` leaves the overlay nowhere to go. + One file is one project and one Intent Fragment ([0063](../../docs/adr/model/0063-intent-authored-per-project.md)). A repository may hold several project files (which is what lets `homelab-collections` stay one @@ -459,8 +467,8 @@ The key set is closed. It exists because a self-delivering Process has to wire its own Vault client, and one of the values it wires (the role name) is derived: written as a literal it is the same staleness class as the `serviceAccountName()` defect, where a hand-maintained name and a derived one -disagreed and nothing noticed. Writing a derived value as a literal is a -Writing a derived value as a literal is a build error, and so is writing a Runtime Profile key at all: `OTEL_*` and +disagreed and nothing noticed. Writing a derived value as a literal is a build +error, and so is writing a Runtime Profile key at all: `OTEL_*` and `PYROSCOPE_*` come from `runtime`, and an exceptional value is not a layer-1 concept: there is no `overrides` field to put it in. Ten `OTEL_*` variables are byte-identical today across `auth-api`, @@ -1312,7 +1320,7 @@ anything. From `scrape` the model derives the **ServiceMonitor or PodMonitor**: its target, its port name and its path are the declared surface and path, and the cadence is the one estate-wide value in the Platform document -([chapter 14](14-platform-intent.md#probe-and-ephemeral-policy)). Nothing about +([chapter 14](14-platform-intent.md#monitor-cadence)). Nothing about that needs a monitoring stack's opinion, so it stays a Deliverable like any other and takes part in the derivation map's properties ([chapter 16](16-dependencies.md#the-derivation-map)). @@ -1633,7 +1641,7 @@ comparison, one rule over one string, which is the property that made the join checkable in the first place. ```yaml -# platform/knowledge.yml +# platform/knowledge.project.yml - path: secret/data/platform/postgres/kb keys: [user, password] ``` @@ -1726,7 +1734,7 @@ this chapter owns: | `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 | -| `delivery: env` or `file` against a Context without `secretsEncryption` | `E_SECRETS_AT_REST_REQUIRED` | render | +| `delivery: env` or `file` against a Platform Intent without `secretsEncryption` | `E_SECRETS_AT_REST_REQUIRED` | render | `keys: ['*']` has no error code because it is not in the grammar: a document carrying it fails schema validation. `E_ROLL_AFFECTS_OTHER_READERS` is the check @@ -1955,9 +1963,9 @@ way: contention decides who arbitrates, not who authors | example | what it exercises | |---|---| | [`minimal/notes.project.yml`](examples/minimal/notes.project.yml) + [`env`](examples/minimal/env/notes-api/base.env) | **read this first.** One project, one Application, one Process, and no field that is not required: 26 authored lines reaching 10 objects, with no grant, no volume and no gap row. It is also the only set that renders on today's pinned inputs, because it holds nothing the secrets-at-rest gate can refuse: see [`minimal/README.md`](examples/minimal/README.md) | -| [`knowledge/knowledge.project.yml`](examples/knowledge/knowledge.project.yml) + [`env`](examples/knowledge/env/knowledge-api.base.env) + [`worker env`](examples/knowledge/env/knowledge-ingest-worker.base.env) | two Processes, two runtimes and therefore two identities, `probes: none` and no `provides` on the worker, grants at **both** levels, a split Subtree path, a `0400` file secret, an `irreplaceable` volume | -| [`auth/auth.project.yml`](examples/auth/auth.project.yml) + [`env`](examples/auth/env/auth-api.base.env) | one Application, two Processes switching atomically; `delivery: self` with `tolerates: reload`, a `self-roll` transit grant taking no placeholder, and the writable paths that retired its hardening exception | -| [`data/data.project.yml`](examples/data/data.project.yml) + [`env`](examples/data/env/platform-postgres.base.env) | three Applications releasing independently in one project, third-party images, a `disk` dimension, TCP probes, and a surface eight Applications consume | +| [`knowledge/knowledge.project.yml`](examples/knowledge/knowledge.project.yml) + [`env`](examples/knowledge/env/knowledge-api/base.env) + [`worker env`](examples/knowledge/env/knowledge-ingest-worker/base.env) | two Processes, two runtimes and therefore two identities, `probes: none` and no `provides` on the worker, grants at **both** levels, a split Subtree path, a `0400` file secret, an `irreplaceable` volume | +| [`auth/auth.project.yml`](examples/auth/auth.project.yml) + [`env`](examples/auth/env/auth-api/base.env) | one Application, two Processes switching atomically; `delivery: self` with `tolerates: reload`, a `self-roll` transit grant taking no placeholder, and the writable paths that retired its hardening exception | +| [`data/data.project.yml`](examples/data/data.project.yml) + [`env`](examples/data/env/postgres/base.env) | three Applications releasing independently in one project, third-party images, a `disk` dimension, TCP probes, and a surface eight Applications consume | The env-file-to-`secrets` cross-check runs over the three larger sets; the minimal one has no grant and no placeholder, which is the base case. `knowledge-api` has diff --git a/spec/v1/14-platform-intent.md b/spec/v1/14-platform-intent.md index 89eb08a..60d0844 100644 --- a/spec/v1/14-platform-intent.md +++ b/spec/v1/14-platform-intent.md @@ -105,8 +105,8 @@ needs fails the build, and a component that should be declared and is not is ## The foundation is declared Vault, VSO, Traefik, Prometheus and Gatus are Applications in project files the -platform owns: `platform/edge.yml`, `platform/secrets.yml`, -`platform/observability.yml`, with an `image`, Processes, `engine`, grants, +platform owns: `platform/edge.project.yml`, `platform/secrets.project.yml`, +`platform/observability.project.yml`, with an `image`, Processes, `engine`, grants, `exposure`, volumes and a Durability Class like any tenant Application ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)). Nothing about them is hand-written, and every estate-wide invariant in diff --git a/spec/v1/16-dependencies.md b/spec/v1/16-dependencies.md index 8addbe9..2488fe6 100644 --- a/spec/v1/16-dependencies.md +++ b/spec/v1/16-dependencies.md @@ -251,7 +251,7 @@ form could not express: | `delivery: env` with `rotation.tolerates: reload` | impossible; a pod's environment is fixed for its lifetime | | `delivery: env` or `file` on a non-KV engine (`transit/`) | impossible; `self` is the only legal delivery for a key that is never materialised | | `access: self-roll` on a path other Applications read, unacknowledged | `E_ROLL_AFFECTS_OTHER_READERS`, computed over the readers of the path | -| `delivery: env` or `file` where the pinned context does not advertise secrets at rest | `E_SECRETS_AT_REST_REQUIRED` ([chapter 60](60-setup.md#secrets-at-rest)) | +| `delivery: env` or `file` where the pinned Platform Intent does not advertise secrets at rest | `E_SECRETS_AT_REST_REQUIRED` ([chapter 60](60-setup.md#secrets-at-rest)) | The roll-impact check is the one nothing in the estate has today: `secret/platform/observability` holds the Prometheus token, the Discord webhook @@ -637,8 +637,8 @@ flowchart LR d_rep["replicas
count + reason"] end - subgraph PIN["Pinned inputs (layer 2)"] - p_ctx["Platform Intent
+ node contract
(allocatable)"] + subgraph PIN["Pinned inputs, each carried by digest (chapter 20)"] + p_ctx["Platform Intent, authored in layer 1
+ node contract
(allocatable)"] p_cs["ClusterState snapshot"] p_img["images lock"] end @@ -657,22 +657,18 @@ flowchart LR r_strat["rollout strategy + surge"] r_prb["container probe timings"] r_dl["progressDeadlineSeconds"] - r_tc["Flux health timeout class"] r_plc["nodeSelector + affinity"] r_bind["recorded PV binding"] k_dep["Deployment / StatefulSet / Job"] - k_svc["Application"] + k_svc["Service"] k_sa["ServiceAccount"] - k_cm["ConfigMap"] + k_cm["ConfigMap
+ derived catalogs"] k_sec["VaultStaticSecret / Secret"] k_pol["Vault policy + auth role"] k_ir["IngressRoute"] k_np["NetworkPolicy"] k_gat["Gatus endpoint"] - k_rch["reachability entry"] - k_edg["edge catalogs"] - k_flx["Flux Kustomization"] k_bkp["backup CronJob + sweep"] k_res["resolved.yml"] @@ -712,14 +708,12 @@ flowchart LR d_exp --> r_tier d_exp --> k_ir - d_exp --> k_rch - d_exp --> k_edg + d_exp --> k_cm d_exp --> k_gat d_exp --> k_np d_exp --> k_res d_prb --> r_prb - d_prb --> r_tc d_prb --> k_gat d_prb --> r_sw d_bud --> r_prb @@ -730,7 +724,6 @@ flowchart LR d_life --> k_dep d_sf --> k_dep - d_sf --> r_tc d_vol --> k_dep d_vol --> r_strat d_vol --> k_bkp @@ -761,9 +754,6 @@ flowchart LR r_plc --> k_dep r_bind --> k_dep r_tier --> k_ir - r_tc --> k_flx - r_ru --> k_flx - r_sw --> k_flx r_ns --> k_res r_sa --> k_res diff --git a/spec/v1/20-resolved-deployment.md b/spec/v1/20-resolved-deployment.md index 9021f8e..2f6837f 100644 --- a/spec/v1/20-resolved-deployment.md +++ b/spec/v1/20-resolved-deployment.md @@ -177,7 +177,7 @@ field's placement link to this anchor rather than copying rows. | `PodDisruptionBudget` | derived | - | emitted only where `replicas` exceeds one, as `maxUnavailable: 1`; a budget over a single replica is a drain deadlock | | `namespace` | derived | - | `-system`, and nothing else ([0063](../../docs/adr/model/0063-intent-authored-per-project.md)); several Applications share one by construction | | requests and limits | derived | - | from `placement.memory` and `placement.cpu`: memory request equals memory limit, cpu request with no cpu limit | -| `securityContext` | derived | - | from `hardening` and its declared exceptions | +| `securityContext` | derived | - | from the one platform `hardening` posture and the Process's declared `writablePaths`; no Process authors a control and no exception relaxes one | | `automountServiceAccountToken` | derived | - | `true` only where a grant carries `delivery: self`; the pod authenticates in that case and in no other ([0087](../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md)) | | the `emptyDir` per writable path, and its `sizeLimit` | derived | - | one mount per declared path, sized from the Platform Intent's ephemeral default ([0092](../../docs/adr/model/0092-writable-paths-are-declared.md)) | | `runAsUser`, `runAsGroup`, `fsGroup` | derived | - | the `uid` and `gid` the images lock resolved; `fsGroup` only where the Process holds a volume ([0082](../../docs/adr/model/0082-images-lock-carries-uid-and-gid.md)) | @@ -465,11 +465,14 @@ Five rules carry most of the weight: `engine`, so two Applications of the same class and engine derive the same objects with different volumes, which is the property that makes a restore rehearsal meaningful ([0077](../../docs/adr/model/0077-durability-derives-a-backup.md)). -- **Hardening is one platform posture plus declared exceptions.** `restricted` ( +- **Hardening is one platform posture, and nothing else.** `restricted` ( `runAsNonRoot`, `readOnlyRootFilesystem`, all capabilities dropped, seccomp `RuntimeDefault`) is declared once in the Platform document and authored by no - Process; each declared exception names one control and carries a reason - ([0016](../../docs/adr/model/0016-pod-hardening.md)). + Process. There is no per-control relaxation and no exception vocabulary: a + Process declares the paths it must write, and an image that cannot meet the + class is `E_HARDENING_UNMET` + ([0016](../../docs/adr/model/0016-pod-hardening.md), + [Hardening has no exception surface either](#hardening-has-no-exception-surface-either)). - **Capacity is not a class.** Requests and limits no longer resolve through a named table in the Platform Intent; they derive from the raw quantities the Process declares, under two shape rules the author does not write. Memory diff --git a/spec/v1/60-setup.md b/spec/v1/60-setup.md index 4d2286b..33e6e07 100644 --- a/spec/v1/60-setup.md +++ b/spec/v1/60-setup.md @@ -156,9 +156,13 @@ for the k3s flag that sets them ([chapter 14](14-platform-intent.md#substrate-facts), [0057](../../docs/adr/model/0057-datastore-and-restore.md), [0097](../../docs/adr/model/0097-authored-values-name-model-concepts.md)). So -are the durability, observability, probe and ephemeral policies that earlier -drafts of this chapter tabulated here; chapter 14 is their normative home, and -this chapter keeps only what setup and restore need from them. +are the durability policy, the monitor cadence, and the probe and ephemeral +policies that earlier drafts of this chapter tabulated here. There is no +observability policy to record: the Platform document carries one monitor +cadence and nothing else, and receivers, severities and rules belong to the +monitoring stack ([chapter 14](14-platform-intent.md#monitor-cadence)). Chapter +14 is their normative home, and this chapter keeps only what setup and restore +need from them. Recording a fact does not choose it. A one-server SQLite cluster stays a one-server SQLite cluster; it stops being an assumption each reader re-derives @@ -229,7 +233,7 @@ without acquiring an owner. v1 makes it mechanical > The pinned Platform Intent carries `secretsEncryption`. The renderer refuses > any `secrets[]` entry with `delivery: env` or `delivery: file` against a -> context that does not advertise `secretsEncryption: true`, with +> Platform Intent that does not advertise `secretsEncryption: true`, with > `E_SECRETS_AT_REST_REQUIRED`. Reading a pinned input rather than the live cluster keeps the check inside @@ -252,7 +256,7 @@ Ticked by: enable the flag, then `sudo strings | grep ` returning nothing while `kubectl get secret canary` still returns the value, the datastore file being the one the [platform facts](#platform-facts-and-restore) record. Then the -context is republished with `secretsEncryption: true` and re-pinned. Owner: +Platform Intent is republished with `secretsEncryption: true` and re-pinned. Owner: joris. Blocks: `delivery: env` and `delivery: file` only. The encryption key file becomes restore-critical: a backup without @@ -327,7 +331,9 @@ derived (`-system`), so no step below names one. 1. Add the Application to its project file, whose shape is [chapter 10](10-project-intent.md#two-artefacts). The file header carries `project` and `owner`, the only field raised to the project; the Application - carries `id`, `alertClass`, `secrets[]` and its Processes; each Process + carries `id`, an optional `observability` block, whole or absent + ([chapter 10](10-project-intent.md#observability)), `secrets[]` and its + Processes; each Process carries its `image`, the ports it `provides`, and its `placement`. Process names are unique within the project (`E_DUPLICATE_PROCESS_NAME`), because the ServiceAccount and the Vault role are the Process name alone diff --git a/spec/v1/diagrams/00-overview-meta-model.drawio.svg b/spec/v1/diagrams/00-overview-meta-model.drawio.svg index a061275..46119f8 100644 --- a/spec/v1/diagrams/00-overview-meta-model.drawio.svg +++ b/spec/v1/diagrams/00-overview-meta-model.drawio.svg @@ -1,4 +1,4 @@ -layer 1 - hand-authoredRequirements and facts, never mechanisms. The contention test decides which of the two authored documents a value lives in,and env files and assets travel inside the same Intent Fragment as the project file they sit beside.Project Intent, in each owning repositoryPlatform Intent, in the platform'sprojects/<project>.ymlapplications, processes, placement,writable paths, durability, probes,exposure, observability, secretsenv/<process>/*.envone set per Processassetsdeclarative,never executableplatform.ymlsubstrate facts, bootstrap set,tiers, durability policy, hardening,cadences, engines, providersplatform project filesthe foundation,declared as ApplicationsIntent Fragmentsevery authored document, published by digestparticipants.ymlevery expected publisher, maxAge 7dcompositionunion + estate-wide invariantsmerges nothing, runs on any publishComposedIntent + CompositionLocklayer 2 - Resolved Deploymentevery platform assignment, the path plan,the release gate's inputs, the capacity exception,a function of the pinned inputs alonenode contractby digestClusterState snapshotclusterStateDigestimages lockdigests, uid, gid: never tagsresolved.ymlpublished back per Applicationlayer 3 - Deliverable Setsix registered adapters, run once centrally,one attributed adapter per filedelivery - DEFINED SEPARATELYdocs/adr/deferred/must honour Release Unit atomicity,Durability Class gates, pinned inputs onlythe clusteran owner reads their own assignmentsbeside the file they authoredLayer 1 contains no mechanisms.Layer 3 contains no decisions.A dashed border means defined separatelyfrom the model. \ No newline at end of file +layer 1 - hand-authoredRequirements and facts, never mechanisms. The contention test decides which of the two authored documents a value lives in,and env files and assets travel inside the same Intent Fragment as the project file they sit beside.Project Intent, in each owning repositoryPlatform Intent, in the platform'splatform/<project>.project.ymlapplications, processes, placement,writable paths, durability, probes,exposure, observability, secretsenv/<process>/*.envone set per Processassetsdeclarative,never executableplatform.intent.ymlsubstrate facts, bootstrap set,tiers, durability policy, hardening,cadences, engines, providersplatform project filesthe foundation,declared as ApplicationsIntent Fragmentsevery authored document, published by digestparticipants.ymlevery expected publisher, maxAge 7dcompositionunion + estate-wide invariantsmerges nothing, runs on any publishComposedIntent + CompositionLocklayer 2 - Resolved Deploymentevery platform assignment, the path plan,the release gate's inputs, the capacity exception,a function of the pinned inputs alonenode contractby digestClusterState snapshotclusterStateDigestimages lockdigests, uid, gid: never tagsresolved.ymlpublished back per Applicationlayer 3 - Deliverable Setsix registered adapters, run once centrally,one attributed adapter per filedelivery - DEFINED SEPARATELYdocs/adr/deferred/must honour Release Unit atomicity,Durability Class gates, pinned inputs onlythe clusteran owner reads their own assignmentsbeside the file they authoredLayer 1 contains no mechanisms.Layer 3 contains no decisions.A dashed border means defined separatelyfrom the model. \ No newline at end of file diff --git a/spec/v1/examples/auth/auth.project.yml b/spec/v1/examples/auth/auth.project.yml index ceb99c7..32621a6 100644 --- a/spec/v1/examples/auth/auth.project.yml +++ b/spec/v1/examples/auth/auth.project.yml @@ -1,6 +1,6 @@ # Worked example: the auth project's Intent Fragment # -# applications/auth/platform/auth.yml +# applications/auth/platform/auth.project.yml # # Intent is authored ONE FILE PER PROJECT, holding every Application that project # owns, and one file is one Intent Fragment @@ -17,7 +17,7 @@ # # Companion env files, one per PROCESS # (docs/adr/model/0011-configuration-env-files-per-process.md): -# platform/env/auth-api/base.env -- reproduced as examples/auth-api.base.env +# platform/env/auth-api/base.env -- reproduced as env/auth-api/base.env # platform/env/auth-ui/base.env # The DATA MODEL's own semver, not the toolkit package's version diff --git a/spec/v1/examples/auth/env/auth-api.base.env b/spec/v1/examples/auth/env/auth-api/base.env similarity index 97% rename from spec/v1/examples/auth/env/auth-api.base.env rename to spec/v1/examples/auth/env/auth-api/base.env index f7bfd65..9d279ae 100644 --- a/spec/v1/examples/auth/env/auth-api.base.env +++ b/spec/v1/examples/auth/env/auth-api/base.env @@ -7,9 +7,9 @@ # the identity reading them differs. # # The Application is declared in the auth project's Intent Fragment, -# platform/auth.yml -- one file per project +# platform/auth.project.yml -- one file per project # (docs/adr/model/0063-intent-authored-per-project.md), reproduced as -# examples/projects/auth.yml. +# examples/auth/auth.project.yml. # # Note what is NOT here. auth-api holds three grants and none of them appear in # this file, because all three are delivery: self -- the application fetches diff --git a/spec/v1/examples/auth/rendered/README.md b/spec/v1/examples/auth/rendered/README.md index 107bed8..4a93264 100644 --- a/spec/v1/examples/auth/rendered/README.md +++ b/spec/v1/examples/auth/rendered/README.md @@ -2,7 +2,7 @@ What a renderer must produce from [`../auth.project.yml`](../auth.project.yml) and -[`../env/auth-api.base.env`](../env/auth-api.base.env), rendered by hand against +[`../env/auth-api/base.env`](../env/auth-api/base.env), rendered by hand against the model as decided: chapter 10 (intent), chapter 16 (identity, edges, policy), chapter 20 (the derivations), chapter 30 (adapters and attribution), and the two amendments in `review/PLACEMENT-DOMAIN-MANIFEST.md` and diff --git a/spec/v1/examples/data/data.project.yml b/spec/v1/examples/data/data.project.yml index ab7d22f..fbc0c88 100644 --- a/spec/v1/examples/data/data.project.yml +++ b/spec/v1/examples/data/data.project.yml @@ -1,6 +1,6 @@ # Worked example: the data project's Intent Fragment # -# collections/data/platform/data.yml +# collections/data/platform/data.project.yml # # THREE Applications in one file. They are neighbours, not a unit: co-location in a # project file couples nothing, and each of the three releases on its own @@ -23,7 +23,7 @@ # hostname that does not follow its Application id. # # Companion env file (per Process, like every env file): -# platform/env/postgres/base.env -- examples/platform-postgres.base.env +# platform/env/postgres/base.env -- reproduced as env/postgres/base.env # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). diff --git a/spec/v1/examples/data/env/platform-postgres.base.env b/spec/v1/examples/data/env/postgres/base.env similarity index 87% rename from spec/v1/examples/data/env/platform-postgres.base.env rename to spec/v1/examples/data/env/postgres/base.env index de91df3..c34b1d5 100644 --- a/spec/v1/examples/data/env/platform-postgres.base.env +++ b/spec/v1/examples/data/env/postgres/base.env @@ -6,9 +6,9 @@ # The single secret placeholder is consumed by the postgres-exporter sidecar. # # The Application this Process belongs to is declared in the data project's Intent -# Fragment, platform/data.yml, alongside platform-rabbitmq and platform-valkey +# Fragment, platform/data.project.yml, alongside platform-rabbitmq and platform-valkey # (docs/adr/model/0063-intent-authored-per-project.md) -- reproduced as -# examples/projects/data.yml. Sharing that file couples nothing: the three +# examples/data/data.project.yml. Sharing that file couples nothing: the three # Applications release independently # (docs/adr/model/0062-application-is-the-release-unit.md). diff --git a/spec/v1/examples/data/rendered/README.md b/spec/v1/examples/data/rendered/README.md index b057775..3637c0d 100644 --- a/spec/v1/examples/data/rendered/README.md +++ b/spec/v1/examples/data/rendered/README.md @@ -1,7 +1,7 @@ # Rendered output: the `data` project What a renderer must produce from [`../data.project.yml`](../data.project.yml) and -[`../env/platform-postgres.base.env`](../env/platform-postgres.base.env), +[`../env/postgres/base.env`](../env/postgres/base.env), rendered by hand against the model as decided: chapter 10 (intent), chapter 16 (identity, edges, policy), chapter 20 (the derivations), chapter 30 (adapters and attribution), and the two amendments in `review/PLACEMENT-DOMAIN-MANIFEST.md` @@ -317,7 +317,7 @@ both trace to that declaration. The id is not reused and nothing is renumbered. | G-25 | **One VaultAuth per estate, or one per Process?** This render emits one per holding Process, which is what makes the per-Process Vault role load-bearing for `env` delivery: the read happens under `data-system.postgres`, bound to exactly its own effective grant set. The registered adapter emits one VaultAuth in `vso-system` on the operator's own ServiceAccount and role, under which the operator reads every path for everyone and the derived roles do nothing | | G-26 | **The Vault policy and Kubernetes auth role have no producer.** `vso` emits `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret` and a ServiceAccount; none is a policy or an auth role, and no other registered adapter writes to Vault. The `read` capability on `secret/data/platform/postgres/exporter` that the whole file depends on is derived by the model and applied by nothing. The same hole as auth's G-02, reached from `delivery: env` rather than `delivery: self` | | G-27 | **Architecture is never checked against the locked digest.** `platform-rabbitmq` and `platform-valkey` declare no `arch`, so all seven nodes are eligible, four amd64 and three arm64. The images lock resolves an alias to **one** digest, and a single-architecture digest scheduled onto a Pi is an `exec format error` at runtime. The model holds both facts and compares them nowhere. `platform-postgres` writes `arch: [amd64]` because pgvector publishes no arm64 pg17 build, and that too is an unverified assertion by the author rather than a fact read from the lock | -| G-28 | **Two of three Processes have no env file in the example set.** The model requires one per Process; only `platform-postgres.base.env` is reproduced, so `rabbitmq` and `valkey` render with no `env` at all. Inventing entries for files that exist would be worse than rendering none | +| G-28 | **Two of three Processes have no env file in the example set.** The model requires one per Process; only `env/postgres/base.env` is reproduced, so `rabbitmq` and `valkey` render with no `env` at all. Inventing entries for files that exist would be worse than rendering none | | G-29 | **Default-deny cannot ship non-enforcing, and this is the namespace to prove it on.** `networking.k8s.io/v1` has no audit, dry-run or log-only mode, and k3s's embedded kube-router has none either, and a policy is enforced the moment it selects a pod. Chapter 16 sequences render-only → audit (zero undeclared flows over 14 days) → enforce, and the audit stage needs a CNI carrying a non-enforcing policy stage that no decision has picked. Enforcing the four policies in this project on day one cuts five live consumers off the datastore ([G-22](#g-22)) | | G-31 | **The forward-auth `Middleware` object has no producer.** This is the example set's first rendered route with `audience: authenticated`, so it is the first that needs one. The registry says `traefik` emits IngressRoutes "with middleware references", references only, and nothing emits the Middleware those references resolve to | | G-32 | **An exposure in this project feeds an env value in another, and the predicate is undefined.** This route is what puts `rabbitmq` among the nine hostnames `auth-api`'s `AUTH_CORS_ALLOWED_ORIGINS` maintains by hand today. Chapter 16's open item 1 says the derivation is probably "inbound edges declaring a browser surface"; no field declares one, so the list stays hand-maintained and this route contributes to it invisibly | diff --git a/spec/v1/examples/knowledge/env/knowledge-api.base.env b/spec/v1/examples/knowledge/env/knowledge-api/base.env similarity index 97% rename from spec/v1/examples/knowledge/env/knowledge-api.base.env rename to spec/v1/examples/knowledge/env/knowledge-api/base.env index c3bbbfa..9fa779d 100644 --- a/spec/v1/examples/knowledge/env/knowledge-api.base.env +++ b/spec/v1/examples/knowledge/env/knowledge-api/base.env @@ -1,9 +1,9 @@ # applications/knowledge/platform/env/knowledge-api/base.env # # One file per Process (docs/adr/model/0011-configuration-env-files-per-process.md), -# beside the project file that grants these paths: platform/knowledge.yml +# beside the project file that grants these paths: platform/knowledge.project.yml # (docs/adr/model/0063-intent-authored-per-project.md), reproduced as -# examples/projects/knowledge.yml. +# examples/knowledge/knowledge.project.yml. # Literals are literal. Derived values are named placeholders. A literal for a # derived key is a build error, and so is any OTEL_* or PYROSCOPE_* key -- # those come from runtime: jvm. diff --git a/spec/v1/examples/knowledge/env/knowledge-ingest-worker.base.env b/spec/v1/examples/knowledge/env/knowledge-ingest-worker/base.env similarity index 95% rename from spec/v1/examples/knowledge/env/knowledge-ingest-worker.base.env rename to spec/v1/examples/knowledge/env/knowledge-ingest-worker/base.env index a2e899f..9ecb9f6 100644 --- a/spec/v1/examples/knowledge/env/knowledge-ingest-worker.base.env +++ b/spec/v1/examples/knowledge/env/knowledge-ingest-worker/base.env @@ -3,7 +3,7 @@ # The second Process of the same Application, and therefore the second file: # Processes of one Application do not share an environment # (docs/adr/model/0011-configuration-env-files-per-process.md). Both are declared in -# the same project file, platform/knowledge.yml. Note what it shares +# the same project file, platform/knowledge.project.yml. Note what it shares # and what it does not -- the Postgres and RabbitMQ credentials come from # Application-level grants, so they are referenced here as well as in # knowledge-api's file. The deploy key is a Process-level grant with diff --git a/spec/v1/examples/knowledge/knowledge.project.yml b/spec/v1/examples/knowledge/knowledge.project.yml index 174d8d9..4f37e97 100644 --- a/spec/v1/examples/knowledge/knowledge.project.yml +++ b/spec/v1/examples/knowledge/knowledge.project.yml @@ -1,6 +1,6 @@ # Worked example: the knowledge project's Intent Fragment # -# applications/knowledge/platform/knowledge.yml +# applications/knowledge/platform/knowledge.project.yml # # One file per project, one file one Intent Fragment # (docs/adr/model/0063-intent-authored-per-project.md). The knowledge project owns one @@ -16,9 +16,9 @@ # Companion env files, one per PROCESS # (docs/adr/model/0011-configuration-env-files-per-process.md): # platform/env/knowledge-api/base.env -# -- reproduced as examples/knowledge-api.base.env +# -- reproduced as env/knowledge-api/base.env # platform/env/knowledge-ingest-worker/base.env -# -- reproduced as examples/knowledge-ingest-worker.base.env +# -- reproduced as env/knowledge-ingest-worker/base.env # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). diff --git a/spec/v1/examples/platform/README.md b/spec/v1/examples/platform/README.md index 65b3457..ebea3fd 100644 --- a/spec/v1/examples/platform/README.md +++ b/spec/v1/examples/platform/README.md @@ -19,7 +19,7 @@ nothing in it names a Kubernetes field, a Traefik key, a k3s flag or a command | `tiers` | the shared edge is finite; four edge facts, and the Traefik Application each tier is | [0076](../../../../docs/adr/model/0076-middleware-has-one-producer.md), [0097](../../../../docs/adr/model/0097-authored-values-name-model-concepts.md) | | `durability` | a backup window is one node's IO, a destination one remote target | [0077](../../../../docs/adr/model/0077-durability-derives-a-backup.md) | | `engines` | the method is an image; nothing authored is executable | [0097](../../../../docs/adr/model/0097-authored-values-name-model-concepts.md) | -| `observability` | a receiver is a shared channel, the scrape budget shared ingest | [0079](../../../../docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md) | +| `monitors` | one scrape cadence for the estate, because ingest is shared and no Application knows better. It is the whole observability surface of this document: no receiver map, no severity mapping, no rule catalog ([chapter 14](../../14-platform-intent.md#monitor-cadence)) | [0021](../../../../docs/adr/model/0021-observability-scrape-and-alert-class.md), [0079](../../../../docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md) | | `probes`, `ephemeral` | one cadence and one size for the estate | [0088](../../../../docs/adr/model/0088-startup-probe-targets-liveness.md), [0092](../../../../docs/adr/model/0092-writable-paths-are-declared.md) | | `providers` | something the estate reaches and does not deploy: a fact, not a hole | [0090](../../../../docs/adr/model/0090-edges-resolve-against-the-register.md), [0095](../../../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md) | diff --git a/spec/v1/examples/workflows/project-publish-fragment.yml b/spec/v1/examples/workflows/project-publish-fragment.yml index 1d893c0..34045e4 100644 --- a/spec/v1/examples/workflows/project-publish-fragment.yml +++ b/spec/v1/examples/workflows/project-publish-fragment.yml @@ -7,7 +7,7 @@ # -> verify digest # # ONE PROJECT FILE IS ONE FRAGMENT (docs/adr/model/0063-intent-authored-per-project.md). -# This repository holds one, platform/knowledge.yml, so it publishes one +# This repository holds one, platform/knowledge.project.yml, so it publishes one # fragment; a repository holding several project files runs these steps once per # file, and a project never spans repositories, so no fragment is ever a partial # project. @@ -84,7 +84,7 @@ jobs: # Estate-wide invariants cannot be checked here and are # composition's job. npx --no-install deploy-config-schema intent validate \ - --project platform/knowledge.yml \ + --project platform/knowledge.project.yml \ --env-dir platform/env \ --strict @@ -99,7 +99,7 @@ jobs: # one directory per Process is unambiguous across every Application the # file declares (docs/adr/model/0024-identity-per-process.md). npx --no-install deploy-config-schema intent pack \ - --project platform/knowledge.yml \ + --project platform/knowledge.project.yml \ --env-dir platform/env \ --out fragment/ From 4c640c18e048a7bc097d983569bf3c19c57e6d3d Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 12:09:48 +0200 Subject: [PATCH 2/2] docs: rename the three-layer meta-model to the three-model pipeline The three layers are three models, each joined to the next by a transformation. They are not metalevels: no layer is a type model of the layer below it, and "meta-model" claims that relation. - 0003's file name, chapter 00's heading and its anchor, the overview diagram's file name and embedded diagram name, CONTEXT.md's section and the README now say "three-model pipeline". 0003's decision and its H1 are unchanged; its Why records the rename. - CONTEXT.md defines "Three-model pipeline" and "Metamodel", the latter kept for a language definition only, and says why "meta-model" is retired. - The anchor, 0003's normative pointer, the register row and every link at the old file names move in this one commit, so lint:adrs and lint:links resolve throughout. That includes 0105, 0116 and the model-driven 0109, which cite 0003 and postdate the first draft of this change. --- CONTEXT.md | 28 ++++++++++++++----- README.md | 7 +++-- docs/adr/README.md | 2 +- ...5-one-hexagon-domain-mirrors-the-layers.md | 2 +- ...7-adapters-build-objects-one-serializer.md | 2 +- ...plementations-meet-at-committed-oracles.md | 2 +- ...-model.md => 0003-three-model-pipeline.md} | 10 ++++++- docs/adr/model/0055-bidirectional-ledgers.md | 2 +- .../0062-application-is-the-release-unit.md | 2 +- .../model/0070-path-authority-is-layer-2.md | 4 +-- ...-intent-is-the-second-authored-document.md | 2 +- .../model/0116-project-application-process.md | 2 +- docs/architecture.md | 2 +- ...-and-target-metamodels-are-hand-written.md | 4 +-- spec/v1/00-overview.md | 20 +++++++++---- spec/v1/10-project-intent.md | 2 +- spec/v1/14-platform-intent.md | 2 +- .../00-overview-meta-model.drawio.svg | 4 --- ...0-overview-three-model-pipeline.drawio.svg | 4 +++ spec/v1/examples/minimal/README.md | 2 +- 20 files changed, 70 insertions(+), 35 deletions(-) rename docs/adr/model/{0003-three-layer-meta-model.md => 0003-three-model-pipeline.md} (89%) delete mode 100644 spec/v1/diagrams/00-overview-meta-model.drawio.svg create mode 100644 spec/v1/diagrams/00-overview-three-model-pipeline.drawio.svg diff --git a/CONTEXT.md b/CONTEXT.md index bcf39d0..92f2d3e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,12 +11,26 @@ It is also the naming authority for code. A type, a folder or a diagnostic that names one of these concepts uses the word below, unchanged, so that a reviewer moving between a chapter and a module never translates. -## The three layers - -Deployment configuration is split into three layers, and the middle one is a -contract ([0003](docs/adr/model/0003-three-layer-meta-model.md), normative in -[chapter 00](spec/v1/00-overview.md#the-meta-model)). **Layer 1 contains no -mechanisms; layer 3 contains no decisions.** +## The three-model pipeline + +**Three-model pipeline**: the arrangement of the whole model. Deployment +configuration is split into three layers, and the middle one is a contract +([0003](docs/adr/model/0003-three-model-pipeline.md), normative in +[chapter 00](spec/v1/00-overview.md#the-three-model-pipeline)). **Layer 1 +contains no mechanisms; layer 3 contains no decisions.** Each layer is a +**model**, and each is joined to the next by a transformation. The layers are +stages of one pipeline, not metalevels: no layer is a type model of the one +below it. So the arrangement is not a "meta-model", a name this record used to +carry and has retired; say *three-model pipeline*, and say *layer 1*, *layer 2* +and *layer 3* for its members. + +**Metamodel**: the definition of the language a model is written in, and +nothing else. A metamodel is a model whose instances are models, so the word is +never used for a layer, a document, an abstraction level or the pipeline as a +whole. The production implementation declares its languages as wire schemas; +the model-driven implementation under `emf/` has two hand-written Ecore +metamodels, a source and a target, and generates the Deliverable Set as files +with no metamodel of its own. One word, `metamodel`, unhyphenated. **Project Intent**: layer 1. What a project's repository authors by hand: requirements, never mechanisms. Two kinds of file, a project file and one env file @@ -241,7 +255,7 @@ described observed context and the content is authored intent **Deployment.** Ambiguous between the Kubernetes kind and the estate's old `deployment.jorisjonkers.dev` documents, which is the confusion -[0003](docs/adr/model/0003-three-layer-meta-model.md) exists to end. Say +[0003](docs/adr/model/0003-three-model-pipeline.md) exists to end. Say Resolved Deployment, or say the Kubernetes kind. **Reload.** A secret's `rotation.tolerates: reload`, and nothing else: the diff --git a/README.md b/README.md index dbc30cc..052598a 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,11 @@ lands, the **compiler** that turns that model into deployable artifacts. ## The shape of the model -Three layers, and the middle one is a contract -([0003](docs/adr/model/0003-three-layer-meta-model.md)): +**The three-model pipeline**: three models, each joined to the next by a +transformation, with the middle one as a contract +([0003](docs/adr/model/0003-three-model-pipeline.md)). They are stages of a +pipeline, not metalevels, which is why "metamodel" here means a language +definition and nothing else ([`CONTEXT.md`](CONTEXT.md#the-three-model-pipeline)): 1. **Project Intent**: hand-authored, requirements only. What an application owner knows and nobody else does: its cold-start budget, what its data is worth, diff --git a/docs/adr/README.md b/docs/adr/README.md index 6adabe2..386bb8b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -81,7 +81,7 @@ check both registers. |---|---|---|---| | [0001](model/0001-estate-scale-and-ownership.md) | The estate is one maintainer, one cluster, about thirty Applications | open | 00-overview.md#the-estate | | [0002](model/0002-kubernetes-as-substrate.md) | Kubernetes stays, for two properties that must be made real | open | 00-overview.md#substrate | -| [0003](model/0003-three-layer-meta-model.md) | Three layers, with the middle layer as a contract | settled | 00-overview.md#the-meta-model | +| [0003](model/0003-three-model-pipeline.md) | Three layers, with the middle layer as a contract | settled | 00-overview.md#the-three-model-pipeline | | [0004](model/0004-contention-decides-authority.md) | Contention decides who declares a value | open | 20-resolved-deployment.md#authority | | [0005](model/0005-derivation-is-total.md) | Derivation from declared intent covers the live estate | open | 20-resolved-deployment.md#derived-mechanics | | [0006](model/0006-pinned-inputs.md) | Every assignment is a function of pinned, digested inputs | open | 20-resolved-deployment.md#pinned-inputs | diff --git a/docs/adr/architecture/0065-one-hexagon-domain-mirrors-the-layers.md b/docs/adr/architecture/0065-one-hexagon-domain-mirrors-the-layers.md index c163af3..c743d34 100644 --- a/docs/adr/architecture/0065-one-hexagon-domain-mirrors-the-layers.md +++ b/docs/adr/architecture/0065-one-hexagon-domain-mirrors-the-layers.md @@ -39,7 +39,7 @@ The model already names the layers, and the code that renders it kept its own unrelated shape. The generation being replaced put resolution inside the renderer, and the estate paid for it in vocabulary: three mutually incompatible documents all claiming `deployment.jorisjonkers.dev/v2`, with no word for which -one was wrong ([0003](../model/0003-three-layer-meta-model.md)). Naming the +one was wrong ([0003](../model/0003-three-model-pipeline.md)). Naming the folders after the layers is not decoration. It means a reviewer reading [chapter 20](../../../spec/v1/20-resolved-deployment.md) and a reviewer reading `src/domain/resolved/` are looking at the same thing, and a rule that lands in diff --git a/docs/adr/architecture/0067-adapters-build-objects-one-serializer.md b/docs/adr/architecture/0067-adapters-build-objects-one-serializer.md index fcb1882..e670233 100644 --- a/docs/adr/architecture/0067-adapters-build-objects-one-serializer.md +++ b/docs/adr/architecture/0067-adapters-build-objects-one-serializer.md @@ -33,7 +33,7 @@ difference to enter. One serializer makes determinism one module's responsibility, and the double-render check then has one place to fail. The typed object model earns its place for a second reason, which is stronger. -Layer 3 contains no decisions ([0003](../model/0003-three-layer-meta-model.md)), +Layer 3 contains no decisions ([0003](../model/0003-three-model-pipeline.md)), and a string builder cannot be held to that: any field can be written, including one no chapter assigns. A narrow object model declaring only the fields this estate sets turns the rule into a compile error: an adapter cannot set what the diff --git a/docs/adr/architecture/0105-two-implementations-meet-at-committed-oracles.md b/docs/adr/architecture/0105-two-implementations-meet-at-committed-oracles.md index e8f2227..d81d346 100644 --- a/docs/adr/architecture/0105-two-implementations-meet-at-committed-oracles.md +++ b/docs/adr/architecture/0105-two-implementations-meet-at-committed-oracles.md @@ -44,7 +44,7 @@ make one side a derivative the other cannot be checked against. Two hand-written implementations can drift, so equality has to be proven. It is proven at the layer boundaries the model already has -([0003](../model/0003-three-layer-meta-model.md)). A parsed intent, a Resolved +([0003](../model/0003-three-model-pipeline.md)). A parsed intent, a Resolved Deployment and a Deliverable Set are each a whole document, and [0006](../model/0006-pinned-inputs.md) makes each a function of pinned inputs, so a committed canonical copy of each is an oracle both sides can be held to diff --git a/docs/adr/model/0003-three-layer-meta-model.md b/docs/adr/model/0003-three-model-pipeline.md similarity index 89% rename from docs/adr/model/0003-three-layer-meta-model.md rename to docs/adr/model/0003-three-model-pipeline.md index aa3a74b..0866c90 100644 --- a/docs/adr/model/0003-three-layer-meta-model.md +++ b/docs/adr/model/0003-three-model-pipeline.md @@ -3,7 +3,7 @@ tier: premise status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/00-overview.md#the-meta-model +normative: spec/v1/00-overview.md#the-three-model-pipeline --- # Three layers, with the middle layer as a contract @@ -52,6 +52,14 @@ the Intent plus the pinned inputs, the cluster-state snapshot among them ([0034](0034-cluster-state-pinned-input.md)), and it is the sole input the deliverable layer's adapters accept ([0053](0053-adapter-port-contract.md)). +**The name, amended 2026-09-15.** This decision was filed as "the three-layer +meta-model", and that name claims something the decision does not. The three +layers are three **models**, each written in its own language and joined to +the next by a transformation; no layer is a type model of the layer below it, +which is what a metamodel requires. The arrangement is the **three-model +pipeline**, "metamodel" is kept for a language definition, and the decision +itself, its H1 and everything else in this record are unchanged. + ## Alternatives | option | cost if taken | why rejected | diff --git a/docs/adr/model/0055-bidirectional-ledgers.md b/docs/adr/model/0055-bidirectional-ledgers.md index 025e071..6dc5ca3 100644 --- a/docs/adr/model/0055-bidirectional-ledgers.md +++ b/docs/adr/model/0055-bidirectional-ledgers.md @@ -25,7 +25,7 @@ rests-on: ["0003"] ## Rests on -Under [0003](0003-three-layer-meta-model.md) layer 3 is a total function of the +Under [0003](0003-three-model-pipeline.md) layer 3 is a total function of the Resolved Deployment, so every live object the render does not produce can be named as a predicate over the cluster inventory (kind, namespace, and a name or name pattern) and so matched or found unmatched by machine. False if: an diff --git a/docs/adr/model/0062-application-is-the-release-unit.md b/docs/adr/model/0062-application-is-the-release-unit.md index 483354d..c425d2e 100644 --- a/docs/adr/model/0062-application-is-the-release-unit.md +++ b/docs/adr/model/0062-application-is-the-release-unit.md @@ -60,7 +60,7 @@ is derived and the other is authored. |---|---|---| | A `releaseWith` field, valid only within the project file | The deleted field worn narrower: the schema keeps a coupling name plus a rule that its target is in the same file, and two Applications in one file get a second way to say "together" | The file-scope rule is the Application boundary spelled out longhand; it adds a record without adding a fact, and the record can disagree with the boundary | | Co-location implies atomicity: every Application in a project file releases as one | Adding an unrelated Application to `media.yml` silently couples its rollout to nine others, and nothing in the file says so | The trap: coupling acquired by editing an unrelated line, discoverable only when a held release blocks an Application its owner never coupled | -| A `components` level between Application and Process | A third authoring level whose only job is grouping Processes, and every field must then be assigned to Application, component or Process | Fails [0003](0003-three-layer-meta-model.md)'s two deciding questions, the level records no decision, and Application is already that grouping | +| A `components` level between Application and Process | A third authoring level whose only job is grouping Processes, and every field must then be assigned to Application, component or Process | Fails [0003](0003-three-model-pipeline.md)'s two deciding questions, the level records no decision, and Application is already that grouping | ## Reversibility Undo cost today: reintroduce an Application-level coupling field and split the merged diff --git a/docs/adr/model/0070-path-authority-is-layer-2.md b/docs/adr/model/0070-path-authority-is-layer-2.md index 86360a9..3379787 100644 --- a/docs/adr/model/0070-path-authority-is-layer-2.md +++ b/docs/adr/model/0070-path-authority-is-layer-2.md @@ -23,7 +23,7 @@ projects with `E_PATH_COLLISION` evaluated on the assembled plan, before any adapter runs, and no adapter holding an API that returns a path. ## Why -Layer 3 contains no decisions ([0003](0003-three-layer-meta-model.md)), and a +Layer 3 contains no decisions ([0003](0003-three-model-pipeline.md)), and a path is a decision. It says which directory owns an object, and therefore which kustomization includes it, which Reconcile Unit applies it, and who is answerable for a field. The earlier rule, a path is a pure function of its @@ -59,7 +59,7 @@ not let one Adapter write into another's. | option | cost if taken | why rejected | |---|---|---| | Keep the path a function of the adapter and the object | No spec edit; matches the sentence chapter 30 already carries | Leaves the per-project object and the estate-scoped Deliverable unresolvable, and keeps collision detection at the writer, where it has never existed | -| A layout policy module consulted by both layers | An explicit seam, testable alone | A component holding decisions while sitting outside the three layers is the unnamed middle the meta-model exists to prevent, and it would own authority no chapter assigns it | +| A layout policy module consulted by both layers | An explicit seam, testable alone | A component holding decisions while sitting outside the three layers is the unnamed middle the three-model pipeline exists to prevent, and it would own authority no chapter assigns it | | Let the writer resolve collisions by precedence | Nothing to design; deterministic given an order | Encodes authority as evaluation order, which is invisible in every artifact a reviewer reads, and makes adding an adapter a change to what an existing one emits | ## Reversibility diff --git a/docs/adr/model/0095-platform-intent-is-the-second-authored-document.md b/docs/adr/model/0095-platform-intent-is-the-second-authored-document.md index 872229b..9440abc 100644 --- a/docs/adr/model/0095-platform-intent-is-the-second-authored-document.md +++ b/docs/adr/model/0095-platform-intent-is-the-second-authored-document.md @@ -56,7 +56,7 @@ it was a digest nobody compared to a clock. Two things deliberately stay out. Foundation components are Applications in project files the platform owns ([0096](0096-the-foundation-is-declared.md)), because a second way to declare an Application is the duplicate vocabulary -[0003](0003-three-layer-meta-model.md) exists to end. The node contract stays +[0003](0003-three-model-pipeline.md) exists to end. The node contract stays its own pinned input ([0056](0056-node-facts-single-source.md)), because nix reads it and would otherwise read a deployment-model document. diff --git a/docs/adr/model/0116-project-application-process.md b/docs/adr/model/0116-project-application-process.md index 1a5b298..c96b209 100644 --- a/docs/adr/model/0116-project-application-process.md +++ b/docs/adr/model/0116-project-application-process.md @@ -11,7 +11,7 @@ rests-on: ["0003"] ## Rests on The three levels of layer 1 are fixed by the model rather than by any -substrate ([0003](0003-three-layer-meta-model.md)): one authored file with one +substrate ([0003](0003-three-model-pipeline.md)): one authored file with one owner, a set of runnable parts that switch version together, and one runnable part. Their names therefore describe those roles and nothing the substrate spells. False if: a level's new name is also a name the rendered output uses diff --git a/docs/architecture.md b/docs/architecture.md index 6535399..a623dc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,7 +168,7 @@ Adapters build **typed objects**, not text. The object model ([`src/objects/`](#layers)) declares only the fields this estate sets, so a field the model cannot express cannot be set by an adapter: which is how "layer 3 contains no decisions" -([0003](adr/model/0003-three-layer-meta-model.md)) becomes a compile-time +([0003](adr/model/0003-three-model-pipeline.md)) becomes a compile-time property rather than a review question. One serializer in the infrastructure ring turns those objects into bytes. It diff --git a/emf/docs/adr/emf/0109-source-and-target-metamodels-are-hand-written.md b/emf/docs/adr/emf/0109-source-and-target-metamodels-are-hand-written.md index 45a90c2..2caa724 100644 --- a/emf/docs/adr/emf/0109-source-and-target-metamodels-are-hand-written.md +++ b/emf/docs/adr/emf/0109-source-and-target-metamodels-are-hand-written.md @@ -43,8 +43,8 @@ written by hand because the course grades authored metamodels and because generating it from Zod would make its structure unfalsifiable against Zod. The proposal defines two metamodels, a source and a target, and the course -grades them as such. The model's three layers -([0003](../../../../docs/adr/model/0003-three-layer-meta-model.md)) still hold: +grades them as such. The three layers of the three-model pipeline +([0003](../../../../docs/adr/model/0003-three-model-pipeline.md)) still hold: the Deliverable Set is the generated files, so it has no metamodel, and the target metamodel carries layer 2 together with the typed resources the files are written from, because an Acceleo template reads one model. The Platform diff --git a/spec/v1/00-overview.md b/spec/v1/00-overview.md index 8f3af13..806b7f0 100644 --- a/spec/v1/00-overview.md +++ b/spec/v1/00-overview.md @@ -72,10 +72,20 @@ registered adapters do not: five of the six emit Kubernetes kinds and the sixth emits Vault configuration, which is why the swap is a v2 migration rather than an undo once repositories author against a shipped v1. -## The meta-model +## The three-model pipeline Deployment configuration is split into three layers, and the middle one is a -contract ([0003](../../docs/adr/model/0003-three-layer-meta-model.md)). +contract ([0003](../../docs/adr/model/0003-three-model-pipeline.md)). + +The three layers are **three models**, each written in its own language and +joined to the next by a transformation. They are **not metalevels**: no layer is +a type model of the layer below it, and calling the arrangement a "meta-model" +claims a relation the layers do not have. A metamodel needs the type-model-of +relation applied twice, which is what separates it from a model of a model; the +layers here are stages of one modelling pipeline, and a modelling layer is a +different thing from an abstraction layer. This specification therefore keeps +**metamodel** for a language definition and calls the arrangement itself the +three-model pipeline. | Layer | Name | Authored | Owns | |---|---|---|---| @@ -111,9 +121,9 @@ time reads live cluster state. Reproducibility is therefore conditional and true: identical inputs *including* `clusterStateDigest` produce a byte-identical tree, so a differing render with identical digests is a defect, never weather. -![The meta-model](diagrams/00-overview-meta-model.drawio.svg) +![The three-model pipeline](diagrams/00-overview-three-model-pipeline.drawio.svg) -[Diagram source](#the-meta-model) · edit by opening the SVG in draw.io +[Diagram source](#the-three-model-pipeline) · edit by opening the SVG in draw.io ## Programme scope @@ -413,7 +423,7 @@ a plain diff. **Where the two disagree the SVG is the diagram and the mermaid is what gets fixed**, the same precedence this repository uses between a chapter and an ADR. -### The meta-model +### The three-model pipeline ```mermaid flowchart TB diff --git a/spec/v1/10-project-intent.md b/spec/v1/10-project-intent.md index 6108a82..29b0cf1 100644 --- a/spec/v1/10-project-intent.md +++ b/spec/v1/10-project-intent.md @@ -69,7 +69,7 @@ schemaVersion: 1.0.0 The `apiVersion` deliberately does not reuse `deployment.jorisjonkers.dev`, which three mutually incompatible documents already share: the defect -[0003](../../docs/adr/model/0003-three-layer-meta-model.md) exists to fix. Each layer +[0003](../../docs/adr/model/0003-three-model-pipeline.md) exists to fix. Each layer gets its own namespace. `kind` names the authored document (one project holding many Applications) while chapter 40's `IntentFragment` is the envelope that publishes it. `schemaVersion` is the **data model's own semver**, not the diff --git a/spec/v1/14-platform-intent.md b/spec/v1/14-platform-intent.md index 60d0844..b3ef90f 100644 --- a/spec/v1/14-platform-intent.md +++ b/spec/v1/14-platform-intent.md @@ -38,7 +38,7 @@ Three things a reader might expect here live elsewhere, each for a reason. | not here | where | why | |---|---|---| -| the foundation components, Vault, VSO, Traefik, the metrics stack, Gatus | project files the platform owns, as ordinary Applications ([The foundation is declared](#the-foundation-is-declared)) | an Application is an Application; a second way to declare one is the duplicate vocabulary [0003](../../docs/adr/model/0003-three-layer-meta-model.md) exists to end | +| the foundation components, Vault, VSO, Traefik, the metrics stack, Gatus | project files the platform owns, as ordinary Applications ([The foundation is declared](#the-foundation-is-declared)) | an Application is an Application; a second way to declare one is the duplicate vocabulary [0003](../../docs/adr/model/0003-three-model-pipeline.md) exists to end | | the node contract, site, arch, allocatable, gpus, disks per node | its own pinned input, authored once where nix reads it ([0056](../../docs/adr/model/0056-node-facts-single-source.md), [chapter 60](60-setup.md#node-facts)) | folding it in would make nix read a deployment-model document or duplicate the facts | | anything executable | the images lock, as a purpose-built image per engine ([Engines](#engines)) | [0012](../../docs/adr/model/0012-assets-not-code.md) applies to the platform's own files | diff --git a/spec/v1/diagrams/00-overview-meta-model.drawio.svg b/spec/v1/diagrams/00-overview-meta-model.drawio.svg deleted file mode 100644 index 46119f8..0000000 --- a/spec/v1/diagrams/00-overview-meta-model.drawio.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -layer 1 - hand-authoredRequirements and facts, never mechanisms. The contention test decides which of the two authored documents a value lives in,and env files and assets travel inside the same Intent Fragment as the project file they sit beside.Project Intent, in each owning repositoryPlatform Intent, in the platform'splatform/<project>.project.ymlapplications, processes, placement,writable paths, durability, probes,exposure, observability, secretsenv/<process>/*.envone set per Processassetsdeclarative,never executableplatform.intent.ymlsubstrate facts, bootstrap set,tiers, durability policy, hardening,cadences, engines, providersplatform project filesthe foundation,declared as ApplicationsIntent Fragmentsevery authored document, published by digestparticipants.ymlevery expected publisher, maxAge 7dcompositionunion + estate-wide invariantsmerges nothing, runs on any publishComposedIntent + CompositionLocklayer 2 - Resolved Deploymentevery platform assignment, the path plan,the release gate's inputs, the capacity exception,a function of the pinned inputs alonenode contractby digestClusterState snapshotclusterStateDigestimages lockdigests, uid, gid: never tagsresolved.ymlpublished back per Applicationlayer 3 - Deliverable Setsix registered adapters, run once centrally,one attributed adapter per filedelivery - DEFINED SEPARATELYdocs/adr/deferred/must honour Release Unit atomicity,Durability Class gates, pinned inputs onlythe clusteran owner reads their own assignmentsbeside the file they authoredLayer 1 contains no mechanisms.Layer 3 contains no decisions.A dashed border means defined separatelyfrom the model. \ No newline at end of file diff --git a/spec/v1/diagrams/00-overview-three-model-pipeline.drawio.svg b/spec/v1/diagrams/00-overview-three-model-pipeline.drawio.svg new file mode 100644 index 0000000..faa37b5 --- /dev/null +++ b/spec/v1/diagrams/00-overview-three-model-pipeline.drawio.svg @@ -0,0 +1,4 @@ + + + +layer 1 - hand-authoredRequirements and facts, never mechanisms. The contention test decides which of the two authored documents a value lives in,and env files and assets travel inside the same Intent Fragment as the project file they sit beside.Project Intent, in each owning repositoryPlatform Intent, in the platform'splatform/<project>.project.ymlapplications, processes, placement,writable paths, durability, probes,exposure, observability, secretsenv/<process>/*.envone set per Processassetsdeclarative,never executableplatform.intent.ymlsubstrate facts, bootstrap set,tiers, durability policy, hardening,cadences, engines, providersplatform project filesthe foundation,declared as ApplicationsIntent Fragmentsevery authored document, published by digestparticipants.ymlevery expected publisher, maxAge 7dcompositionunion + estate-wide invariantsmerges nothing, runs on any publishComposedIntent + CompositionLocklayer 2 - Resolved Deploymentevery platform assignment, the path plan,the release gate's inputs, the capacity exception,a function of the pinned inputs alonenode contractby digestClusterState snapshotclusterStateDigestimages lockdigests, uid, gid: never tagsresolved.ymlpublished back per Applicationlayer 3 - Deliverable Setsix registered adapters, run once centrally,one attributed adapter per filedelivery - DEFINED SEPARATELYdocs/adr/deferred/must honour Release Unit atomicity,Durability Class gates, pinned inputs onlythe clusteran owner reads their own assignmentsbeside the file they authoredLayer 1 contains no mechanisms.Layer 3 contains no decisions.A dashed border means defined separatelyfrom the model. \ No newline at end of file diff --git a/spec/v1/examples/minimal/README.md b/spec/v1/examples/minimal/README.md index 638fc06..7e05694 100644 --- a/spec/v1/examples/minimal/README.md +++ b/spec/v1/examples/minimal/README.md @@ -56,7 +56,7 @@ Every absence below is a decision, not an omission: ## Reading it beside the diagrams The pipeline this example walks through is -[chapter 00's meta-model](../../diagrams/00-overview-meta-model.drawio.svg); the +[chapter 00's three-model pipeline](../../diagrams/00-overview-three-model-pipeline.drawio.svg); the shape of the authored file is [chapter 10's model](../../diagrams/10-project-intent-model.drawio.svg); and the route from a declaration to an object is