From 154841994ae1cf6326c70c9f13e3d8a5615f12dc Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Mon, 14 Sep 2026 14:23:48 +0200 Subject: [PATCH] refactor: rename the authored hierarchy to Project, Application and Process --- CLAUDE.md | 8 +- CONTEXT.md | 88 ++- README.md | 16 +- docs/adr/README.md | 47 +- ...5-one-hexagon-domain-mirrors-the-layers.md | 13 +- ...7-adapters-build-objects-one-serializer.md | 7 +- .../0068-failures-are-a-diagnostic-list.md | 7 +- ...08-tested-equals-deployed-requires-push.md | 7 +- .../deferred/0041-push-delivery-boundary.md | 17 +- .../0042-apply-before-prune-inventory.md | 11 +- .../0043-delete-authority-durability-gate.md | 19 +- docs/adr/deferred/0044-reconcile-cronjob.md | 7 +- .../deferred/0047-namespace-per-deployer.md | 25 +- docs/adr/deferred/0048-class-b-pinning.md | 9 +- .../deferred/0049-aggregator-owned-tests.md | 39 +- .../deferred/0050-exercises-and-deploys.md | 41 +- docs/adr/deferred/0051-vcluster-substrate.md | 13 +- .../0058-delivery-machinery-observability.md | 17 +- docs/adr/deferred/README.md | 2 +- .../deferred/examples/aggregator-deploy.yml | 4 +- .../adr/deferred/examples/aggregator-gate.yml | 2 +- docs/adr/deferred/examples/aggregator.yml | 6 +- docs/adr/deferred/examples/deployer-rbac.yaml | 4 +- .../deferred/examples/reapply-cronjob.yaml | 2 +- .../model/0001-estate-scale-and-ownership.md | 23 +- .../adr/model/0002-kubernetes-as-substrate.md | 23 +- docs/adr/model/0003-three-layer-meta-model.md | 9 +- .../0004-contention-decides-authority.md | 29 +- docs/adr/model/0005-derivation-is-total.md | 23 +- docs/adr/model/0006-pinned-inputs.md | 13 +- .../model/0007-schema-version-separable.md | 9 +- docs/adr/model/0009-vault-read-is-per-path.md | 9 +- .../model/0010-flat-application-identity.md | 95 +++ docs/adr/model/0010-flat-service-identity.md | 90 --- ...11-configuration-env-files-per-process.md} | 55 +- docs/adr/model/0012-assets-not-code.md | 29 +- .../0013-blueprint-packs-pinned-checkout.md | 15 +- docs/adr/model/0014-probes-are-siblings.md | 47 +- .../model/0015-durability-class-per-volume.md | 25 +- docs/adr/model/0016-pod-hardening.md | 35 +- .../adr/model/0017-placement-by-capability.md | 35 +- docs/adr/model/0018-exposure-by-audience.md | 61 +- .../0019-registered-unmanaged-surfaces.md | 15 +- .../0020-dependency-edges-carry-surface.md | 35 +- ...21-observability-scrape-and-alert-class.md | 47 +- .../0022-grants-live-on-the-application.md | 98 +++ .../model/0022-grants-live-on-the-service.md | 93 --- docs/adr/model/0023-grant-unit-is-the-path.md | 19 +- docs/adr/model/0024-identity-per-process.md | 105 +++ docs/adr/model/0024-identity-per-workload.md | 100 --- .../model/0025-access-tiers-derive-policy.md | 23 +- docs/adr/model/0026-delivery-env-file-self.md | 21 +- .../model/0027-secret-reference-join-key.md | 15 +- docs/adr/model/0028-secrets-at-rest-gate.md | 13 +- ...-resolved-deployment-versioned-artifact.md | 15 +- .../model/0030-runtime-mechanics-derived.md | 47 +- .../0031-derived-overrides-with-reason.md | 33 +- docs/adr/model/0032-reconcile-unit-derived.md | 45 +- .../model/0033-assignments-published-back.md | 17 +- .../model/0034-cluster-state-pinned-input.md | 9 +- .../model/0035-network-policy-default-deny.md | 25 +- docs/adr/model/0036-cni-selection.md | 13 +- .../model/0037-composition-oci-fragments.md | 31 +- .../model/0038-participants-list-staleness.md | 45 +- .../model/0039-artifact-schema-versioning.md | 11 +- docs/adr/model/0040-renovate-ordering-gate.md | 7 +- .../model/0052-registered-adapters-are-v1.md | 7 +- docs/adr/model/0053-adapter-port-contract.md | 9 +- docs/adr/model/0054-adapter-attribution.md | 13 +- docs/adr/model/0055-bidirectional-ledgers.md | 15 +- .../model/0056-node-facts-single-source.md | 13 +- docs/adr/model/0057-datastore-and-restore.md | 11 +- docs/adr/model/0059-v1-scope-stopping-rule.md | 7 +- docs/adr/model/0060-release-unit.md | 25 +- .../0061-placement-is-hard-dimensions.md | 27 +- .../0062-application-is-the-release-unit.md | 93 +++ .../model/0062-service-is-the-release-unit.md | 88 --- .../model/0063-intent-authored-per-domain.md | 99 --- .../model/0063-intent-authored-per-project.md | 104 +++ ...> 0064-sidecars-are-process-vocabulary.md} | 47 +- .../model/0070-path-authority-is-layer-2.md | 17 +- .../0071-release-gate-inputs-are-layer-2.md | 29 +- docs/adr/model/0072-the-label-set-is-fixed.md | 35 +- .../0073-vault-policy-is-a-deliverable.md | 25 +- .../0074-networking-adapter-emits-policy.md | 21 +- ...in-v1.md => 0075-no-process-rbac-in-v1.md} | 31 +- .../model/0076-middleware-has-one-producer.md | 17 +- .../model/0077-durability-derives-a-backup.md | 23 +- ...d => 0078-engine-is-process-vocabulary.md} | 27 +- ...alert-class-derives-from-a-rule-catalog.md | 51 +- .../0080-database-catalog-is-derived-data.md | 15 +- .../0081-volume-size-is-a-hard-dimension.md | 25 +- .../0082-images-lock-carries-uid-and-gid.md | 21 +- ...83-privileged-port-needs-the-capability.md | 23 +- .../0085-a-grant-is-a-union-on-engine.md | 9 +- ...086-kv-read-covers-its-metadata-sibling.md | 9 +- ...87-token-mounted-only-for-delivery-self.md | 15 +- .../0088-startup-probe-targets-liveness.md | 17 +- .../0089-replicas-derived-no-minavailable.md | 29 +- ...0090-edges-resolve-against-the-register.md | 15 +- ...ntity-placeholders-not-framework-wiring.md | 39 +- .../model/0092-writable-paths-are-declared.md | 23 +- .../model/0093-route-precedence-is-derived.md | 9 +- ...4-asset-change-restarts-unconditionally.md | 17 +- ...-intent-is-the-second-authored-document.md | 21 +- .../model/0096-the-foundation-is-declared.md | 35 +- ...097-authored-values-name-model-concepts.md | 9 +- docs/adr/model/0098-one-publication-path.md | 25 +- .../model/0099-bootstrap-set-is-recorded.md | 9 +- .../model/0116-project-application-process.md | 80 ++ docs/architecture.md | 8 +- ...etamodels-are-hand-written-per-document.md | 7 +- ...es-the-authored-yaml-into-the-metamodel.md | 7 +- emf/docs/architecture.md | 10 +- scripts/diagrams/class-diagram.py | 22 +- spec/v1/00-overview.md | 70 +- ...service-intent.md => 10-project-intent.md} | 694 +++++++++--------- spec/v1/14-platform-intent.md | 54 +- spec/v1/16-dependencies.md | 256 +++---- spec/v1/20-resolved-deployment.md | 358 ++++----- spec/v1/30-deliverables.md | 80 +- spec/v1/40-composition.md | 264 +++---- spec/v1/50-lifecycle.md | 60 +- spec/v1/60-setup.md | 136 ++-- .../00-overview-meta-model.drawio.svg | 2 +- .../10-project-intent-model.drawio.svg | 4 + .../10-service-intent-model.drawio.svg | 4 - .../16-derivation-map-assignments.drawio.svg | 2 +- .../16-derivation-map-deliverables.drawio.svg | 2 +- spec/v1/diagrams/16-edge-derives.drawio.svg | 2 +- spec/v1/diagrams/16-exposure-trace.drawio.svg | 2 +- .../20-resolved-deployment-io.drawio.svg | 2 +- .../v1/diagrams/30-render-pipeline.drawio.svg | 2 +- .../v1/diagrams/40-composition-run.drawio.svg | 2 +- .../diagrams/50-change-end-to-end.drawio.svg | 2 +- .../50-release-unit-switchover.drawio.svg | 2 +- .../v1/diagrams/60-bootstrap-order.drawio.svg | 2 +- spec/v1/diagrams/README.md | 6 +- spec/v1/examples/RENDER-GAPS.md | 32 +- .../{auth.domain.yml => auth.project.yml} | 172 ++--- spec/v1/examples/auth/env/auth-api.base.env | 34 +- spec/v1/examples/auth/rendered/README.md | 120 +-- .../{data.domain.yml => data.project.yml} | 148 ++-- .../data/env/platform-postgres.base.env | 16 +- spec/v1/examples/data/rendered/README.md | 168 ++--- .../knowledge/env/knowledge-api.base.env | 16 +- .../env/knowledge-ingest-worker.base.env | 18 +- ...ledge.domain.yml => knowledge.project.yml} | 138 ++-- spec/v1/examples/knowledge/rendered/README.md | 108 +-- spec/v1/examples/minimal/README.md | 26 +- .../examples/minimal/env/notes-api/base.env | 4 +- .../{notes.domain.yml => notes.project.yml} | 30 +- .../README.md | 28 +- .../intent-a/knowledge.yml | 16 +- .../intent-b/agents.yml | 26 + .../negative/duplicate-process-name/README.md | 48 ++ .../intent/agents.yml | 28 +- .../duplicate-service-id/intent-b/agents.yml | 26 - .../duplicate-workload-name/README.md | 48 -- spec/v1/examples/platform/README.md | 8 +- spec/v1/examples/platform/platform.intent.yml | 16 +- spec/v1/examples/refusals/README.md | 12 +- ...in.yml => alert-class-unknown.project.yml} | 16 +- ...=> alert-class-without-signal.project.yml} | 12 +- ... => cutover-recreate-over-rwo.project.yml} | 16 +- ...l => cutover-rolling-over-rwo.project.yml} | 18 +- spec/v1/examples/workflows/compose.yml | 42 +- ...gment.yml => project-publish-fragment.yml} | 28 +- test/diagram-model-consistency.test.ts | 26 +- test/simplification-contract.test.ts | 168 ++--- 170 files changed, 3725 insertions(+), 3146 deletions(-) create mode 100644 docs/adr/model/0010-flat-application-identity.md delete mode 100644 docs/adr/model/0010-flat-service-identity.md rename docs/adr/model/{0011-configuration-env-files-per-workload.md => 0011-configuration-env-files-per-process.md} (68%) create mode 100644 docs/adr/model/0022-grants-live-on-the-application.md delete mode 100644 docs/adr/model/0022-grants-live-on-the-service.md create mode 100644 docs/adr/model/0024-identity-per-process.md delete mode 100644 docs/adr/model/0024-identity-per-workload.md create mode 100644 docs/adr/model/0062-application-is-the-release-unit.md delete mode 100644 docs/adr/model/0062-service-is-the-release-unit.md delete mode 100644 docs/adr/model/0063-intent-authored-per-domain.md create mode 100644 docs/adr/model/0063-intent-authored-per-project.md rename docs/adr/model/{0064-sidecars-are-workload-vocabulary.md => 0064-sidecars-are-process-vocabulary.md} (59%) rename docs/adr/model/{0075-no-workload-rbac-in-v1.md => 0075-no-process-rbac-in-v1.md} (70%) rename docs/adr/model/{0078-engine-is-workload-vocabulary.md => 0078-engine-is-process-vocabulary.md} (73%) create mode 100644 docs/adr/model/0116-project-application-process.md rename spec/v1/{10-service-intent.md => 10-project-intent.md} (77%) create mode 100644 spec/v1/diagrams/10-project-intent-model.drawio.svg delete mode 100644 spec/v1/diagrams/10-service-intent-model.drawio.svg rename spec/v1/examples/auth/{auth.domain.yml => auth.project.yml} (77%) rename spec/v1/examples/data/{data.domain.yml => data.project.yml} (78%) rename spec/v1/examples/knowledge/{knowledge.domain.yml => knowledge.project.yml} (72%) rename spec/v1/examples/minimal/{notes.domain.yml => notes.project.yml} (72%) rename spec/v1/examples/negative/{duplicate-service-id => duplicate-application-id}/README.md (63%) rename spec/v1/examples/negative/{duplicate-service-id => duplicate-application-id}/intent-a/knowledge.yml (71%) create mode 100644 spec/v1/examples/negative/duplicate-application-id/intent-b/agents.yml create mode 100644 spec/v1/examples/negative/duplicate-process-name/README.md rename spec/v1/examples/negative/{duplicate-workload-name => duplicate-process-name}/intent/agents.yml (62%) delete mode 100644 spec/v1/examples/negative/duplicate-service-id/intent-b/agents.yml delete mode 100644 spec/v1/examples/negative/duplicate-workload-name/README.md rename spec/v1/examples/refusals/{alert-class-unknown.domain.yml => alert-class-unknown.project.yml} (79%) rename spec/v1/examples/refusals/{alert-class-without-signal.domain.yml => alert-class-without-signal.project.yml} (88%) rename spec/v1/examples/refusals/{cutover-recreate-over-rwo.domain.yml => cutover-recreate-over-rwo.project.yml} (80%) rename spec/v1/examples/refusals/{cutover-rolling-over-rwo.domain.yml => cutover-rolling-over-rwo.project.yml} (78%) rename spec/v1/examples/workflows/{service-publish-fragment.yml => project-publish-fragment.yml} (86%) diff --git a/CLAUDE.md b/CLAUDE.md index c3e9fc2..c3a1243 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,12 +35,12 @@ what gets fixed. Before changing anything under `docs/adr/` or `spec/v1/`: - Read [`docs/adr/README.md`](docs/adr/README.md) for the register, the citation - rule and the domain table, and `docs/adr/model/0003`–`0006` for the model's + rule and the project table, and `docs/adr/model/0003`–`0006` for the model's premises. -- `docs/adr/` carries **one directory per domain**: `model/` (v1 model, +- `docs/adr/` carries **one directory per project**: `model/` (v1 model, pointers into `spec/v1`), `architecture/` (the compiler's own structure, pointers into `docs/architecture.md`), `deferred/` (not linted). Numbers run - in one estate-wide sequence, so never reuse a number from another domain. + in one estate-wide sequence, so never reuse a number from another project. - Run `npm run lint:adrs`. It enforces frontmatter schema, register integrity, qualified citations (a bare `ADR-` token outside a link fails), normative anchors resolving against real headings in `spec/v1`, and content shape. @@ -64,7 +64,7 @@ from the day it lands and deleted at its sunset: - **The root stays TypeScript.** Every pom, module, check, ledger and decision of the Java side lives under `emf/`. The root references it only from CI - (the `emf` job and the `emf` ADR lint step), the `emf` domain in + (the `emf` job and the `emf` ADR lint step), the `emf` project in `scripts/lint-adrs.ts` and its test, and [`docs/architecture.md#the-parity-contract`](docs/architecture.md#the-parity-contract). - **Never generate one implementation from the other.** Both are tested, diff --git a/CONTEXT.md b/CONTEXT.md index 5d28a5f..a512233 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,13 +18,13 @@ 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.** -**Service Intent**: layer 1. What a Service's repository authors by hand: -requirements, never mechanisms. Two kinds of file, a domain file and one env file -set per Workload ([chapter 10](spec/v1/10-service-intent.md)). +**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 +set per Process ([chapter 10](spec/v1/10-project-intent.md)). **Platform Intent**: layer 1, the second authored document. What the estate offers, authored by the platform: substrate facts, the bootstrap set, tiers, -durability and observability policy, engines, providers. Same rule as Service +durability and observability policy, engines, providers. Same rule as Project Intent, and the contention test decides which of the two a value lives in ([chapter 14](spec/v1/14-platform-intent.md), [0095](docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)). @@ -39,33 +39,33 @@ artifact ([chapter 20](spec/v1/20-resolved-deployment.md)). ## Layer 1: what a human authors -**Domain**: the authored document, and the unit of publication. One domain -file holds one domain's `owner` and every Service in it. A domain never spans -repositories ([0063](docs/adr/model/0063-intent-authored-per-domain.md)). +**Project**: the authored document, and the unit of publication. One project +file holds one project's `owner` and every Application in it. A project never spans +repositories ([0063](docs/adr/model/0063-intent-authored-per-project.md)). -**Service**: the release unit, and the thing an owner reasons about. Holds one -or more Workloads, its exposure and its shared grants -([0062](docs/adr/model/0062-service-is-the-release-unit.md)). +**Application**: the release unit, and the thing an owner reasons about. Holds one +or more Processes, its exposure and its shared grants +([0062](docs/adr/model/0062-application-is-the-release-unit.md)). -**Workload**: one process to run, with its own image, lifecycle, env file set, +**Process**: one program to run, with its own image, lifecycle, env file set, probes, volumes, placement and hardening. A port is a property of a process, so -`provides` hangs off the Workload; a hostname is a property of the product, so -exposure hangs off the Service. +`provides` hangs off the Process; a hostname is a property of the product, so +exposure hangs off the Application. -**Surface**: a named port a Workload provides. What a dependency edge and a +**Surface**: a named port a Process provides. What a dependency edge and a route both name. -**Sidecar**: a second container in a Workload's pod, and Workload vocabulary -rather than a Service of its own -([0064](docs/adr/model/0064-sidecars-are-workload-vocabulary.md)). +**Sidecar**: a second container in a Process's pod, and Process vocabulary +rather than an Application of its own +([0064](docs/adr/model/0064-sidecars-are-process-vocabulary.md)). -**Dependency edge**: a declared need for another Service's Surface, carrying +**Dependency edge**: a declared need for another Application's Surface, carrying whether it is required ([chapter 16](spec/v1/16-dependencies.md)). -**Exposure**: a hostname a Service answers on, its audience and its content +**Exposure**: a hostname an Application answers on, its audience and its content policy. Holds one or more Routes. -**Route**: a path within an Exposure, naming the Workload and Surface that +**Route**: a path within an Exposure, naming the Process and Surface that serve it. **Audience**: who may reach an Exposure. The declared word from which the edge @@ -73,9 +73,9 @@ mechanism is derived ([0018](docs/adr/model/0018-exposure-by-audience.md)). **Probe**: a declared readiness or liveness check. -**Asset**: a file mounted into a Workload. Declarative, never executable +**Asset**: a file mounted into a Process. Declarative, never executable ([0012](docs/adr/model/0012-assets-not-code.md)). Its object name is -content-hashed and a change restarts the Workload, unconditionally +content-hashed and a change restarts the Process, unconditionally ([0094](docs/adr/model/0094-asset-change-restarts-unconditionally.md)). **Volume**: a claim mounted at a path, carrying its Durability Class. @@ -86,22 +86,22 @@ content-hashed and a change restarts the Workload, unconditionally backup objects that make the class mean something ([0077](docs/adr/model/0077-durability-derives-a-backup.md)). -**Engine**: what a Workload's process *is*, where the platform must treat it +**Engine**: what a Process *is*, where the platform must treat it specially: `postgres`, `rabbitmq`, `valkey`, `files`. Not `runtime`, which says how a process is instrumented -([0078](docs/adr/model/0078-engine-is-workload-vocabulary.md)). +([0078](docs/adr/model/0078-engine-is-process-vocabulary.md)). **Durability policy**: the platform's terms for one Durability Class: the backup window, the retention count, and the off-cluster destination. Carried by the Platform Intent, never authored per volume. -**Placement**: the hard dimensions a Workload requires of a node: memory, cpu, +**Placement**: the hard dimensions a Process requires of a node: memory, cpu, architecture, site, capabilities, and optionally disk and GPU. Eligibility, not bin-packing ([0061](docs/adr/model/0061-placement-is-hard-dimensions.md)). -**Capability**: a named node property a Workload may require. +**Capability**: a named node property a Process may require. -**Hardening Class**: the pod security posture a Workload takes, with named +**Hardening Class**: the pod security posture a Process takes, with named exceptions each carrying a reason ([0016](docs/adr/model/0016-pod-hardening.md)). @@ -109,12 +109,12 @@ exceptions each carrying a reason and runtime environment variables are derived. Writing one of its keys by hand is a build error. -**Alert Class**: how an alert on this Service should be delivered +**Alert Class**: how an alert on this Application should be delivered ([0021](docs/adr/model/0021-observability-scrape-and-alert-class.md)). **Grant**: declared access to a Secret Store path, its keys, its access tier -and its delivery mode. Lives on the Service, or on a Workload when it is -specific to one ([0022](docs/adr/model/0022-grants-live-on-the-service.md), +and its delivery mode. Lives on the Application, or on a Process when it is +specific to one ([0022](docs/adr/model/0022-grants-live-on-the-application.md), [0023](docs/adr/model/0023-grant-unit-is-the-path.md)). **Placeholder**: an env-file entry naming what should be substituted rather @@ -128,7 +128,7 @@ generic override mechanism. ## Composition: many repositories, one estate -**Intent Fragment**: one domain file published as an OCI artifact, by digest +**Intent Fragment**: one project file published as an OCI artifact, by digest ([0037](docs/adr/model/0037-composition-oci-fragments.md), [chapter 40](spec/v1/40-composition.md#fragments)). @@ -141,7 +141,7 @@ estate-wide invariants. It merges nothing, and runs on every publish. fragment it resolved. An output rather than an input, because an artifact cannot contain its own digest. -**Participants**: the expected set of publishing domains, with a staleness +**Participants**: the expected set of publishing projects, with a staleness bound. A missed publish is a deletion, so the bound is what makes silence visible ([0038](docs/adr/model/0038-participants-list-staleness.md)). @@ -152,7 +152,7 @@ family and separate from the toolkit's package version ## Layer 2: what the platform decides **Pinned input set**: the closed set layer 2 derives from: every Intent Fragment -(the domain files and the Platform document), the node contract, the locks, and +(the project files and the Platform document), the node contract, the locks, and the ClusterState snapshot, each carried by digest. Nothing at render time reads live cluster state ([0006](docs/adr/model/0006-pinned-inputs.md), [0034](docs/adr/model/0034-cluster-state-pinned-input.md)). @@ -162,11 +162,11 @@ reads them and named by the Platform document by digest ([0056](docs/adr/model/0056-node-facts-single-source.md)). **Tier**: where the edge terminates: four facts, `audiences`, `listener`, -`certificates`, `forwardAuth`, plus the Traefik Service that is its proxy. A +`certificates`, `forwardAuth`, plus the Traefik Application that is its proxy. A route's audience is the only way it reaches a tier ([chapter 14](spec/v1/14-platform-intent.md#tiers)). -**Provider**: something the estate runs and does not deploy, that a Service may +**Provider**: something the estate runs and does not deploy, that an Application may depend on: an address and surfaces, in the Platform document. A fact, not a hole ([chapter 14](spec/v1/14-platform-intent.md#providers)). @@ -174,7 +174,7 @@ depend on: an address and surfaces, in the Platform document. A fact, not a hole k3s, the Flux source, Vault's unseal, the CRDs. Recorded, enumerated, never declared ([0099](docs/adr/model/0099-bootstrap-set-is-recorded.md)). -**The foundation**: Vault, VSO, Traefik, Prometheus, Gatus: Services in domain +**The foundation**: Vault, VSO, Traefik, Prometheus, Gatus: Applications in project files the platform owns, declared like any tenant ([0096](docs/adr/model/0096-the-foundation-is-declared.md)). Not packs, not charts. @@ -189,7 +189,7 @@ must be unique across the estate or draws on a shared finite resource ([0004](docs/adr/model/0004-contention-decides-authority.md)). **ResolvedService**: the projection of the Resolved Deployment belonging to one -Service, obtained by filtering and published back to its repository +Application, obtained by filtering and published back to its repository ([0033](docs/adr/model/0033-assignments-published-back.md)). **Reconcile Unit**: the ordering unit, derived from the dependency graph and @@ -257,5 +257,17 @@ vocabulary keeps the two apart so the act of deciding is still *resolution* or from this model ([`docs/adr/deferred/`](docs/adr/deferred/README.md)). A render is not a deploy. +**Service.** Retired as a model word: the level is **Application** +([0116](docs/adr/model/0116-project-application-process.md)). Say *Service* +only for the Kubernetes `Service` object a Deliverable contains. + +**Workload.** Retired as a model word: the level is **Process**. A rendered +`workload.yaml` keeps the name, because layer 3 spells what the target calls +its objects. + +**Domain.** Retired as a model word: the level is **Project**. *Domain* is left +for a DNS name, an ADR decision domain, and the core ring of the compiler's +hexagon. + **Config.** Avoid. Env files carry *configuration*; the platform's facts and -policies are *Platform Intent*; a Service's authored document is *Service Intent*. +policies are *Platform Intent*; an Application's authored document is *Project Intent*. diff --git a/README.md b/README.md index 20f556c..76e370e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # deploy-kit -Everything needed to get a service deployed: the **model** a service author +Everything needed to get an application deployed: the **model** an application author writes, the **decision record** that justifies every rule in it, and, as it lands, the **compiler** that turns that model into deployable artifacts. @@ -16,14 +16,14 @@ lands, the **compiler** that turns that model into deployable artifacts. | --- | --- | | [`CONTEXT.md`](CONTEXT.md) | The vocabulary. One term, one meaning; also the naming authority for code. | | [`docs/architecture.md`](docs/architecture.md) | Normative for code structure, the way `spec/v1` is normative for the model. | -| [`docs/adr/`](docs/adr/README.md) | The decision surface, one directory per domain. Machine-checked. | +| [`docs/adr/`](docs/adr/README.md) | The decision surface, one directory per project. Machine-checked. | | [`docs/adr/model/`](docs/adr/model/) | The v1 model: 8 premises carrying falsifiable claims, 43 decisions resting on them. | | [`docs/adr/architecture/`](docs/adr/architecture/README.md) | The compiler's own structure. Pointers resolve against `docs/architecture.md`, not `spec/v1`. | | [`docs/adr/deferred/`](docs/adr/deferred/README.md) | Delivery and co-testing decisions, defined separately from the model. Direction work, not v1. | -| [`spec/v1/`](spec/v1/00-overview.md) | The normative specification. Chapters 00–60, including the two authored documents: Service Intent (10) and Platform Intent (14). | +| [`spec/v1/`](spec/v1/00-overview.md) | The normative specification. Chapters 00–60, including the two authored documents: Project Intent (10) and Platform Intent (14). | | [`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. | +| [`spec/v1/examples/minimal/`](spec/v1/examples/minimal/README.md) | The smallest complete Application: one project, one Application, one Process, 26 authored lines reaching 10 objects. | +| [`spec/v1/examples/`](spec/v1/examples) | Worked examples: real Applications 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)). | ## The shape of the model @@ -31,7 +31,7 @@ lands, the **compiler** that turns that model into deployable artifacts. Three layers, and the middle one is a contract ([0003](docs/adr/model/0003-three-layer-meta-model.md)): -1. **Service Intent**: hand-authored, requirements only. What a service owner +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, which paths answer readiness. 2. **Resolved Deployment**: derived. Every platform decision, assigned from @@ -42,10 +42,10 @@ Three layers, and the middle one is a contract Two rules do most of the work. **Contention decides authority** ([0004](docs/adr/model/0004-contention-decides-authority.md)): a value is platform-assigned exactly when it must be unique estate-wide or draws on a -shared finite resource; everything else belongs to the Service. And +shared finite resource; everything else belongs to the Application. And **derivation is total** ([0005](docs/adr/model/0005-derivation-is-total.md)): every hand-tuned value in the live estate must be reachable from something only the -Service could have declared. +Application could have declared. ## Reading it diff --git a/docs/adr/README.md b/docs/adr/README.md index 674fbe8..80f6d4c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,9 +16,9 @@ names: that is the follow-up spec rewrite, tracked in `review/REBUILD-MANIFEST.md`; `scripts/lint-adrs.ts` stays non-blocking until it lands. -The set was amended on 2026-09-07 for placement and domain-authored intent: +The set was amended on 2026-09-07 for placement and project-authored intent: `size` became a set of hard `placement` dimensions matched against node -allocatable, Intent is authored one file per domain, and a Service is itself the +allocatable, Intent is authored one file per project, and an Application is itself the unit of atomic release. 0017 and 0060 are superseded by that amendment and kept for the record; 0004, 0010, 0016, 0024, 0037 and 0056 were amended in place. @@ -26,7 +26,7 @@ The set was amended on 2026-09-10 for the v1 simplification: the generic override hatch is deleted and `replicas: {count, reason}` is the sole local capacity exception (0031, 0089, 0097); `zeroDowntime` is replaced by required `cutover: rolling | recreate` with `E_CUTOVER_UNHONOURABLE` (0030); and -observability becomes one optional `observability` block on the Service, whole +observability becomes one optional `observability` block on the Application, whole or absent, with the ServiceMonitor derived from it and rule expressions, severity and receivers left to the stack that reads the projection (0021, 0079). The hardening exception surface is deleted with the override hatch it @@ -43,6 +43,12 @@ implementation's own decisions live in [`emf/docs/adr/`](../../emf/docs/adr/README.md), numbered from the same sequence, and are deleted with it. The model is unchanged. +The set was amended on 2026-09-14 for vocabulary: the authored levels Domain, +Service and Workload are now Project, Application and Process, and Service +Intent is Project Intent (0116). Every ADR that used the old words carries an +amendment note and was renamed where its file name used them; no decision +changed. + Tier-0 **premises** carry one falsifiable claim each; tier-1 **decisions** name the premises they stand on in `rests-on`. A `claim: open` means decided in direction, untested: its owner and settling test are in the file. @@ -73,14 +79,14 @@ check both registers. | # | title | claim | normative | |---|---|---|---| -| [0001](model/0001-estate-scale-and-ownership.md) | The estate is one maintainer, one cluster, about thirty Services | open | 00-overview.md#the-estate | +| [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 | | [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 | | [0007](model/0007-schema-version-separable.md) | The data model's version is not the package's version | open | 40-composition.md#versioning | -| [0009](model/0009-vault-read-is-per-path.md) | A Vault KV-v2 read grant covers the whole path | open | 10-service-intent.md#secrets | +| [0009](model/0009-vault-read-is-per-path.md) | A Vault KV-v2 read grant covers the whole path | open | 10-project-intent.md#secrets | Premise 0008 (tested-equals-deployed) moved to [deferred/](deferred/0008-tested-equals-deployed-requires-push.md) with the @@ -91,16 +97,17 @@ delivery work it underpins. ### Identity and authorship | # | title | claim | |---|---|---| -| [0010](model/0010-flat-service-identity.md) | One flat Service Id | settled | -| [0011](model/0011-configuration-env-files-per-workload.md) | Configuration is per-Workload env files with named placeholders | settled | -| [0091](model/0091-identity-placeholders-not-framework-wiring.md) | The model derives no framework wiring; it exposes the Workload's own identity as placeholders | settled | +| [0010](model/0010-flat-application-identity.md) | One flat Application Id | settled | +| [0011](model/0011-configuration-env-files-per-process.md) | Configuration is per-Process env files with named placeholders | settled | +| [0091](model/0091-identity-placeholders-not-framework-wiring.md) | The model derives no framework wiring; it exposes the Process's own identity as placeholders | settled | | [0012](model/0012-assets-not-code.md) | File-shaped configuration is an Asset; code is not configuration | settled | -| [0094](model/0094-asset-change-restarts-unconditionally.md) | An Asset change is content-hashed and restarts the Workload; there is no onChange field | settled | +| [0094](model/0094-asset-change-restarts-unconditionally.md) | An Asset change is content-hashed and restarts the Process; there is no onChange field | settled | | [0013](model/0013-blueprint-packs-pinned-checkout.md) | Blueprint packs arrive by pinned checkout, not a registry | superseded by [0096](model/0096-the-foundation-is-declared.md) | -| [0063](model/0063-intent-authored-per-domain.md) | Intent is authored one file per domain | settled | -| [0064](model/0064-sidecars-are-workload-vocabulary.md) | A Workload may hold sidecars, and a sidecar carries what a container carries | settled | +| [0063](model/0063-intent-authored-per-project.md) | Intent is authored one file per project | settled | +| [0116](model/0116-project-application-process.md) | The authored hierarchy is Project, Application and Process, named for a reader who does not work with deployments | settled | +| [0064](model/0064-sidecars-are-process-vocabulary.md) | A Process may hold sidecars, and a sidecar carries what a container carries | settled | -### Workload-declared runtime intent +### Process-declared runtime intent | # | title | claim | |---|---|---| | [0014](model/0014-probes-are-siblings.md) | Probes are sibling declarations, each carrying its own path | settled | @@ -108,10 +115,10 @@ delivery work it underpins. | [0015](model/0015-durability-class-per-volume.md) | Every volume declares a Durability Class | settled | | [0077](model/0077-durability-derives-a-backup.md) | A Durability Class derives a backup, from platform terms and a method keyed by engine | settled | | [0081](model/0081-volume-size-is-a-hard-dimension.md) | A volume declares its size; the platform decides whether it fits | settled | -| [0078](model/0078-engine-is-workload-vocabulary.md) | `engine` is layer-1 vocabulary: what the process is, not how it is instrumented | settled | +| [0078](model/0078-engine-is-process-vocabulary.md) | `engine` is layer-1 vocabulary: what the process is, not how it is instrumented | settled | | [0016](model/0016-pod-hardening.md) | Pod hardening is platform policy, and has no exception surface | open | | [0082](model/0082-images-lock-carries-uid-and-gid.md) | The images lock resolves each image's uid and gid, and fsGroup derives from the gid | settled | -| [0092](model/0092-writable-paths-are-declared.md) | A Workload declares the paths it writes, and that is not a hardening exception | settled | +| [0092](model/0092-writable-paths-are-declared.md) | A Process declares the paths it writes, and that is not a hardening exception | settled | | [0083](model/0083-privileged-port-needs-the-capability.md) | A privileged port under non-root is refused | settled | | [0017](model/0017-placement-by-capability.md) | Placement is declared as capabilities, never labels | superseded by [0061](model/0061-placement-is-hard-dimensions.md) | | [0061](model/0061-placement-is-hard-dimensions.md) | Placement is a set of hard dimensions matched against allocatable | open | @@ -132,9 +139,9 @@ delivery work it underpins. ### Secrets | # | title | claim | |---|---|---| -| [0022](model/0022-grants-live-on-the-service.md) | Secret grants live on the Service document, at two levels | settled | +| [0022](model/0022-grants-live-on-the-application.md) | Secret grants live on the Application document, at two levels | settled | | [0023](model/0023-grant-unit-is-the-path.md) | The grant unit is the path; the subtree splits per reader set | open | -| [0024](model/0024-identity-per-workload.md) | Workloads hold their own identity | settled | +| [0024](model/0024-identity-per-process.md) | Processes hold their own identity | settled | | [0087](model/0087-token-mounted-only-for-delivery-self.md) | A ServiceAccount token is mounted only where the pod itself authenticates | settled | | [0025](model/0025-access-tiers-derive-policy.md) | Access tiers derive the Vault policy | settled, KV-only per [0085](model/0085-a-grant-is-a-union-on-engine.md) | | [0026](model/0026-delivery-env-file-self.md) | Secret delivery is env, file, or self | settled | @@ -176,14 +183,14 @@ delivery work it underpins. | [0072](model/0072-the-label-set-is-fixed.md) | The object label set is fixed, and two of its labels are immutable | settled | | [0073](model/0073-vault-policy-is-a-deliverable.md) | The derived Vault policy and auth role are Deliverables of their own adapter | settled | | [0074](model/0074-networking-adapter-emits-policy.md) | A networking adapter owns every NetworkPolicy in the estate | settled | -| [0075](model/0075-no-workload-rbac-in-v1.md) | v1 renders no workload RBAC, and refuses any Deliverable that grants it | settled | +| [0075](model/0075-no-process-rbac-in-v1.md) | v1 renders no process RBAC, and refuses any Deliverable that grants it | settled | | [0076](model/0076-middleware-has-one-producer.md) | Every Middleware has one producer, and the tier names its forward-auth endpoint | settled | ### Platform Intent | # | title | claim | |---|---|---| | [0095](model/0095-platform-intent-is-the-second-authored-document.md) | Platform Intent is the second authored document, published as an Intent Fragment | settled | -| [0096](model/0096-the-foundation-is-declared.md) | The foundation is declared as Services; nothing hand-written enters the render | settled | +| [0096](model/0096-the-foundation-is-declared.md) | The foundation is declared as Applications; nothing hand-written enters the render | settled | | [0097](model/0097-authored-values-name-model-concepts.md) | An authored value names a model concept; the target's spelling is a derivation | settled | | [0098](model/0098-one-publication-path.md) | A repository publishes its Intent Fragment and nothing else; every derivation runs once, centrally | settled | | [0099](model/0099-bootstrap-set-is-recorded.md) | The bootstrap set is a recorded, enumerated table | open | @@ -198,8 +205,8 @@ delivery work it underpins. | # | title | claim | |---|---|---| | [0059](model/0059-v1-scope-stopping-rule.md) | v1 has a scope and a stopping rule | open | -| [0060](model/0060-release-unit.md) | Several Services switch as one Release Unit | superseded by [0062](model/0062-service-is-the-release-unit.md) | -| [0062](model/0062-service-is-the-release-unit.md) | A Service is the unit of atomic release | settled | +| [0060](model/0060-release-unit.md) | Several Applications switch as one Release Unit | superseded by [0062](model/0062-application-is-the-release-unit.md) | +| [0062](model/0062-application-is-the-release-unit.md) | An Application is the unit of atomic release | settled | ## Architecture 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 361e0af..de69a29 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 @@ -9,6 +9,11 @@ rests-on: ["0003"] # One hexagon, two use-cases, and a domain whose folders are the three layers +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-14.** Scoped to the TypeScript tree under `src/`. For the > length of the model-driven engineering course a second, Java implementation > lives under `emf/`, bound to this one only by the parity contract @@ -40,14 +45,14 @@ folders after the layers is not decoration. It means a reviewer reading `src/domain/resolved/` are looking at the same thing, and a rule that lands in the wrong ring is visible as a wrong import rather than as a wrong idea. -The two runtimes in [chapter 30](../../../spec/v1/30-deliverables.md#adapters) (five fragment producers in the Service repository, eleven central adapters over +The two runtimes in [chapter 30](../../../spec/v1/30-deliverables.md#adapters) (five fragment producers in the Project repository, eleven central adapters over the union) are the reason to be careful here. They differ in *what documents -they receive*, not in what a Service means. One core with two use-cases keeps +they receive*, not in what an Application means. One core with two use-cases keeps the invariants in one place; two applications would put them in a third package that both import and neither owns. Type names come from [`CONTEXT.md`](../../../CONTEXT.md) unchanged, which is -what makes the mapping legible in both directions: `Service`, `Workload`, +what makes the mapping legible in both directions: `Project`, `Application`, `Process`, `IntentFragment`, `ResolvedDeployment`, `Deliverable`, `Adapter`, `ReconcileUnit`. @@ -83,5 +88,5 @@ release. three places: the glossary, the chapters, and the tree, paid by whoever renames, and the reason the glossary landed first. - The publish-time use-case and the central one share a core, so a change to - Service semantics cannot apply to one and not the other, which is the point, + Application semantics cannot apply to one and not the other, which is the point, and it also means neither can be optimised independently. 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 d341352..fcb1882 100644 --- a/docs/adr/architecture/0067-adapters-build-objects-one-serializer.md +++ b/docs/adr/architecture/0067-adapters-build-objects-one-serializer.md @@ -9,13 +9,18 @@ rests-on: ["0003"] # Adapters build typed objects; one serializer owns the bytes +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every Deliverable this estate emits is expressible as a typed object built from fields the model can name, and turning objects into bytes needs no per-adapter choice. False if: an adapter must emit a construct the object model cannot express (a comment carrying meaning, a document whose key order is semantic) in which case that adapter is deciding something at serialization time. -Settled by: the double render over the three worked domains producing a +Settled by: the double render over the three worked projects producing a byte-identical tree with every adapter going through the one serializer. ## Why diff --git a/docs/adr/architecture/0068-failures-are-a-diagnostic-list.md b/docs/adr/architecture/0068-failures-are-a-diagnostic-list.md index 2dd8e30..4474add 100644 --- a/docs/adr/architecture/0068-failures-are-a-diagnostic-list.md +++ b/docs/adr/architecture/0068-failures-are-a-diagnostic-list.md @@ -9,12 +9,17 @@ rests-on: ["0005"] # A failure is a coded diagnostic in a list, not a thrown error +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every rule the compiler enforces can be evaluated independently of the others, so one run can report all violations rather than the first. False if: a later rule cannot run until an earlier one passes (a derivation that needs a valid input set to be meaningful) in which case the run has phases and each phase -reports its own complete list. Settled by: a fixture domain with one violation +reports its own complete list. Settled by: a fixture project with one violation per invariant group producing one diagnostic per violation in a single run. ## Why diff --git a/docs/adr/deferred/0008-tested-equals-deployed-requires-push.md b/docs/adr/deferred/0008-tested-equals-deployed-requires-push.md index 9948d6d..ec3a975 100644 --- a/docs/adr/deferred/0008-tested-equals-deployed-requires-push.md +++ b/docs/adr/deferred/0008-tested-equals-deployed-requires-push.md @@ -10,6 +10,11 @@ decided-in: JorisJonkers-dev/workspace#45 # Tested-equals-deployed cannot be had from pull alone +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Under Flux pull delivery, the combination of artefacts that passed the system @@ -60,7 +65,7 @@ owner; 12 of the 32 system-tagged classes exercise `auth-api` *with its consumer |---|---|---| | Rival premise: the gap is wiring, not architecture: test the lock in CI, let the passing merge gate the Flux source update | An afternoon for the caller (workspace#45) plus a required merge in front of the Flux source, spending composition's no-merge property on every change | Not rejected: this is the live falsification path; the premise stays `open` until the experiment runs, and group-G scope hangs on its outcome | | Rival premise: the 147 tests are rotten, so tested-equals-deployed is moot either way | Nothing to build; the suite is abandoned | Falsified by [workspace ADR-0010](https://github.com/JorisJonkers-dev/workspace/blob/main/docs/decisions/ADR-0010-system-tests-disposition.md): all 32 classes compile and lint on every PR, two tasks and two reusable workflows exist, only the caller is missing | -| Rival premise: tested-equals-deployed is not worth having: deploy on trust and monitor | Cross-service regressions in the auth relationship set surface in production instead of CI | 12 of ~25 test classes exist precisely to catch provider-with-consumer breakage; discarding the estate's only cross-service evidence contradicts its own investment | +| Rival premise: tested-equals-deployed is not worth having: deploy on trust and monitor | Cross-application regressions in the auth relationship set surface in production instead of CI | 12 of ~25 test classes exist precisely to catch provider-with-consumer breakage; discarding the estate's only cross-application evidence contradicts its own investment | ## Reversibility diff --git a/docs/adr/deferred/0041-push-delivery-boundary.md b/docs/adr/deferred/0041-push-delivery-boundary.md index 12b8113..86c9b32 100644 --- a/docs/adr/deferred/0041-push-delivery-boundary.md +++ b/docs/adr/deferred/0041-push-delivery-boundary.md @@ -10,9 +10,14 @@ rests-on: ["0008"] # Class A is pushed by Aggregators; class B stays with Flux +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -Every object derived from Service Intent reaches its intended state under +Every object derived from Project Intent reaches its intended state under `kubectl apply --server-side` alone; the eighteen `HelmRelease`s in the pack-delivered foundation do not. False if: any class-A kind the registered adapters render needs a Flux controller to take effect, or a pack-delivered @@ -22,9 +27,9 @@ full estate render into a vcluster with **no** Flux controllers, diff. ## Why Chapter 30 measured coverage and found three classes: **364 objects derived -from Service Intent**, 41 delivered by blueprint packs, 45 authored. Delivery +from Project Intent**, 41 delivered by blueprint packs, 45 authored. Delivery splits at exactly the same line, which was not planned. An **Aggregator** (a -repository owning a relationship between Services) applies class A with +repository owning a relationship between Applications) applies class A with `kubectl apply --server-side` on merge, after that relationship's system tests pass against an ephemeral vcluster; Flux keeps the foundation. The boundary is enforced: both appliers claiming one object is a build error. @@ -56,7 +61,7 @@ falsifies that premise, scope is cut per | option | cost if taken | why rejected | |---|---|---| -| Push class B too: render the 18 charts to plain manifests and apply them with `kubectl` | Ownership of 18 upstream charts' values, hooks and CRD upgrade paths, re-paid on every chart bump | `vault` and `vault-secrets-operator` are in the set; one bad render breaks secret delivery for every Service in the estate | +| Push class B too: render the 18 charts to plain manifests and apply them with `kubectl` | Ownership of 18 upstream charts' values, hooks and CRD upgrade paths, re-paid on every chart bump | `vault` and `vault-secrets-operator` are in the set; one bad render breaks secret delivery for every Application in the estate | | Draw the boundary elsewhere: per namespace, or a hand-kept list of pushed objects | A list to maintain across 364 objects and every new adapter | The split is already measured and each object's class is derivable from its adapter; a hand-kept list drifts, and then both appliers claim one object | | Leave everything with Flux and gate the source update instead | An afternoon for the missing test caller, plus a required merge in front of the Flux source | Not rejected: this is [0008](0008-tested-equals-deployed-requires-push.md)'s live falsification path, and it is why this claim is `open` | @@ -78,9 +83,9 @@ nothing pruning class A meanwhile. anyone debugging at 03:00. - A merge is now required to deploy, against composition's *"no repository needs a merge before a change takes effect"* (but per-relationship, not - estate-wide), paid by service owners, one PR per relationship change. + estate-wide), paid by application owners, one PR per relationship change. - CI cost grows: a change anywhere invalidates every aggregator's pin, so - roughly six suites run per service change, each provisioning a vcluster, a + roughly six suites run per application change, each provisioning a vcluster, a shape the estate measured as *"561 minutes of real compute billed 2,845, four fifths of the spend was rounding"*, paid by joris in Actions minutes, against [0051](0051-vcluster-substrate.md)'s thresholds. diff --git a/docs/adr/deferred/0042-apply-before-prune-inventory.md b/docs/adr/deferred/0042-apply-before-prune-inventory.md index 630f149..14b03fe 100644 --- a/docs/adr/deferred/0042-apply-before-prune-inventory.md +++ b/docs/adr/deferred/0042-apply-before-prune-inventory.md @@ -9,15 +9,20 @@ rests-on: ["0008"] # Apply first, prune last, from an inventory of rendered kinds +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every kind the registered adapters render tolerates a bounded window in which the outgoing and incoming object both exist, so deferring the delete pass until after a successful apply costs a transient duplicate, nothing more. False if: a rendered kind is exclusive: two live objects contend for one resource, so the -overlap breaks the service rather than doubling it (two `IngressRoute`s on one +overlap breaks the application rather than doubling it (two `IngressRoute`s on one hostname). Settled by: in a k3d vcluster ([0051](0051-vcluster-substrate.md)), -rename a Service owning a public hostname and a PVC, apply the new slice before +rename an Application owning a public hostname and a PVC, apply the new slice before pruning the old, and curl the hostname each second across the overlap; any non-2xx falsifies it. @@ -26,7 +31,7 @@ non-2xx falsifies it. The order was inverted. `spec/v1/examples/workflows/aggregator-deploy.yml` runs *"Prune what left the render"* (line 82) before *"Server-side apply in DAG order"* (line 98), and `spec/v1/50-lifecycle.md:105-108` repeats it. Flux -applies then prunes. A rename, or a Service reassigned between Aggregators, +applies then prunes. A rename, or an Application reassigned between Aggregators, presents here as delete-then-create, and the create can fail: a field-ownership conflict *"is reported and fails this step"* by design, and the job times out at 20 minutes. Old object gone, new one never written (B4: diff --git a/docs/adr/deferred/0043-delete-authority-durability-gate.md b/docs/adr/deferred/0043-delete-authority-durability-gate.md index ccc638f..37b3d9d 100644 --- a/docs/adr/deferred/0043-delete-authority-durability-gate.md +++ b/docs/adr/deferred/0043-delete-authority-durability-gate.md @@ -9,6 +9,11 @@ rests-on: ["0008"] # Deletion is gated by Durability Class +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every kind the registered adapters render is reconstructible by re-applying the @@ -25,7 +30,7 @@ settles it. The dependence on the Durability Class ## Why The authority is currently wider than the undo. `spec/v1/examples/rendered/deployer-rbac.yaml:29-30` -puts `persistentvolumeclaims` in the same rule as `services`, `serviceaccounts` +puts `persistentvolumeclaims` in the same rule as `applications`, `serviceaccounts` and `configmaps` and grants `[get, list, create, patch, update, delete]` with no `resourceNames`, under a comment claiming the Role is *"Exactly the kinds the … adapters attribute to auth-api. Nothing else"*, true as a list of kinds, silent @@ -49,8 +54,8 @@ is what puts a `delete` verb in a token at all; a pull model never held one. The worked example ties it together: `knowledge-vault-clone` is declared `durability: irreplaceable` on `local-path` (`spec/v1/examples/knowledge.service.yml:110-112`), -and it is a personal knowledge vault. A rename, a claim moving between Workloads, -or a Service reassigned between Aggregators each present to the prune pass as a +and it is a personal knowledge vault. A rename, a claim moving between Processes, +or an Application reassigned between Aggregators each present to the prune pass as a delete-then-create. So: the deployer Role holds `delete` only on kinds reversible from git. A claim backing non-`reconstructible` data that leaves the render is `E_ORPHANED_CLAIM` plus a required state-move-plan (the artefact @@ -64,8 +69,8 @@ other half of this defect, decided in [0042](0042-apply-before-prune-inventory.m |---|---|---| | Keep the verb, add a real interactive confirmation | a prompt in the apply path, plus a held-open CI job per deploy | both callers are unattended by design (a CronJob and an Actions runner), so the prompt is either auto-answered or the reconcile loop stops; the flag that exists is already this idea, auto-answered | | Keep the verb, refuse in the renderer when `durability != reconstructible` | one check and one negative fixture, days of work | the token still holds `delete` on every claim, so a bug in the check, or any break-glass `kubectl` using the deployer credential, still reaches the vault; RBAC is the only refusal that survives the tool being wrong | -| Set `persistentVolumeReclaimPolicy: Retain` and keep pruning claims | a directory per deleted claim left on the node with no sweep; re-created claims bind fresh empty volumes | protects the bytes and loses the binding: the vault survives as an unreferenced directory nobody is told about, and the Workload comes back empty and healthy | -| Prune nothing, ever | withdrawn hostnames stay served; `IngressRoute` and `VaultStaticSecret` outlive their Service | abandons "the render is the truth" for every reversible kind to protect the one irreversible one, and leaves the coverage assertion permanently red | +| Set `persistentVolumeReclaimPolicy: Retain` and keep pruning claims | a directory per deleted claim left on the node with no sweep; re-created claims bind fresh empty volumes | protects the bytes and loses the binding: the vault survives as an unreferenced directory nobody is told about, and the Process comes back empty and healthy | +| Prune nothing, ever | withdrawn hostnames stay served; `IngressRoute` and `VaultStaticSecret` outlive their Application | abandons "the render is the truth" for every reversible kind to protect the one irreversible one, and leaves the coverage assertion permanently red | ## Reversibility @@ -81,7 +86,7 @@ restores exactly the state this record exists to end. - A stateful claim leaving the render stalls the deploy on `E_ORPHANED_CLAIM` until a state-move-plan exists; renaming one stops being a one-line edit, - paid by service owners. + paid by application owners. - Dead claims are never reclaimed automatically; the one `k3s-control-plane` host accumulates them until swept by hand, paid by the platform owner. - The RBAC adapter splits `delete` by kind and re-renders every deployer Role; @@ -89,5 +94,5 @@ restores exactly the state this record exists to end. lifecycle chapter, paid by adapter maintainers and aggregator repositories. - [0015](../model/0015-durability-class-per-volume.md) becomes load-bearing: a volume mis-declared `reconstructible` is deletable, paid by whoever declares it. -- The gate protects claims, not the bytes inside them; a Workload that corrupts +- The gate protects claims, not the bytes inside them; a Process that corrupts its own volume is untouched, paid by owners who read the gate as a backup. diff --git a/docs/adr/deferred/0044-reconcile-cronjob.md b/docs/adr/deferred/0044-reconcile-cronjob.md index f9f4c96..deecaf1 100644 --- a/docs/adr/deferred/0044-reconcile-cronjob.md +++ b/docs/adr/deferred/0044-reconcile-cronjob.md @@ -9,6 +9,11 @@ rests-on: ["0008"] # Reconciliation is an in-cluster CronJob per Aggregator +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on A CronJob object in the cluster fires within one period of the schedule it @@ -85,7 +90,7 @@ hand-edit the CronJob has quietly overwritten surfaces at once. ## Consequences -- Drift on a class-A slice is corrected within an hour instead of at the next merge, paid by the Services in that slice, which gain a bounded staleness where they had none. +- Drift on a class-A slice is corrected within an hour instead of at the next merge, paid by the Applications in that slice, which gain a bounded staleness where they had none. - Roughly six CronJobs must be rendered, pinned and kept current, each with an image, a ServiceAccount and a lock read path, paid by the `rbac` and cron adapters and whoever maintains them. - `startingDeadlineSeconds` makes a long outage produce refused jobs and `MissedSchedule` events rather than silence, paid by whoever answers the alert [0058](0058-delivery-machinery-observability.md) requires. - The reapply and the merge apply now need distinct field managers and a Lease (machinery neither needed while they shared a name), paid by the deploy adapter. diff --git a/docs/adr/deferred/0047-namespace-per-deployer.md b/docs/adr/deferred/0047-namespace-per-deployer.md index 2fccbef..a0674cc 100644 --- a/docs/adr/deferred/0047-namespace-per-deployer.md +++ b/docs/adr/deferred/0047-namespace-per-deployer.md @@ -9,13 +9,18 @@ rests-on: ["0002"] # A namespace has exactly one deployer +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Once every namespace has one deploying Aggregator, its deployer Role reaches -nothing outside the Services that Aggregator deploys (the Secret reach `create` +nothing outside the Applications that Aggregator deploys (the Secret reach `create` confers included). False if: `kubectl auth can-i` returns `yes` for an Aggregator ServiceAccount against a namespace outside its own `deploys` set, or composition -still admits two Services with different deployers into one namespace. Settled +still admits two Applications with different deployers into one namespace. Settled by: `kubectl auth can-i --as=system:serviceaccount:deploy-system:deployer- -n ` over every Aggregator × foreign namespace × `create`/`patch`/`delete` × `deployments` and `vaultstaticsecrets`, every cell @@ -24,7 +29,7 @@ by: `kubectl auth can-i --as=system:serviceaccount:deploy-system:deployer- ## Why The first property [0002](../model/0002-kubernetes-as-substrate.md) keeps Kubernetes for is -"a workflow that tries to apply a Service it does not own receives a 403 rather +"a workflow that tries to apply an Application it does not own receives a 403 rather than producing a bad deploy" (`spec/v1/50-lifecycle.md:200`). The artefact meant to deliver it does not: `spec/v1/examples/rendered/deployer-rbac.yaml:28-51` grants `get, list, create, patch, update, delete` across eight apiGroups with no @@ -35,9 +40,9 @@ chapter 20's alias catalogue (`spec/v1/20-resolved-deployment.md:131`), and `home-portal` sits in this Aggregator's `exercises`, not its `deploys` (`spec/v1/50-lifecycle.md:81-82`), a different Aggregator applies it into a namespace where this one holds `delete` on every Deployment. `aliases.namespace` -exists precisely to let Services share a namespace, so that is the sanctioned +exists precisely to let Applications share a namespace, so that is the sanctioned case, and `E_MULTIPLE_DEPLOYERS` cannot see it: the invariant -(`spec/v1/40-composition.md:193`) asks whether a Service has two deployers, not +(`spec/v1/40-composition.md:193`) asks whether an Application has two deployers, not whether a namespace does. One correction to the review's wording: the file renders a Role in `auth-system` only: two namespaces declared, one Role emitted, itself a defect. @@ -70,17 +75,17 @@ inside one deployer's own namespaces. This is the other half of making Undo cost today: hours for the mechanism: delete one composition invariant, let the `rbac` adapter widen the Role again. The namespace moves it forces cost more: -a namespace change is a DNS change (`..svc`), so consumers, +a namespace change is a DNS change (`..svc`), so consumers, dependency edges and NetworkPolicies follow, and the blast radius is exactly the -Services sharing a namespace across deployers, today `app-system`. Becomes -irreversible once: those Services have moved and are addressed at the new names. +Applications sharing a namespace across deployers, today `app-system`. Becomes +irreversible once: those Applications have moved and are addressed at the new names. ## Consequences - `aliases.namespace` narrows to a rename inside one deployer's own namespaces, - and Services sharing one across deployers must move, paid by joris, once. + and Applications sharing one across deployers must move, paid by joris, once. - The namespace-wide Secret read is contained, not removed: a compromised deploy - workflow still gets every Secret its own Services hold, and withholding + workflow still gets every Secret its own Applications hold, and withholding `secrets` verbs stops being describable as a control, paid by the estate, as accepted residual risk. - Namespace count rises to at least one per deployer, multiplying per-namespace diff --git a/docs/adr/deferred/0048-class-b-pinning.md b/docs/adr/deferred/0048-class-b-pinning.md index 9f9cb80..39ac7d9 100644 --- a/docs/adr/deferred/0048-class-b-pinning.md +++ b/docs/adr/deferred/0048-class-b-pinning.md @@ -9,6 +9,11 @@ rests-on: ["0002"] # The foundation is pinned like everything else +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every chart and image in the pack-delivered foundation publishes immutable, @@ -52,12 +57,12 @@ image pull took the MCP fully down (503)"*. Under `"*"` that window opens unattended, at an hour's notice, with no record of what version opened it. The gap runs past charts. `platform-postgres.service.yml:34-35` pins the database -eight Services depend on to `pgvector/pgvector:pg17`, a mutable tag commented as +eight Applications depend on to `pgvector/pgvector:pg17`, a mutable tag commented as bypassing the lock, while `30-deliverables.md:224` makes a floating tag `E_FLOATING_IMAGE`. Both production-touching workflows invoke bare `npx deploy-config-schema`, registry-resolved at invocation, while the one workflow that never touches production installs an exact version -(`service-publish-fragment.yml:50-57`); the in-cluster runner holding the deploy +(`project-publish-fragment.yml:50-57`); the in-cluster runner holding the deploy ServiceAccount runs `actions/checkout@v4` and `oras-project/setup-oras@v1`, floating; and this repository's CI (`.github/workflows/ci.yml:51`) pipes a third-party script from `main` into `bash`. So class B takes class A's diff --git a/docs/adr/deferred/0049-aggregator-owned-tests.md b/docs/adr/deferred/0049-aggregator-owned-tests.md index 688b10c..a24f02d 100644 --- a/docs/adr/deferred/0049-aggregator-owned-tests.md +++ b/docs/adr/deferred/0049-aggregator-owned-tests.md @@ -9,17 +9,22 @@ rests-on: ["0001"] # System tests are owned by Aggregators +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -A co-test set cannot be derived from a Service's own declarations: most existing +A co-test set cannot be derived from an Application's own declarations: most existing system-test classes exercise a provider together with its consumers, and a -Service declares outbound edges only, it never names who calls it. False if: -every class in the existing suite touches only Services inside the outbound -dependency closure of one Service it names, making the set derivable and a new +Application declares outbound edges only, it never names who calls it. False if: +every class in the existing suite touches only Applications inside the outbound +dependency closure of one Application it names, making the set derivable and a new document unnecessary. Settled by: for each class under -`tests/stack-integration-tests/src/test`, grep the Service hostnames it +`tests/stack-integration-tests/src/test`, grep the Application hostnames it references to obtain the set it touches, then compare that set against the -outbound `dependsOn` closure of every Service it names; the claim fails if every +outbound `dependsOn` closure of every Application it names; the claim fails if every set is contained in one such closure. ## Why @@ -36,17 +41,17 @@ appears only as a provider of `amqp` `RabbitMqOidc` exercises. A provider's edge set points at what it calls, never at what calls it. -That is the whole argument, and it needs no borrowed authority: a Service knows +That is the whole argument, and it needs no borrowed authority: an Application knows what it calls and never knows what calls it, the same property chapter 40 -already names, *"No Service knows its own consumers, so none of these are +already names, *"No Application knows its own consumers, so none of these are locally computable"* (`../../spec/v1/40-composition.md:22`). Put the co-test list on the provider and `auth-api` becomes a register of its own consumers: -every new routed service in the estate arrives as a pull request against the one +every new routed application in the estate arrives as a pull request against the one repository with no reason to know it exists, and a forgotten edit silently removes a gate rather than failing anything. Putting the declaration on the test project inverts it: the project that understands a relationship is the project that declares it. An Aggregator is its own repository carrying an `exercises` -list; a Service declares no co-test list, and its pre-deploy gate runs every +list; an Application declares no co-test list, and its pre-deploy gate runs every project naming it, against the pinned image set. Nothing is rewritten to get there. [workspace ADR-0010](https://github.com/JorisJonkers-dev/workspace/blob/main/docs/decisions/ADR-0010-system-tests-disposition.md) @@ -59,16 +64,16 @@ also independent of [0008](0008-tested-equals-deployed-requires-push.md): if tha premise falls and delivery stays a Flux pull, the question *which repository runs the auth federation suite before `auth-api`'s image moves* still has to have exactly one answer. Relationship ownership is what survives the premise. The -`deploys` half of the same file (one applier per Service) is +`deploys` half of the same file (one applier per Application) is [0050](0050-exercises-and-deploys.md). ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| `coTestWith` list on the provider Service | An edit to `auth-api` for every new consumer; six exist in the auth set today and each newly routed service adds another | The provider never knows its consumers, so the list rots by omission, and an omission silently deletes a gate instead of failing a check | -| Derive the co-test set from outbound dependency edges | Nothing to author: composition computes it from edges that already exist | Twelve of ~25 classes name Services outside any single provider's outbound closure; the derivation drops precisely the tests that exercise a relationship | -| Keep one monolithic suite and gate every Service on all of it | No decomposition work; one workflow caller and the gap closes | Every Service then waits on all 32 compiled classes, most unrelated to it, and one flake in the media smoke tests blocks an auth deploy | +| `coTestWith` list on the provider Application | An edit to `auth-api` for every new consumer; six exist in the auth set today and each newly routed application adds another | The provider never knows its consumers, so the list rots by omission, and an omission silently deletes a gate instead of failing a check | +| Derive the co-test set from outbound dependency edges | Nothing to author: composition computes it from edges that already exist | Twelve of ~25 classes name Applications outside any single provider's outbound closure; the derivation drops precisely the tests that exercise a relationship | +| Keep one monolithic suite and gate every Application on all of it | No decomposition work; one workflow caller and the gap closes | Every Application then waits on all 32 compiled classes, most unrelated to it, and one flake in the media smoke tests blocks an auth deploy | ## Reversibility @@ -78,14 +83,14 @@ resolves its targets (`E_UNRESOLVED_TEST_TARGET`). Nothing has moved yet (the suite is still one project with zero runs), so undoing means deleting a key and a check: an hour, no cluster change, no test rewritten. Becomes irreversible once: the 147 tests are split across the aggregator repositories and each holds -its own history and CI, and Service gates resolve their gate set through the +its own history and CI, and Application gates resolve their gate set through the participants list rather than a fixed workflow name; recombining is then a repository merge per suite plus a rewrite of every gate. ## Consequences -- A Service's gate must discover which Aggregators name it, which is only possible through composition, making [0037](../model/0037-composition-oci-fragments.md) and [0038](../model/0038-participants-list-staleness.md) prerequisites for having any gate at all, paid by joris, at build time. +- An Application's gate must discover which Aggregators name it, which is only possible through composition, making [0037](../model/0037-composition-oci-fragments.md) and [0038](../model/0038-participants-list-staleness.md) prerequisites for having any gate at all, paid by joris, at build time. - `tests/stack-integration-tests` is decomposed; the auth federation suite is the natural first project and carries twelve of the classes, paid by joris, once. - A relationship with no Aggregator has no gate, and nothing announces that absence; the participants list is the only place it can be made visible, paid by whoever later debugs the untested relationship. -- Every domain needs a default Aggregator or a Service named by none cannot deploy at all: `jellyfin`, `sonarr`, `radarr`, `prowlarr`, `bazarr`, `qbittorrent` and `immich` have zero test classes between them and get one behind smoke tests only, paid by joris. +- Every project needs a default Aggregator or an Application named by none cannot deploy at all: `jellyfin`, `sonarr`, `radarr`, `prowlarr`, `bazarr`, `qbittorrent` and `immich` have zero test classes between them and get one behind smoke tests only, paid by joris. - Adding a consumer to a relationship becomes a pull request in the aggregator repository rather than in the consumer's own, paid by the consumer's author, on every new edge. diff --git a/docs/adr/deferred/0050-exercises-and-deploys.md b/docs/adr/deferred/0050-exercises-and-deploys.md index 4467a5d..836b3ee 100644 --- a/docs/adr/deferred/0050-exercises-and-deploys.md +++ b/docs/adr/deferred/0050-exercises-and-deploys.md @@ -9,14 +9,19 @@ rests-on: ["0001"] # Exercises are many-to-many; deploys are exactly-one +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Overlap on `exercises` never yields two writers of one object, because `deploys` -is exactly-one per Service and, once a namespace has exactly one deployer +is exactly-one per Application and, once a namespace has exactly one deployer ([0047](0047-namespace-per-deployer.md)), the API server and not only the composition check refuses the second applier. False if: `kubectl auth can-i --as=system:serviceaccount:deploy-system:deployer-auth-federation -n app-system -patch deployments` answers `yes` for a Service that Aggregator does not deploy, +patch deployments` answers `yes` for an Application that Aggregator does not deploy, the enforcement is then convention, not control. Settled by: that `can-i` against a k3d vcluster carrying two Aggregators' rendered deployer RBAC, once per (aggregator, namespace) pair; and `yq -r '.spec.deploys[]' */aggregator.yml | @@ -31,26 +36,26 @@ with `auth-ui` *and* by the OIDC federation set. Of roughly 25 classes in `DownstreamOidcAuthorization`, `ForwardAuthChain`, `ForwardAuthRedirect`, `GrafanaOidc`, `N8nOidc`, `OAuth2Flow`, `RabbitMqOidc`, `Registration`, `SessionSecurity`, `Totp`, `TotpReLogin`) and they span both relationships. A -Service's pre-deploy gate runs every Aggregator naming it, so overlap is the +Application's pre-deploy gate runs every Aggregator naming it, so overlap is the point. `deploys` is **exactly-one** estate-wide: many gates, one applier, which is what makes overlap safe and why the two lists cannot collapse into one. Exactly-one is enforced twice: `E_NO_DEPLOYER` and `E_MULTIPLE_DEPLOYERS` at composition (`../../spec/v1/40-composition.md:192-193`), and independently by the API server, because `deploys` generates the Aggregator's Role: *"a workflow that -tries to apply a Service it does not own receives a 403 rather than producing a +tries to apply an Application it does not own receives a 403 rather than producing a bad deploy"* (`../../spec/v1/50-lifecycle.md:200-201`). The second does not hold as written: the generated Role is namespace-scoped with `create/patch/delete` on -every kind and no `resourceNames`, `aliases.namespace` lets two Services share a +every kind and no `resourceNames`, `aliases.namespace` lets two Applications share a namespace, and `deployer-rbac.yaml`'s header declares `deploys: [auth-api, auth-ui] -> namespaces auth-system, app-system` while rendering a single Role. A -per-Service CI check cannot see a shared namespace, so the API-server half is +per-Application CI check cannot see a shared namespace, so the API-server half is real only through [0047](0047-namespace-per-deployer.md). -Every domain needs a default Aggregator, or a Service in nobody's `deploys` list +Every project needs a default Aggregator, or an Application in nobody's `deploys` list cannot deploy at all, and the default cannot come from testing: `jellyfin`, `sonarr`, `radarr`, `prowlarr`, `bazarr`, `qbittorrent` and `immich` have **zero** -test classes between them, so `media-stack` deploys seven services behind smoke +test classes between them, so `media-stack` deploys seven applications behind smoke tests only. The symmetric gap has no backstop: a relationship with *no* Aggregator has no gate, visible only in [0038](../model/0038-participants-list-staleness.md)'s list. @@ -58,17 +63,17 @@ has no gate, visible only in [0038](../model/0038-participants-list-staleness.md | option | cost if taken | why rejected | |---|---|---| -| One list: the suite that gates a Service also applies it | Zero schema; one key instead of two. Costs the twelve auth classes their second home: `auth-api` may belong to the pairing suite or the federation suite, not both | Forbidding overlap removes gates to protect apply authority; the gates are why the Aggregator exists | +| One list: the suite that gates an Application also applies it | Zero schema; one key instead of two. Costs the twelve auth classes their second home: `auth-api` may belong to the pairing suite or the federation suite, not both | Forbidding overlap removes gates to protect apply authority; the gates are why the Aggregator exists | | Two lists, but let `deploys` overlap and arbitrate at apply time | No composition invariants to build. Every shared object becomes a server-side-apply conflict, hourly, atop the two appliers [0046](0046-distinct-field-managers.md) already serialises | Prune is a label query on `deploy.jorisjonkers.dev/deployer=`; with two deployers the second prunes the first's objects, so overlap is not a conflict but a deletion | -| Derive the deployer from `exercises`: most classes wins | Nothing to author; one resolver function | Makes deploy authority a function of test coverage: the seven media services, at zero classes, resolve to no deployer and become undeployable | -| Put both lists on the Service (`gatedBy`, `deployedBy`) | Deploy authority reads locally, in one file | A provider never knows its consumers; `auth-api` would be edited whenever any new consumer appeared, the shape [0049](0049-aggregator-owned-tests.md) rejects | +| Derive the deployer from `exercises`: most classes wins | Nothing to author; one resolver function | Makes deploy authority a function of test coverage: the seven media applications, at zero classes, resolve to no deployer and become undeployable | +| Put both lists on the Application (`gatedBy`, `deployedBy`) | Deploy authority reads locally, in one file | A provider never knows its consumers; `auth-api` would be edited whenever any new consumer appeared, the shape [0049](0049-aggregator-owned-tests.md) rejects | ## Reversibility Undo cost today: two keys in the Aggregator schema, the two invariant rows at `../../spec/v1/40-composition.md:192-193`, and the `rbac` adapter's Role generation. Collapsing to one list is a schema change plus one re-render per Aggregator, a -day, bounded to composition; no Workload manifest changes. Becomes irreversible +day, bounded to composition; no Process manifest changes. Becomes irreversible once prune-by-label-query runs in production: applied objects carry `deploy.jorisjonkers.dev/deployer` as the prune key, so a second deployer means one Aggregator's prune deletes the other's, and relabelling a live slice is an @@ -76,17 +81,17 @@ outage, not an edit. ## Consequences -- A Service can be gated by every relationship it participates in at no +- An Application can be gated by every relationship it participates in at no coordination cost, paid by Aggregator authors, maintaining two alike lists. -- A new Service is undeployable until exactly one Aggregator claims it, failing - as `E_NO_DEPLOYER` at composition, paid by the Service owner, at creation. -- Every domain needs a default Aggregator whether a relationship justifies one or - not, paid by the platform owner, once per untested domain. +- A new Application is undeployable until exactly one Aggregator claims it, failing + as `E_NO_DEPLOYER` at composition, paid by the Application owner, at creation. +- Every project needs a default Aggregator whether a relationship justifies one or + not, paid by the platform owner, once per untested project. - A relationship with no Aggregator has no gate and nothing errors, paid by whoever reviews the participants list, the one place it shows. - Every `aliases.namespace` value becomes a deploy-authority decision, paid by whoever reviews aliases, under [0047](0047-namespace-per-deployer.md). -- Reassigning a Service between Aggregators relabels live objects mid-flight; it +- Reassigning an Application between Aggregators relabels live objects mid-flight; it is safe only because the incoming Aggregator applies before the outgoing one prunes ([0042](0042-apply-before-prune-inventory.md)), and unsafe in any ordering that prunes first, paid by the platform owner. diff --git a/docs/adr/deferred/0051-vcluster-substrate.md b/docs/adr/deferred/0051-vcluster-substrate.md index 44673f0..0572247 100644 --- a/docs/adr/deferred/0051-vcluster-substrate.md +++ b/docs/adr/deferred/0051-vcluster-substrate.md @@ -10,6 +10,11 @@ rests-on: ["0008"] # The test substrate is measured before it gates +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on One provision-and-apply of the whole composed estate into a k3d cluster on the CI @@ -25,9 +30,9 @@ The push-delivery design made every deploy conditional on a substrate it never located. Objects are applied "after that relationship's system tests have passed against an ephemeral vcluster", and its own consequence list concedes that "**CI cost grows.** A change anywhere invalidates every aggregator's pin, so roughly six -suites run per service change, each provisioning a vcluster." What gets provisioned +suites run per application change, each provisioning a vcluster." What gets provisioned six times is not small: chapter 30's coverage measurement is 364 objects derived -from Service Intent plus 41 delivered by blueprint packs, 405 rendered objects (364 class A plus 41 pack-delivered) per run, +from Project Intent plus 41 delivered by blueprint packs, 405 rendered objects (364 class A plus 41 pack-delivered) per run, and 18 of those 41 are `HelmRelease` (`vault`, `vault-secrets-operator`, `metrics-stack`, `traefik`, `cert-manager`, `metallb`, `grafana`, `loki`, `tempo` and the rest), none of which mean anything without Flux's `helm-controller` in the @@ -61,7 +66,7 @@ script above "already does" a layer-ordered apply against a vcluster today. | option | cost if taken | why rejected | |---|---|---| -| vclusters on the production k3s cluster | six concurrent syncers × 405 objects on the 7-node pool and its `local-path` volumes; no isolation from the workloads [0061](../model/0061-placement-is-hard-dimensions.md) rations | Test load evicting production is precisely the failure the placement model exists to prevent | +| vclusters on the production k3s cluster | six concurrent syncers × 405 objects on the 7-node pool and its `local-path` volumes; no isolation from the processes [0061](../model/0061-placement-is-hard-dimensions.md) rations | Test load evicting production is precisely the failure the placement model exists to prevent | | a dedicated always-on test cluster | second set of hardware plus its own k3s upgrade, CNI ([0036](../model/0036-cni-selection.md)) and restore ([0057](../model/0057-datastore-and-restore.md)) story, run by the same one person | Doubles the operational surface to serve a gate whose cost is not yet known | | `kind` on the runner instead of k3d | same isolation, but a different distribution from the k3s the gate is predicting for | Reintroduces substrate drift the gate exists to eliminate; k3d runs the production k3s binary | | no substrate: schema validation plus `--dry-run=server` against production | near-zero CI cost | Needs a production credential on every PR and still never runs a relationship suite ([0049](0049-aggregator-owned-tests.md)) | @@ -85,7 +90,7 @@ its registry, `local-path` defaults) rather than the k3s API, or once the gate s - The runner host must hold a k3d cluster plus 18 HelmReleases; it stops being a checkout-and-node box and needs a capacity budget, paid by its operator - Production nodes are never the test substrate, so the 7-node pool stays rationed - for real workloads, paid by nobody; a benefit to service owners + for real processes, paid by nobody; a benefit to application owners - k3d cannot exercise the production CNI's enforcement path or the real MetalLB pool, so [0035](../model/0035-network-policy-default-deny.md)'s flow observation still needs a real cluster, paid by the network-policy work diff --git a/docs/adr/deferred/0058-delivery-machinery-observability.md b/docs/adr/deferred/0058-delivery-machinery-observability.md index d17214d..977f66b 100644 --- a/docs/adr/deferred/0058-delivery-machinery-observability.md +++ b/docs/adr/deferred/0058-delivery-machinery-observability.md @@ -9,6 +9,11 @@ rests-on: ["0008"] # The delivery machinery watches itself +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Nothing today notices when the delivery machinery stops working: no page, mail or @@ -21,11 +26,11 @@ then wait three hours and record every notification received, from any channel. ## Why -Routing was derived from fields only a Service carries. The old observability +Routing was derived from fields only an Application carries. The old observability record ends its decision at *"notifier routing from the Alert Class and owner"*, -and both are Service intent ([0021](../model/0021-observability-scrape-and-alert-class.md)). +and both are Application intent ([0021](../model/0021-observability-scrape-and-alert-class.md)). The reapply CronJob, the composition workflow, the relationship gate and the deploy -job are not Services: no Alert Class, no owner, no derived route. Their only +job are not Applications: no Alert Class, no owner, no derived route. Their only failure signal is a red GitHub Actions run, in an estate whose own record says a routinely-red PR is what people learn to ignore, and `failedJobsHistoryLimit: 3` (`spec/v1/examples/rendered/reapply-cronjob.yaml:22`) means the fourth consecutive @@ -51,7 +56,7 @@ exactly when a deploy has just succeeded and never when an aggregator's gate has been red for a week, its runner is offline, or its Renovate PR was closed: the failure mode it exists to detect is the only one during which it does not run. So machinery components carry the same scrape-surface-plus-Alert-Class vocabulary as -Services, and four conditions (missed or failed CronJob run, composition failure, +Applications, and four conditions (missed or failed CronJob run, composition failure, participant staleness past [0038](../model/0038-participants-list-staleness.md)'s `maxAge`, lag beyond bound), each carry an urgent class and an owner, lag evaluated cluster-side over the minimum lock annotation, not on the deploy path. @@ -61,7 +66,7 @@ cluster-side over the minimum lock annotation, not on the deploy path. | option | cost if taken | why rejected | |---|---|---| | Leave machinery signalling to red GitHub Actions runs | zero build; it is the status quo | A red run is a pull signal in a repository nobody opens on a quiet day, `failedJobsHistoryLimit: 3` destroys the first failure by the fourth, and it cannot report a job that never started | -| Model each machinery component as a Service so it inherits the Service vocabulary | one Service entry per CronJob, workflow and gate: six aggregators, four components each | Most Service fields are meaningless for a workflow (exposure, probes, grants), and composition would depend on the machinery being a participant in the estate it composes | +| Model each machinery component as an Application so it inherits the Application vocabulary | one Application entry per CronJob, workflow and gate: six aggregators, four components each | Most Application fields are meaningless for a workflow (exposure, probes, grants), and composition would depend on the machinery being a participant in the estate it composes | | An external dead-man's switch as the whole answer | one third-party account, a heartbeat token per aggregator, roughly a day | Detects the missed run and nothing else: blind to composition failure, staleness and lag. Kept as one of the four signals; rejected as the mechanism | ## Reversibility @@ -79,7 +84,7 @@ then leaves the thing that deploys everything unwatched, with no habit left. - Four machinery conditions gain an urgent class and a named owner, and someone is woken for a CronJob that did not fire, paid by joris, owner of all four. - Machinery rules need CronJob and Job state scraped and a receiver for a class no - Service routes to, paid by the metrics stack and its Alertmanager config. + Application routes to, paid by the metrics stack and its Alertmanager config. - Lag moves off the deploy path into a cluster-side query: one more object per aggregator, paid by the cron and rbac adapters. - The gate and composition run outside the cluster, so their signal is a heartbeat, diff --git a/docs/adr/deferred/README.md b/docs/adr/deferred/README.md index f1c4779..929f955 100644 --- a/docs/adr/deferred/README.md +++ b/docs/adr/deferred/README.md @@ -51,7 +51,7 @@ Status of everything in this directory: ([0098](../model/0098-one-publication-path.md)). The model emits the kustomize groupings and the ordering; until this set defines otherwise, the bootstrap Flux source applies the tree those groupings describe. -- **Whether a Release Unit may span an ownership boundary.** A Service belongs to +- **Whether a Release Unit may span an ownership boundary.** An Application belongs to at most one unit and membership is estate-wide; whether a unit may cross whatever ownership boundary a delivery definition introduces is that definition's question, moved here from diff --git a/docs/adr/deferred/examples/aggregator-deploy.yml b/docs/adr/deferred/examples/aggregator-deploy.yml index c333baf..2e82980 100644 --- a/docs/adr/deferred/examples/aggregator-deploy.yml +++ b/docs/adr/deferred/examples/aggregator-deploy.yml @@ -10,7 +10,7 @@ # # Runs on a self-hosted runner INSIDE the cluster under a ServiceAccount whose # Role is generated from this Aggregator's `deploys` list, so applying a -# Service it does not own returns 403 rather than producing a bad deploy. No +# Application it does not own returns 403 rather than producing a bad deploy. No # kubeconfig exists outside the cluster. name: Deploy @@ -72,7 +72,7 @@ jobs: - name: Render my slice if: ${{ !cancelled() }} run: | - # Only the Services this Aggregator deploys. Everything else in the + # Only the Applications this Aggregator deploys. Everything else in the # composed estate belongs to another Aggregator or to Flux. npx deploy-config-schema render \ --composed candidate/ \ diff --git a/docs/adr/deferred/examples/aggregator-gate.yml b/docs/adr/deferred/examples/aggregator-gate.yml index 37cbf4a..0f62b40 100644 --- a/docs/adr/deferred/examples/aggregator-gate.yml +++ b/docs/adr/deferred/examples/aggregator-gate.yml @@ -71,7 +71,7 @@ jobs: # production's split rather than diverging from it. node deploy-harness/scripts/apply-candidate.mjs \ --candidate candidate/ \ - --rewrite-domain jorisjonkers.dev=jorisjonkers.test + --rewrite-project jorisjonkers.dev=jorisjonkers.test - name: Wait for readiness if: ${{ !cancelled() }} diff --git a/docs/adr/deferred/examples/aggregator.yml b/docs/adr/deferred/examples/aggregator.yml index c590388..2dbcbf2 100644 --- a/docs/adr/deferred/examples/aggregator.yml +++ b/docs/adr/deferred/examples/aggregator.yml @@ -1,6 +1,6 @@ # systest-auth-federation/aggregator.yml # -# An Aggregator owns a relationship between Services. It carries two lists, and +# An Aggregator owns a relationship between Applications. It carries two lists, and # the distinction between them is what makes overlap safe. apiVersion: intent.jorisjonkers.dev/v1 kind: Aggregator @@ -8,7 +8,7 @@ metadata: repository: JorisJonkers-dev/systest-auth-federation spec: - # exercises: MANY-TO-MANY. The Services this suite runs against. + # exercises: MANY-TO-MANY. The Applications this suite runs against. # # auth-api appears here and also in systest-auth-pairing, because it # genuinely participates in two relationships. The twelve auth relationship @@ -24,7 +24,7 @@ spec: - n8n - platform-rabbitmq - # deploys: ONE-TO-ONE across the estate. The Services this Aggregator alone + # deploys: ONE-TO-ONE across the estate. The Applications this Aggregator alone # may apply. Every other Aggregator naming them in `exercises` is a gate. # # Enforced twice: E_NO_DEPLOYER / E_MULTIPLE_DEPLOYERS at composition, and by diff --git a/docs/adr/deferred/examples/deployer-rbac.yaml b/docs/adr/deferred/examples/deployer-rbac.yaml index 69e9d2b..06fefd6 100644 --- a/docs/adr/deferred/examples/deployer-rbac.yaml +++ b/docs/adr/deferred/examples/deployer-rbac.yaml @@ -2,7 +2,7 @@ # Never hand-edited. # # This is what turns "deploy authority is exactly one" from a CI convention -# into an API-server control. A workflow that tries to apply a Service this +# into an API-server control. A workflow that tries to apply an Application this # Aggregator does not deploy receives 403 rather than producing a bad deploy. # # deploys: [auth-api, auth-ui] -> namespaces auth-system, app-system @@ -26,7 +26,7 @@ rules: # else, and no cluster-scoped verbs: an Aggregator cannot create a Namespace # or a CRD, because those belong to class B and to Flux. - apiGroups: [''] - resources: [services, serviceaccounts, configmaps, persistentvolumeclaims] + resources: [applications, serviceaccounts, configmaps, persistentvolumeclaims] verbs: [get, list, create, patch, update, delete] - apiGroups: [apps] resources: [deployments, statefulsets] diff --git a/docs/adr/deferred/examples/reapply-cronjob.yaml b/docs/adr/deferred/examples/reapply-cronjob.yaml index 75d3ab0..b0f4935 100644 --- a/docs/adr/deferred/examples/reapply-cronjob.yaml +++ b/docs/adr/deferred/examples/reapply-cronjob.yaml @@ -28,7 +28,7 @@ spec: restartPolicy: OnFailure # The same identity as the merge apply, so the re-apply cannot touch # anything the merge apply could not. - serviceAccountName: deployer-auth-federation + applicationAccountName: deployer-auth-federation containers: - name: reapply image: ghcr.io/jorisjonkers-dev/deploy-config-schema:1.0.0 diff --git a/docs/adr/model/0001-estate-scale-and-ownership.md b/docs/adr/model/0001-estate-scale-and-ownership.md index 05b6cdb..b3b7e0d 100644 --- a/docs/adr/model/0001-estate-scale-and-ownership.md +++ b/docs/adr/model/0001-estate-scale-and-ownership.md @@ -7,12 +7,17 @@ date: 2026-08-31 normative: spec/v1/00-overview.md#the-estate --- -# The estate is one maintainer, one cluster, about thirty Services +# The estate is one maintainer, one cluster, about thirty Applications + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on The estate is operated by one regular human maintainer (one person across three git identities, plus bot accounts), runs one production cluster, and comprises -about thirty Services across about ten repositories, and this holds for a +about thirty Applications across about ten repositories, and this holds for a stated horizon of 24 months, until 2028-08-31. False if: a second regular human maintainer (sustained commits or reviews, not a bot) or a second production cluster appears before the horizon. Settled by: `git shortlog -sn --all` run @@ -27,10 +32,10 @@ bot account (five of them, 99 commits between them, as of 2026-08-31). The red team verified the same estate-wide: "`git shortlog --all` shows one human across three identities (35 commits) plus bot accounts" (review/06-redteam.md, RED-006). The physical estate is what RED-013 measured the design's value -against: "a 30-service, 7-node, one-cluster, one-user homelab that currently +against: "a 30-application, 7-node, one-cluster, one-user homelab that currently runs." Reviewers who wrote "~30 separate repositories" -(`review/CONSOLIDATED.md:248`, `review/04-k3s.md:121`) were counting Services, -not repositories; the premise is ~30 Services in ~10 repositories, and the +(`review/CONSOLIDATED.md:248`, `review/04-k3s.md:121`) were counting Applications, +not repositories; the premise is ~30 Applications in ~10 repositories, and the horizon recount settles which. This has to be the outermost premise because its absence was the review's @@ -40,11 +45,11 @@ an organisation and an estate that do not exist") collapses nine findings section) into that one sentence. RED-006 itemises the machinery built for separated authority: `owner` fields, `alertClass: page` notifier routing, publish-back pull requests into "the owning repository", per-aggregator RBAC -where a workflow applying a Service it does not own gets a 403. Its verdict: "If +where a workflow applying an Application it does not own gets a 403. Its verdict: "If one person is every owner, then every publish-back PR is a self-review, every ledger review date is a note to self, and the 403 protects the author from the author." Every mechanism in this specification must therefore justify itself at -THIS scale, one maintainer, one cluster, thirty Services, not at the scale of +THIS scale, one maintainer, one cluster, thirty Applications, not at the scale of an imagined organisation. The premise is dated, not permanent. A 24-month horizon is long enough to build @@ -75,14 +80,14 @@ re-opened against the new scale, at whatever the estate has grown to cost. ## Consequences - Every ADR in this set must justify its mechanism at one-maintainer, - one-cluster, thirty-Service scale, and an ADR that cannot is wrong by + one-cluster, thirty-Application scale, and an ADR that cannot is wrong by construction, paid by the author of each ADR, at writing time. - Invariants that arbitrate between people (self-reviewed publish-back PRs, RBAC protecting the author from the author) fall out of v1 scope; if a second maintainer arrives, those controls are missing on day one and must be built then, paid by that second maintainer and joris, at growth time. - The horizon review on 2028-08-31 is a standing obligation: re-run the - shortlog, recount clusters and Services, re-date or revise this file, paid + shortlog, recount clusters and Applications, re-date or revise this file, paid by joris. - A falsifying observation inside the horizon (second maintainer, second production cluster) forces re-examination of every decision resting here, diff --git a/docs/adr/model/0002-kubernetes-as-substrate.md b/docs/adr/model/0002-kubernetes-as-substrate.md index 840d859..1ac518d 100644 --- a/docs/adr/model/0002-kubernetes-as-substrate.md +++ b/docs/adr/model/0002-kubernetes-as-substrate.md @@ -9,11 +9,16 @@ normative: spec/v1/00-overview.md#substrate # Kubernetes stays, for two properties that must be made real +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Both retained properties (the API server as a per-aggregator authorisation boundary, and server-side-apply field ownership as the drift signal) can be made real at this scale. False if: either cannot, an aggregator can still -mutate a Service it does not deploy after +mutate an Application it does not deploy after [0047](../deferred/0047-namespace-per-deployer.md) lands, or a hand edit to an owned field still surfaces no conflict after [0046](../deferred/0046-distinct-field-managers.md) lands. Settled by: a `kubectl @@ -26,20 +31,20 @@ CronJob run, observe the reported conflict) after 0046 is applied. The estate uses none of the headline properties Kubernetes is bought for. Rescheduling does not exist: storage is `local-path`, the fourteen PVCs are `ReadWriteOnce`, and a `local-path` volume does not survive its node, so every -stateful workload is pinned to one machine by construction. Control-plane HA +stateful process is pinned to one machine by construction. Control-plane HA does not exist: every platform fixture carries exactly one `k3s-control-plane` host (`fixtures/platform/single-node.platform.yaml:30`, `full-tree.platform.yaml:42`, `multi-site.platform.yaml:37`). Horizontal scale is not exercised: `auth-api`'s two replicas "were a capacity decision on freed Frankfurt budget, not an availability requirement" -(`spec/v1/10-service-intent.md:491-492`), and on one node two replicas is two +(`spec/v1/10-project-intent.md:491-492`), and on one node two replicas is two processes on one kernel. The overhead is counted: 405 rendered objects (364 -class A plus 41 pack-delivered, of 450 live) for ~30 Services +class A plus 41 pack-delivered, of 450 live) for ~30 Applications (`spec/v1/50-lifecycle.md:33`), and the foundation 41 is the part with the CVEs and the CRD upgrades. Exactly two properties justify keeping it. First, the API server as the -authorisation boundary: "a workflow that tries to apply a Service it does not +authorisation boundary: "a workflow that tries to apply an Application it does not own receives a 403 rather than producing a bad deploy" (`spec/v1/50-lifecycle.md:200`). Second, server-side-apply field ownership as the drift mechanism: a conflict "means a human edited a field this aggregator @@ -47,7 +52,7 @@ owns: it is reported, never resolved with `--force-conflicts`" (`spec/v1/50-lifecycle.md:151`). The review (`review/CONSOLIDATED.md` B9) verified both fail as currently designed. The boundary fails because the generated deployer Role is namespace-scoped with no `resourceNames` while the -Services of one domain all share its namespace, so the ownership rule is a CI +Applications of one project all share its namespace, so the ownership rule is a CI check, not an API-server control. The drift signal fails because the merge deploy and the hourly re-apply CronJob deliberately share the field-manager name `auth-federation` @@ -60,7 +65,7 @@ properties real; this premise stands or falls with their settling measurements, which is why its claim is open. The rival substrate is already resident: the estate runs Nix as a second -deployment target for five host services (`samba`, `wolf`, `tailscale`, +deployment target for five host applications (`samba`, `wolf`, `tailscale`, `media-storage`, `btrfs-backup-snapshots`). Collapsing onto it would delete the 41 foundation objects, two Traefiks, MetalLB, VSO and the upgrade treadmill, and would also delete the authorisation boundary and field-level ownership, @@ -83,8 +88,8 @@ emit Kubernetes kinds, the delivery workflows speak `kubectl` and server-side apply, the 41 foundation objects have no non-Kubernetes packaging, and fourteen node-pinned `local-path` volumes must be re-homed by hand. The layer-1 intent files survive a swap: they name no Kubernetes kind. -Becomes irreversible once: the ~10 service repositories holding the estate's ~30 -Services author against a shipped v1 whose adapters and delivery machinery are +Becomes irreversible once: the ~10 project repositories holding the estate's ~30 +Applications author against a shipped v1 whose adapters and delivery machinery are Kubernetes-shaped, from that point a substrate swap is a v2 migration, not an undo. diff --git a/docs/adr/model/0003-three-layer-meta-model.md b/docs/adr/model/0003-three-layer-meta-model.md index 18d666a..aa3a74b 100644 --- a/docs/adr/model/0003-three-layer-meta-model.md +++ b/docs/adr/model/0003-three-layer-meta-model.md @@ -8,9 +8,14 @@ normative: spec/v1/00-overview.md#the-meta-model # Three layers, with the middle layer as a contract +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -With deployment configuration split into **Service Intent** (hand-authored, +With deployment configuration split into **Project Intent** (hand-authored, requirements only), **Resolved Deployment** (derived; holds every platform decision) and **Deliverable Set** (serialization only, no decisions), every field is assignable to exactly one layer, and the boundary is decidable by two @@ -37,7 +42,7 @@ on `/apiVersion`. A two-layer vocabulary could not even say which document was wrong, because "the deployment" named all three. Naming the middle layer turns two rules from aspirations into things that can -fail: Service Intent contains no mechanisms, and the Deliverable Set contains +fail: Project Intent contains no mechanisms, and the Deliverable Set contains no decisions. Every field is then assignable to exactly one layer, and a reviewer can read a diff of the Resolved Deployment to see what the platform decided on their behalf. The middle layer is a contract in the concrete sense: diff --git a/docs/adr/model/0004-contention-decides-authority.md b/docs/adr/model/0004-contention-decides-authority.md index 3d0f149..a9488e5 100644 --- a/docs/adr/model/0004-contention-decides-authority.md +++ b/docs/adr/model/0004-contention-decides-authority.md @@ -9,12 +9,17 @@ normative: spec/v1/20-resolved-deployment.md#authority # Contention decides who declares a value +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Contention decides who **arbitrates** a value, not who authors it: a value unique across the estate or drawing on a shared finite resource is arbitrated by the platform, which decides whether a stated requirement fits and where; -every other value is Service-declared and carried through untouched, and this +every other value is Application-declared and carried through untouched, and this test partitions every field in the model with no residue. False if: any field needs a third category, or the authority table needs an exceptions row to hold. Settled by: the authority table in [chapter 20](../../../spec/v1/20-resolved-deployment.md#authority) @@ -27,20 +32,20 @@ the change that introduced them was being made. One hostname, `kb.jorisjonkers.dev`, ended up declared in seven authoritative places across three repositories: `homelab-inventory/catalog/reachability.yml`, three `fleet-infra` edge and knowledge manifests, a bearer-token secret, and the -service's own `platform/deployment.yml`, plus hardcoded in -`ServicePermission.kt`. Two conformance tests exist for no purpose other than +application's own `platform/deployment.yml`, plus hardcoded in +`ApplicationPermission.kt`. Two conformance tests exist for no purpose other than detecting when those seven disagree. The guard was cheaper to write than the fix, which is how the estate arrived here. The rule replaces a per-field negotiation with a one-question test: does the value contend? A hostname must be unique across the estate; a node slot is a -draw on a finite pool. Contention does not silence the Service: it means the -Service does not get the last word: the Service states its requirement, the +draw on a finite pool. Contention does not silence the Application: it means the +Application does not get the last word: the Application states its requirement, the platform decides whether it fits and where. Placement forced that reading. -`memory` and `cpu` are authored per Workload as raw quantities +`memory` and `cpu` are authored per Process as raw quantities ([0061](0061-placement-is-hard-dimensions.md)) and both are contended, so an authors-only rule would forbid the field and leave the estate where it is: -BestEffort on every pod, because a number no Service may write is a number +BestEffort on every pod, because a number no Application may write is a number nobody writes. The platform arbitrates against node `allocatable` from the pinned node contract ([0056](0056-node-facts-single-source.md)) and rejects what no node can hold with `E_PLACEMENT_UNSATISFIABLE`. @@ -51,7 +56,7 @@ later decisions without amendment (co-test sets, health paths, migration strategy, and hostnames), the last recorded as open item 1 in [../../spec/v1/00-overview.md](../../../spec/v1/00-overview.md): chapter 20 separates *identity* (unique, declared, checked) from *pool* (finite, -assigned) "because not one live hostname is derivable from a Service Id". A +assigned) "because not one live hostname is derivable from an Application Id". A rule and a table have different lifetimes. The table lives once, at the normative anchor, where every row must cite which half of the rule placed it; that same open item is live pressure on the residue claim, which is why this @@ -62,19 +67,19 @@ premise is open rather than settled. | option | cost if taken | why rejected | |---|---|---| | Ownership-by-on-call: whoever gets paged for a value declares it | a fresh negotiation per field, re-run whenever the pager rotation changes | one of the two implicit rules actually in force while `kb.jorisjonkers.dev` accumulated seven authoritative declarations and two disagreement-detecting tests | -| Ownership-by-churn: whoever edits a value most declares it | authority migrates silently as a service matures and its churn moves; each migration is another declaration site | the other implicit rule in force; it is precisely how declarations landed "wherever the change was being made" | +| Ownership-by-churn: whoever edits a value most declares it | authority migrates silently as an application matures and its churn moves; each migration is another declaration site | the other implicit rule in force; it is precisely how declarations landed "wherever the change was being made" | | Enumerated authority table with no generating rule | every new field is a table negotiation, and the table becomes the decision | the old set ran this experiment: its worked list drifted four times, unamended, while still being cited as the authority | ## Reversibility Undo cost today: reassign authority field-by-field in the chapter-20 table (one file) and retire this premise: hours of editing, but the decisions the -index rests on it ([0010](0010-flat-service-identity.md), +index rests on it ([0010](0010-flat-application-identity.md), [0018](0018-exposure-by-audience.md), [0019](0019-registered-unmanaged-surfaces.md), [0033](0033-assignments-published-back.md)) each lose their stated justification and must restate their own. -Becomes irreversible once: service repositories author Intent against the +Becomes irreversible once: project repositories author Intent against the chapter-20 table: moving a field across the Intent/Resolved boundary after that is a schema-shape change paid again in every consuming repository. @@ -83,7 +88,7 @@ that is a schema-shape change paid again in every consuming repository. - Field placement stops being a negotiation: every proposed field answers the contention question before it enters the schema, paid by the author of the schema change. -- A service owner cannot read their own service's URL out of their own +- An application owner cannot read their own application's URL out of their own repository, so assignments must be published back rather than merely computed during a render ([0033](0033-assignments-published-back.md)), paid by the platform, which owns the publish-back machinery. diff --git a/docs/adr/model/0005-derivation-is-total.md b/docs/adr/model/0005-derivation-is-total.md index 6cf908c..3fe5b94 100644 --- a/docs/adr/model/0005-derivation-is-total.md +++ b/docs/adr/model/0005-derivation-is-total.md @@ -9,15 +9,20 @@ normative: spec/v1/20-resolved-deployment.md#derived-mechanics # Derivation from declared intent covers the live estate +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every hand-tuned value in the live estate is reachable from a value only the -owning Service could know. False if: a live value exists that no layer-1 field +owning Application could know. False if: a live value exists that no layer-1 field can reach. Settled by: rendering the whole estate and diffing against the live manifests: every unauthored value remaining in the diff is a counterexample. ## Why This is the premise under every derive-not-declare decision -([0030](0030-runtime-mechanics-derived.md) and its siblings): if a Service +([0030](0030-runtime-mechanics-derived.md) and its siblings): if an Application declares what only it can know (its cold-start budget, whether it requires zero-downtime rolls, which paths answer readiness and liveness, what a volume's data is worth), then probe timings, rollout strategy, surge and unavailability, @@ -55,8 +60,8 @@ Rival premises, this being a premise: | option | cost if taken | why rejected | |---|---|---| -| Partial derivation, hand-tuning allowed (the v2 state) | v2's vocabulary was `path`, `port`, `timeoutClass`, `mandatory`, `livenessPath`, `probeTimeoutSeconds`; everything else lived only in hand-written manifests, so each new service rediscovered the rollout pattern or blind-copied it without the reasoning | Hard-won behaviour decays into uncomprehended copies; the four identical blocks with cost-recording comments are the price already paid | -| Full declaration (every service restates the platform) | the ~10 hand-authored layer-1 repositories carry every mechanic for all ~30 Services; one platform-wide tuning change is ~30 Service edits across ~10 pull requests | The values are platform knowledge, not Service knowledge; a Service owner cannot defend `failureThreshold: 120` and should not be asked to | +| Partial derivation, hand-tuning allowed (the v2 state) | v2's vocabulary was `path`, `port`, `timeoutClass`, `mandatory`, `livenessPath`, `probeTimeoutSeconds`; everything else lived only in hand-written manifests, so each new application rediscovered the rollout pattern or blind-copied it without the reasoning | Hard-won behaviour decays into uncomprehended copies; the four identical blocks with cost-recording comments are the price already paid | +| Full declaration (every application restates the platform) | the ~10 hand-authored layer-1 repositories carry every mechanic for all ~30 Applications; one platform-wide tuning change is ~30 Application edits across ~10 pull requests | The values are platform knowledge, not Application knowledge; an Application owner cannot defend `failureThreshold: 120` and should not be asked to | ## Reversibility Undo cost today: reopen the 14 index decisions that rest on this premise (0011 @@ -64,7 +69,7 @@ through [0056](0056-node-facts-single-source.md)), reintroduce authored-mechanics fields into the layer-1 schemas, and accept hand-tuned values back across ~10 repositories, days of schema work plus an estate-wide re-author. Becomes irreversible once: the hand-written manifests carrying the -tuned values and their explanatory comments are deleted from the service +tuned values and their explanatory comments are deleted from the application repositories. After that the derivation rules are the only record of the hard-won values, and there is nothing to fall back to. @@ -75,11 +80,11 @@ hard-won values, and there is nothing to fall back to. - Vocabulary gaps (securityContext and requests today) are invisible at authoring time and surface only in the settlement diff, paid by whoever runs the estate render. -- New workloads inherit the four-deployment rollout pattern as a rule instead +- New processes inherit the four-deployment rollout pattern as a rule instead of a copy, and the reasoning lives once, maintenance paid by the platform owner. -- A wrong derivation rule mis-tunes every service at once instead of one, +- A wrong derivation rule mis-tunes every application at once instead of one, paid by the whole estate on the first rollout after the bad rule. -- An unusual workload cannot hand-tune anything except through the +- An unusual process cannot hand-tune anything except through the named-override hatch of [0031](0031-derived-overrides-with-reason.md), - paid by that workload's owner, in a recorded reason per override. + paid by that process's owner, in a recorded reason per override. diff --git a/docs/adr/model/0006-pinned-inputs.md b/docs/adr/model/0006-pinned-inputs.md index 1bf87c0..bab4dd7 100644 --- a/docs/adr/model/0006-pinned-inputs.md +++ b/docs/adr/model/0006-pinned-inputs.md @@ -9,9 +9,14 @@ normative: spec/v1/20-resolved-deployment.md#pinned-inputs # Every assignment is a function of pinned, digested inputs +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -The pinned input set (Service Intent, the Platform Intent, the locks, and a +The pinned input set (Project Intent, the Platform Intent, the locks, and a ClusterState snapshot, each carried by digest) is closed: no layer-2 assignment reads anything outside it at render time. False if: any assignment consults live cluster state, a mutable pool, a counter, or state remembered between renders. @@ -24,13 +29,13 @@ machines, and byte-diff the output trees; any difference falsifies the premise. The previous formulation was absolute and was falsified by its own chapter. `spec/v1/20-resolved-deployment.md:8-11` declared, as "the load-bearing property of the whole specification", that *"Every assignment is a pure function of -Service Intent, the pinned Platform Intent, and the pinned locks."* Yet the same +Project Intent, the pinned Platform Intent, and the pinned locks."* Yet the same chapter's normative `ResolvedService` example (`:246-249`) carries an observed PV binding (`node: enschede-t1000-1`, `because: knowledge-vault-clone PV is bound here`) while `inputDigests` is `{intent, imagesLock}` with `contextRef` alongside (`:216`): the binding is in none of the pinned inputs and is read from the live cluster. A third instance sat unresolved across chapters: -`spec/v1/10-service-intent.md:463` assigns `replicas` "from `minAvailable` and +`spec/v1/10-project-intent.md:463` assigns `replicas` "from `minAvailable` and capacity" while `20-resolved-deployment.md:266` says a `replicas` assignment reading live capacity "would violate purity outright". Two review lenses found this independently (RED-002; DAT-006, DAT-007: consolidated finding B2). @@ -70,7 +75,7 @@ on this premise, would need reworking. No snapshot-capture code exists yet, so the blast radius is documents plus that one dependent decision. Becomes irreversible once: the double-render determinism test gates CI and `resolved.yml` artifacts carrying `clusterStateDigest` are published back into -service repositories, weakening the premise after that silently reclassifies +project repositories, weakening the premise after that silently reclassifies defects as weather across the estate. ## Consequences diff --git a/docs/adr/model/0007-schema-version-separable.md b/docs/adr/model/0007-schema-version-separable.md index b4c4b17..d98fd84 100644 --- a/docs/adr/model/0007-schema-version-separable.md +++ b/docs/adr/model/0007-schema-version-separable.md @@ -9,6 +9,11 @@ normative: spec/v1/40-composition.md#versioning # The data model's version is not the package's version +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on The artifact schema version can be decoupled from `package.json`'s version, and composition can accept a compatibility range per fragment without losing "no @@ -27,7 +32,7 @@ Today the two versions are one number by construction. `version`. That field moves at release cadence, not model cadence: CHANGELOG.md shows 26 releases in ten weeks (2026-06-09 to 2026-08-20), most of which changed code, not the data model. The superseded lockstep decision (0013 in the -old set) records the live result: skew of `0.16.0` in four service repos, +old set) records the live result: skew of `0.16.0` in four project repos, `0.20.0` in `stalwart-provisioner`, `0.22.0` in the published contexts, and the estate still functions. The equality rule is stricter than what the estate demonstrably needs. @@ -40,7 +45,7 @@ fix), and `dormant: true` exempts a participant from `maxAge` but not from the version assert. Post-v1, each of those 26-in-ten-weeks releases would open roughly ten Renovate PRs that must all merge before anything renders. Two spec artifacts already contradict lockstep as written: `spec/v1/40-composition.md:237` -and `spec/v1/10-service-intent.md:36` both declare `schemaVersion: 1.0.0`, +and `spec/v1/10-project-intent.md:36` both declare `schemaVersion: 1.0.0`, satisfiable only while the npm package sits at exactly `1.0.0`. Separability does not surrender determinism. The guarantee the old equality diff --git a/docs/adr/model/0009-vault-read-is-per-path.md b/docs/adr/model/0009-vault-read-is-per-path.md index 9697a16..0e2a882 100644 --- a/docs/adr/model/0009-vault-read-is-per-path.md +++ b/docs/adr/model/0009-vault-read-is-per-path.md @@ -4,11 +4,16 @@ status: proposed claim: open owner: joris date: 2026-08-31 -normative: spec/v1/10-service-intent.md#secrets +normative: spec/v1/10-project-intent.md#secrets --- # A Vault KV-v2 read grant covers the whole path +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on On this estate's KV-v2 mount, the `read` capability attaches to the API path @@ -72,7 +77,7 @@ placeholder that names them. ## Consequences - `keys:` documents and validates but confers nothing; an author must never - read it as an access boundary, paid by service authors. + read it as an access boundary, paid by application authors. - The grant unit must be the path, and any two secrets with different reader sets must live at different paths, the Secret Subtree split decided in [0023](0023-grant-unit-is-the-path.md), paid by joris in the layout pass. diff --git a/docs/adr/model/0010-flat-application-identity.md b/docs/adr/model/0010-flat-application-identity.md new file mode 100644 index 0000000..7da49a4 --- /dev/null +++ b/docs/adr/model/0010-flat-application-identity.md @@ -0,0 +1,95 @@ +--- +tier: decision +status: proposed +claim: settled +date: 2026-09-07 +normative: spec/v1/10-project-intent.md#application-identity +rests-on: ["0004"] +--- + +# One flat Application Id + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +An Application is identified by one short string, unique across the estate, and that +string is the only identity another Application may reference. The id is the +repository or product name. The namespace derives from the Application's project +([0063](0063-intent-authored-per-project.md)); Process names and image +references are authored, not derived; there is no alias mechanism. + +## Rests on + +Identity is the estate's most contended surface (dependency edges, Vault paths +and route ownership all resolve through it), so per +[0004](0004-contention-decides-authority.md) it takes exactly one authoritative +form, while the namespace is derivable from the project and Process and image +names are properties of the processes themselves. False if: two Applications +genuinely require the same id, or a live coordinate turns up that is neither +derivable from the project nor already authored explicitly. Settled by: composing +the full participants list and asserting zero `E_DUPLICATE_APPLICATION_ID` +occurrences and that every namespace it renders is `-system`. + +## Why + +Take the case the alias field was invented for. +`fleet-infra/docs/live-divergence.md` records it as a rename, *"the application +repository is home-portal; live called the image app-ui"*, and the model +carried an `alias` to explain it. Under this decision there is nothing to +explain. The Application id is the repository name, `home-portal`. The Process is +called what the process is called, `app-ui`, and so is its image, because a +Process name is what the program is called and never a derivative of the id. The namespace +is `app-system` because the project is `app` +([0063](0063-intent-authored-per-project.md)). Nothing moves and nothing is +aliased: the prose row describes a divergence that no longer exists. + +Flat uniqueness cannot be had by construction, only by check: the id is a bare +string with no project or repository path inside it, so nothing structural stops +two repositories claiming the same string. Uniqueness is therefore a composition-time check: +`E_DUPLICATE_APPLICATION_ID`, specified in +[chapter 40](../../../spec/v1/40-composition.md), and the window in which two +repositories both claim an id, open until composition runs, is an accepted +cost. + +An alias field would have nothing left to carry. The namespace comes from the +project, the Process name and the image are already authored, and the one +divergence an alias still expressed (a namespace of its own choosing), is +exactly the move that lets an Application claim another project's namespace. Deleting +the field deletes that move with it. + +## Alternatives + +| option | cost if taken | why rejected | +|---|---|---| +| Project-qualified id (`data/platform-postgres`), mirroring the vault claim format and the collection layout | every inbound reference and Vault path embeds the project, so a project move is an estate-wide rename | moving an Application between projects would rename it and break every inbound reference and Vault path | +| Location-derived URN (`svc:/`), unique by construction and self-resolving | identity welds to repository layout; splitting or merging a repository renames its Applications | `homelab-collections` holds three Applications in one repository, so the repo coordinate does not identify an Application | + +## Reversibility + +Undo cost today: hours, not days: the identity rules live in +`spec/v1/10-project-intent.md#application-identity` and chapter 20's derivation +table, and only a handful of Application documents exist; no composed artifact has +been published against the scheme. +Becomes irreversible once: ids are baked into published composed artifacts, +Vault paths and other Applications' dependency edges across the estate, from that +point a scheme change is the very estate-wide rename this decision exists to +avoid. + +## Consequences + +- Every cross-Application reference resolves through one string, and the namespace + falls out of the project, paid by authors, who give up encoding project or + location in the id. +- A deliberate divergence can no longer be recorded as data with a reason: + there is no field for one, so a name that surprises a reader is explained in + prose or not at all, paid by whoever next asks why the `home-portal` + repository runs a process called `app-ui`. +- Uniqueness is enforced by `E_DUPLICATE_APPLICATION_ID` at composition, not by + construction; two repositories can claim one id until composition runs, + paid by the aggregator operator, who discovers the collision only then. +- A namespace is no longer reachable from Project Intent at all: it is + `-system` and nothing else, so the Applications of one project share one + namespace and that namespace is not a trust boundary, paid by anyone who + read co-location as isolation. diff --git a/docs/adr/model/0010-flat-service-identity.md b/docs/adr/model/0010-flat-service-identity.md deleted file mode 100644 index 7a1fcbe..0000000 --- a/docs/adr/model/0010-flat-service-identity.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -tier: decision -status: proposed -claim: settled -date: 2026-09-07 -normative: spec/v1/10-service-intent.md#service-identity -rests-on: ["0004"] ---- - -# One flat Service Id - -A Service is identified by one short string, unique across the estate, and that -string is the only identity another Service may reference. The id is the -repository or product name. The namespace derives from the Service's domain -([0063](0063-intent-authored-per-domain.md)); Workload names and image -references are authored, not derived; there is no alias mechanism. - -## Rests on - -Identity is the estate's most contended surface (dependency edges, Vault paths -and route ownership all resolve through it), so per -[0004](0004-contention-decides-authority.md) it takes exactly one authoritative -form, while the namespace is derivable from the domain and Workload and image -names are properties of the processes themselves. False if: two Services -genuinely require the same id, or a live coordinate turns up that is neither -derivable from the domain nor already authored explicitly. Settled by: composing -the full participants list and asserting zero `E_DUPLICATE_SERVICE_ID` -occurrences and that every namespace it renders is `-system`. - -## Why - -Take the case the alias field was invented for. -`fleet-infra/docs/live-divergence.md` records it as a rename, *"the service -repository is home-portal; live called the image app-ui"*, and the model -carried an `alias` to explain it. Under this decision there is nothing to -explain. The Service id is the repository name, `home-portal`. The Workload is -called what the process is called, `app-ui`, and so is its image, because a -Workload name is a process name and never a derivative of the id. The namespace -is `app-system` because the domain is `app` -([0063](0063-intent-authored-per-domain.md)). Nothing moves and nothing is -aliased: the prose row describes a divergence that no longer exists. - -Flat uniqueness cannot be had by construction, only by check: the id is a bare -string with no domain or repository path inside it, so nothing structural stops -two repositories claiming the same string. Uniqueness is therefore a composition-time check: -`E_DUPLICATE_SERVICE_ID`, specified in -[chapter 40](../../../spec/v1/40-composition.md), and the window in which two -repositories both claim an id, open until composition runs, is an accepted -cost. - -An alias field would have nothing left to carry. The namespace comes from the -domain, the Workload name and the image are already authored, and the one -divergence an alias still expressed (a namespace of its own choosing), is -exactly the move that lets a Service claim another domain's namespace. Deleting -the field deletes that move with it. - -## Alternatives - -| option | cost if taken | why rejected | -|---|---|---| -| Domain-qualified id (`data/platform-postgres`), mirroring the vault claim format and the collection layout | every inbound reference and Vault path embeds the domain, so a domain move is an estate-wide rename | moving a Service between domains would rename it and break every inbound reference and Vault path | -| Location-derived URN (`svc:/`), unique by construction and self-resolving | identity welds to repository layout; splitting or merging a repository renames its Services | `homelab-collections` holds three Services in one repository, so the repo coordinate does not identify a Service | - -## Reversibility - -Undo cost today: hours, not days: the identity rules live in -`spec/v1/10-service-intent.md#service-identity` and chapter 20's derivation -table, and only a handful of Service documents exist; no composed artifact has -been published against the scheme. -Becomes irreversible once: ids are baked into published composed artifacts, -Vault paths and other Services' dependency edges across the estate, from that -point a scheme change is the very estate-wide rename this decision exists to -avoid. - -## Consequences - -- Every cross-Service reference resolves through one string, and the namespace - falls out of the domain, paid by authors, who give up encoding domain or - location in the id. -- A deliberate divergence can no longer be recorded as data with a reason: - there is no field for one, so a name that surprises a reader is explained in - prose or not at all, paid by whoever next asks why the `home-portal` - repository runs a process called `app-ui`. -- Uniqueness is enforced by `E_DUPLICATE_SERVICE_ID` at composition, not by - construction; two repositories can claim one id until composition runs, - paid by the aggregator operator, who discovers the collision only then. -- A namespace is no longer reachable from Service Intent at all: it is - `-system` and nothing else, so the Services of one domain share one - namespace and that namespace is not a trust boundary, paid by anyone who - read co-location as isolation. diff --git a/docs/adr/model/0011-configuration-env-files-per-workload.md b/docs/adr/model/0011-configuration-env-files-per-process.md similarity index 68% rename from docs/adr/model/0011-configuration-env-files-per-workload.md rename to docs/adr/model/0011-configuration-env-files-per-process.md index 2f434cd..04daa76 100644 --- a/docs/adr/model/0011-configuration-env-files-per-workload.md +++ b/docs/adr/model/0011-configuration-env-files-per-process.md @@ -3,11 +3,16 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#configuration +normative: spec/v1/10-project-intent.md#configuration rests-on: ["0005"] --- -# Configuration is per-Workload env files with named placeholders +# Configuration is per-Process env files with named placeholders + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. > **Amended 2026-09-08.** The reversibility clause below speaks of blueprint > packs distributing the format; packs no longer exist @@ -15,47 +20,47 @@ rests-on: ["0005"] > irreversible is the same thing by another route: participants author against > the format and publish it as Intent Fragments. -Configuration is authored as env files in real dotenv format, **per Workload**: -`platform/env//base.env` carries what does not vary, one overlay per -Cluster Target (`platform/env//.env`) carries only what +Configuration is authored as env files in real dotenv format, **per Process**: +`platform/env//base.env` carries what does not vary, one overlay per +Cluster Target (`platform/env//.env`) carries only what differs, overlay winning key by key. A literal is written literally; a value the platform derives is written as a **named placeholder** the renderer resolves: `${dependency:…}` for a Dependency Coordinate, `${secret:…}` for a secret ([0027](0027-secret-reference-join-key.md)), and `${exposure:…}` for a hostname -another Service authored ([0018](0018-exposure-by-audience.md)). Writing a +another Application authored ([0018](0018-exposure-by-audience.md)). Writing a derived value as a literal -is a build error; overriding a derived value uses the Workload's declared +is a build error; overriding a derived value uses the Process's declared override mechanism ([0031](0031-derived-overrides-with-reason.md)), never the env -file. The old configuration ADR scoped these files per Service while the spec and +file. The old configuration ADR scoped these files per Application while the spec and the worked examples (`knowledge-api.base.env`, `knowledge-ingest-worker.base.env`) -were per Workload; the review caught the drift (finding X4) and this sentence -closes it, matching the per-Workload identity of -[0024](0024-identity-per-workload.md). +were per Process; the review caught the drift (finding X4) and this sentence +closes it, matching the per-Process identity of +[0024](0024-identity-per-process.md). ## Rests on -Every non-secret value a Workload's environment needs is either service-owned (a +Every non-secret value a Process's environment needs is either application-owned (a literal the author knows) or a total function of the Intent and pinned inputs -([0005](0005-derivation-is-total.md)). False if: a Workload requires an +([0005](0005-derivation-is-total.md)). False if: a Process requires an environment variable that is neither author-known nor producible by any `${dependency:…}`, `${secret:…}` or `${exposure:…}` source, forcing a -hand-maintained copy of a derived value. Settled by: rendering the three service repositories' Workloads +hand-maintained copy of a derived value. Settled by: rendering the three project repositories' Processes and diffing each rendered environment against `knowledge-api`'s roughly thirty hand-written live variables; any live variable no literal or placeholder can reproduce falsifies the claim. ## Why -Configuration was nominally declared and actually hand-written. All three service +Configuration was nominally declared and actually hand-written. All three application repositories ship a `platform/production.env` containing nothing but comments -(*"Non-secret production environment values… Rendered into the workload fragment +(*"Non-secret production environment values… Rendered into the process fragment by deploy-config-schema"*) while `knowledge-api`'s live manifest hand-writes roughly thirty environment variables. Those thirty fall into three classes with three rightful owners: **app knobs** (`SPRING_PROFILES_ACTIVE`, -`KNOWLEDGE_MODE=lite`, uncontended, service-owned literals), **dependency +`KNOWLEDGE_MODE=lite`, uncontended, application-owned literals), **dependency coordinates** (`DB_HOST`, `DB_PORT`, `RABBITMQ_HOST`, entirely derivable from `dependsOn`), and **runtime boilerplate** (ten `OTEL_*` variables byte-identical -across `auth-api`, `agents-api` and `knowledge-api` except `OTEL_SERVICE_NAME`; +across `auth-api`, `agents-api` and `knowledge-api` except `OTEL_APPLICATION_NAME`; `knowledge-ingest-worker`, being Python, carries a different but equally fixed set, two Runtime Profiles, one derived value, sixty duplicated lines). @@ -83,14 +88,14 @@ env file. | option | cost if taken | why rejected | |---|---|---| -| Per-Service env files (the old ADRs' scoping) | Workloads share an environment they do not have (`knowledge-api` and `knowledge-ingest-worker` overlap on RabbitMQ coordinates and nothing else) and the Workload-level joins catching dead grants and unauthorised secret references lose their subject | Finding X4: spec and examples were already per Workload; the joins are the only checks between a `secrets` list and an unauthorised read | -| Typed source-declaring map in `service.yml` | A new schema for what dotenv already expresses; a format the estate has zero instances of | The estate's only real config-bearing `.env` file is already dotenv | +| Per-Application env files (the old ADRs' scoping) | Processes share an environment they do not have (`knowledge-api` and `knowledge-ingest-worker` overlap on RabbitMQ coordinates and nothing else) and the Process-level joins catching dead grants and unauthorised secret references lose their subject | Finding X4: spec and examples were already per Process; the joins are the only checks between a `secrets` list and an unauthorised read | +| Typed source-declaring map in `application.yml` | A new schema for what dotenv already expresses; a format the estate has zero instances of | The estate's only real config-bearing `.env` file is already dotenv | | Defaulted-but-overridable derived values | Every override must be audited against staleness by hand | A permitted override is indistinguishable from a stale copy | | General template language in env files | Configuration becomes a program; values stop being statically derivable and diffable | Placeholders are named-source references, nothing else | ## Reversibility -Undo cost today: rewrite a handful of env files across three service repositories +Undo cost today: rewrite a handful of env files across three project repositories plus `spec/v1/examples/`, and swap the renderer's dotenv parsing, hours, blast radius confined to layer-1 authoring; the rendered artifact does not change shape. Becomes irreversible once: blueprint packs distribute the format and participant @@ -99,15 +104,15 @@ coordinated migration across every participant. ## Consequences -- Sixty duplicated OTEL lines leave the service repositories; runtime boilerplate +- Sixty duplicated OTEL lines leave the project repositories; runtime boilerplate derives from centrally maintained Runtime Profiles: paid by the platform owner. - An effective value takes two files to determine (`base.env` plus overlay); the counterfactual is `stalwart-provisioner`'s byte-identical pair: paid by whoever debugs a value on-call. -- Coordinates shared between sibling Workloads are written once per Workload, not - once per Service; placeholders cannot go stale: paid by service authors. +- Coordinates shared between sibling Processes are written once per Process, not + once per Application; placeholders cannot go stale: paid by application authors. - Hand-written derived literals must be deleted before a repository's first - render succeeds: paid by service owners at migration time. + render succeeds: paid by application owners at migration time. - The renderer partitions keys by destination: literals become plain env entries, `${secret:…}` keys follow [0026](0026-delivery-env-file-self.md); the author never partitions: paid by the toolkit maintainer. diff --git a/docs/adr/model/0012-assets-not-code.md b/docs/adr/model/0012-assets-not-code.md index 3e0e1ab..0984f10 100644 --- a/docs/adr/model/0012-assets-not-code.md +++ b/docs/adr/model/0012-assets-not-code.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#assets +normative: spec/v1/10-project-intent.md#assets rests-on: ["0005"] --- # File-shaped configuration is an Asset; code is not configuration +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on [0005](0005-derivation-is-total.md), applied to files: every file-shaped @@ -31,14 +36,14 @@ derived catalogs**, which are Deliverables rather than configuration: `gatus-endpoints` (41 derived references in 288 lines), `platform-edge-route-catalog` (30/163), `platform-edge-catalog` (28/146), `grafana-datasources` (6/104), and `postgres-init-script` (18/98, it creates -one database and user per consuming service, which the dependency graph +one database and user per consuming application, which the dependency graph already knows). **Seven mixed files**, a large static body threaded with a few derived values: `rabbitmq.conf` most starkly, with exactly one derived line out of twenty-four: `auth_oauth2.issuer = https://auth.jorisjonkers.dev`, a -hostname belonging to another service. The Asset covers the first and third +hostname belonging to another application. The Asset covers the first and third classes: a declarative settings file in the application's own format, with optional substitution of named placeholders, the same restricted mechanism -env files use ([0011](0011-configuration-env-files-per-workload.md)), never a +env files use ([0011](0011-configuration-env-files-per-process.md)), never a template language. The second class leaves configuration entirely and renders as Deliverables. @@ -48,7 +53,7 @@ shell, `n8n-hooks` is 499 lines of JavaScript. That is first-party code with no image, no tests and no version, and it belongs in an image. The boundary is mechanical, not a judgement: an Asset may not be executable and must be a declarative settings file in the consuming application's own format -(`spec/v1/10-service-intent.md:477` makes an executable Asset a build error). +(`spec/v1/10-project-intent.md:477` makes an executable Asset a build error). `postgres-init-script`'s reliance on `/run/secrets/` (a Docker Compose convention that does not exist in Kubernetes) is a sign of how long code-shaped ConfigMaps go unexamined. @@ -57,16 +62,16 @@ code-shaped ConfigMaps go unexamined. | option | cost if taken | why rejected | |---|---|---| -| Bake the fixed files into images | A derived image, build pipeline, registry entry and Renovate rule per service; a rebuild on every upstream bump; and, because `rabbitmq.conf` carries a derived hostname, a route change becomes an image rebuild | Every one of those services runs a third-party image (`pgvector/pgvector:pg17`, `rabbitmq:4.2-management-alpine`, `twinproduction/gatus`, `stalwartlabs/stalwart`, `couchdb`); only ten images in the estate are first-party, and none are these | +| Bake the fixed files into images | A derived image, build pipeline, registry entry and Renovate rule per application; a rebuild on every upstream bump; and, because `rabbitmq.conf` carries a derived hostname, a route change becomes an image rebuild | Every one of those applications runs a third-party image (`pgvector/pgvector:pg17`, `rabbitmq:4.2-management-alpine`, `twinproduction/gatus`, `stalwartlabs/stalwart`, `couchdb`); only ten images in the estate are first-party, and none are these | | Admit executable Assets (scripts in ConfigMaps) | `hermes-bootstrap` (221 lines of shell) and `n8n-hooks` (499 lines of JavaScript) stay unversioned, untested first-party code invisible to CI and Renovate | Code without an image, tests or a version is the defect, not a convenience | | A general template language for Assets | Conditionals and arithmetic make an Asset a program the platform cannot validate, and every file format grows a second syntax | Substitution stays named placeholders with declared sources, shared with env files; nothing else | ## Reversibility -Undo cost today: `assets` is a short per-Workload list in the Service Intent; +Undo cost today: `assets` is a short per-Process list in the Project Intent; dropping the boundary means editing the Assets section of chapter 10 and the -intent files of the six-plus-seven services carrying fixed and mixed files, -hours, blast radius one spec section and those Service repositories. Becomes +intent files of the six-plus-seven applications carrying fixed and mixed files, +hours, blast radius one spec section and those Project repositories. Becomes irreversible once: the code-shaped ConfigMaps are deleted in favour of built first-party images, resurrecting script-in-ConfigMap then means extracting code back out of images and re-creating exactly the unversioned state this @@ -76,14 +81,14 @@ decision removes. - The six fixed files and the static bodies of the seven mixed files are authored as Assets, derived values as named placeholders, paid by the - owning Service repositories, once each. + owning Project repositories, once each. - The five derived catalogs stop being hand-maintained configuration and are rendered as Deliverables, paid by the platform's renderer work. - `hermes-bootstrap`, `n8n-hooks` and their `alpine:3.21` hosts need first-party images before v1 can render the current cluster, paid by the owners of `hermes`, `garage` and `n8n`. - Assets are unvalidated by the platform: a malformed `postgresql.conf` - renders successfully and fails at runtime, paid by the service owner. + renders successfully and fails at runtime, paid by the application owner. - Substitution stays one restricted mechanism in two places (env files and Assets); anyone needing a conditional must build an image instead, paid by - the service owner who wanted the shortcut. + the application owner who wanted the shortcut. diff --git a/docs/adr/model/0013-blueprint-packs-pinned-checkout.md b/docs/adr/model/0013-blueprint-packs-pinned-checkout.md index 7cae2b7..7ce08bf 100644 --- a/docs/adr/model/0013-blueprint-packs-pinned-checkout.md +++ b/docs/adr/model/0013-blueprint-packs-pinned-checkout.md @@ -9,10 +9,15 @@ rests-on: ["0001"] # Blueprint packs arrive by pinned checkout, not a registry +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Superseded by [0096](0096-the-foundation-is-declared.md) on 2026-09-08.** > This decision settled *how* packs arrive. The direction is now that packs do -> not exist: the foundation they delivered is declared as Services of the -> platform domains and rendered, and the CRDs among them are the bootstrap set +> not exist: the foundation they delivered is declared as Applications of the +> platform projects and rendered, and the CRDs among them are the bootstrap set > ([chapter 14](../../../spec/v1/14-platform-intent.md#the-bootstrap-set)). The > evidence below, that every consumer already checks out `flux-modules` by ref, > stays true and stops mattering, because nothing reads the checkout. @@ -50,14 +55,14 @@ already performs. A pinned checkout also works offline once it exists in CI, and the consumer controls the ref, which keeps rendering deterministic. This deliberately diverges from [0037](0037-composition-oci-fragments.md), -where domain declarations compose from published OCI fragments. Packs are +where project declarations compose from published OCI fragments. Packs are exempt from that route for three reasons. They are already consumed by ref, so the checkout adds no step a consumer does not run today, while OCI would add one. The registry-auth friction is evidenced for `@jorisjonkers-dev` packages, whereas fragment publication rides infrastructure composition requires anyway. And packs are class-B foundation material delivered by Flux ([0048](../deferred/0048-class-b-pinning.md)), consumed whole at render time by two -adapters, they are not domain declarations, join no composition union, carry +adapters, they are not project declarations, join no composition union, carry no lock digest, and hold no participants-list row. The exemption is a material-class boundary, not a contradiction of the composition decision. @@ -68,7 +73,7 @@ material-class boundary, not a contradiction of the composition decision. | Publish `packs/**` as an npm or OCI artifact | Registry credentials in every consumer, a resolver in the toolkit, cache and offline handling | Auth friction is evidenced for `@jorisjonkers-dev` packages; adds a second consumption path beside the ref checkout every consumer already runs | | Bundle a pinned pack snapshot inside this package | Every pack change requires a `deploy-config-schema` release; the bundled snapshot skews against the tag Flux delivers | Couples two release cadences and hides the effective pack version from the consumer | | Implicit default checkout path | CI behavior depends on developer workstation layout | Machine-specific defaults break reproducibility; the explicit root is the whole point | -| Route packs through the [0037](0037-composition-oci-fragments.md) fragment pipeline | Digests, lock entries and a participants-list row for material that is never composed | Packs are foundation input to two adapters, not domain declarations; composition's invariants do not apply to them | +| Route packs through the [0037](0037-composition-oci-fragments.md) fragment pipeline | Digests, lock entries and a participants-list row for material that is never composed | Packs are foundation input to two adapters, not project declarations; composition's invariants do not apply to them | ## Reversibility diff --git a/docs/adr/model/0014-probes-are-siblings.md b/docs/adr/model/0014-probes-are-siblings.md index a33212c..ba8f1fc 100644 --- a/docs/adr/model/0014-probes-are-siblings.md +++ b/docs/adr/model/0014-probes-are-siblings.md @@ -3,18 +3,23 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#probes +normative: spec/v1/10-project-intent.md#probes rests-on: ["0005"] --- # Probes are sibling declarations, each carrying its own path +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on A liveness probe pointed at a readiness endpoint restarts pods during dependency outages. False if: a pod whose readiness handler reports unready because a dependency is down survives its liveness `failureThreshold` without -a restart. Settled by: deploy a workload whose single health endpoint checks a +a restart. Settled by: deploy a process whose single health endpoint checks a dependency, take the dependency down for longer than the derived liveness window, and read `kubectl get pod -o jsonpath='{.status.containerStatuses[0].restartCount}'`. @@ -22,24 +27,24 @@ window, and read ## Why `readiness` and `liveness` are siblings: each carries its own `path` and -`port`, or `tcp` and a port for a service with no HTTP surface, and neither -falls back to the other. The endpoints are the part only the owning Service +`port`, or `tcp` and a port for an application with no HTTP surface, and neither +falls back to the other. The endpoints are the part only the owning Application knows ([0005](0005-derivation-is-total.md)); timings, thresholds and deadlines stay derived ([0030](0030-runtime-mechanics-derived.md)). The v2 model instead -made liveness a fallback (`src/adapters/kubernetes-workload-fragment.ts:166` +made liveness a fallback (`src/adapters/kubernetes-process-fragment.ts:166` renders `livenessProbe: probe(health.livenessPath ?? health.path)`), and two -live workloads rely on it: `app-ui` declares only `/`, `agents-login` only +live processes rely on it: `app-ui` declares only `/`, `agents-login` only `/healthz`. For both, liveness silently probes the readiness endpoint. That fallback made a specific failure the default. Readiness means *can I serve traffic*; liveness means *is my process wedged*. When liveness probes the readiness endpoint, a dependency outage turns readiness red, which fails -liveness, which restarts the pod, converting a degraded service into a +liveness, which restarts the pod, converting a degraded application into a crash-loop. Requiring both declarations does not prevent an author pointing them at the same endpoint; it prevents them doing so without noticing. `tcp` is not optional decoration: `postgres` probes with `tcpSocket` on port -`db` for both readiness and liveness, and the other data services do the same. +`db` for both readiness and liveness, and the other data applications do the same. An HTTP-only vocabulary could not express the estate's data tier. Absence must also be sayable: `knowledge-ingest-worker` has no ports and nothing to probe, and declaring `probes: none` distinguishes that fact from an oversight. @@ -48,35 +53,35 @@ probe, and declaring `probes: none` distinguishes that fact from an oversight. | option | cost if taken | why rejected | |---|---|---| -| Keep the v2 fallback (`livenessPath` optional, defaults to `path`) | every service taking the default inherits the dependency-outage crash-loop; `app-ui` and `agents-login` already do | makes the worst wiring the path of least resistance | +| Keep the v2 fallback (`livenessPath` optional, defaults to `path`) | every application taking the default inherits the dependency-outage crash-loop; `app-ui` and `agents-login` already do | makes the worst wiring the path of least resistance | | Derive liveness from readiness with softer thresholds | same endpoint either way. Restarts are delayed, not avoided, and tuning hides the design error | mitigates a failure the model should not produce | | Omit liveness unless authored | a wedged process (deadlocked JVM, stuck event loop) is never restarted: the one condition liveness exists for | trades a loud failure for a silent one | -| HTTP-only probes, no `tcp` | `postgres` and the rest of the data tier need fake HTTP sidecars or lose probes entirely | the estate's stateful services are TCP-native today | +| HTTP-only probes, no `tcp` | `postgres` and the rest of the data tier need fake HTTP sidecars or lose probes entirely | the estate's stateful applications are TCP-native today | | Absence by omission instead of `probes: none` | a forgotten probe block is indistinguishable from a deliberate one | reviewability is the point of declared intent | ## Reversibility -Undo cost today: restore the fallback in the probe derivation (the workload +Undo cost today: restore the fallback in the probe derivation (the process adapter and the intent schema, two files, under a day) and relax validation; Intents that already author both paths keep working unchanged. Blast radius is -one rolling restart per workload whose rendered probes change. -Becomes irreversible once: workloads ship liveness endpoints distinct from +one rolling restart per process whose rendered probes change. +Becomes irreversible once: processes ship liveness endpoints distinct from their readiness paths. Reinstating a fallback would then silently repoint -liveness for every service that omits it, and no diff would show which -services regressed. +liveness for every application that omits it, and no diff would show which +applications regressed. ## Consequences -- Every HTTP workload authors two endpoint declarations even when they are - deliberately identical, paid by service authors. +- Every HTTP process authors two endpoint declarations even when they are + deliberately identical, paid by application authors. - `app-ui` and `agents-login` must each decide what their liveness endpoint actually is instead of inheriting the readiness path, paid by their owners. -- A dependency outage degrades a service to unready instead of restarting it, +- A dependency outage degrades an application to unready instead of restarting it, provided the authored liveness endpoint checks process health only, paid by - service authors, in endpoint discipline. + application authors, in endpoint discipline. - Port-less workers add one `probes: none` line so silence is never ambiguous, paid by their authors. -- Adapters render `httpGet` and `tcpSocket` variants and refuse a Workload +- Adapters render `httpGet` and `tcpSocket` variants and refuse a Process with ports but no probe declaration, paid by adapter maintainers. - Pointing both probes at one endpoint remains expressible; the model makes it - visible, not impossible, paid by the service that chooses it. + visible, not impossible, paid by the application that chooses it. diff --git a/docs/adr/model/0015-durability-class-per-volume.md b/docs/adr/model/0015-durability-class-per-volume.md index f341c21..6c3b45d 100644 --- a/docs/adr/model/0015-durability-class-per-volume.md +++ b/docs/adr/model/0015-durability-class-per-volume.md @@ -3,19 +3,24 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#storage-and-durability +normative: spec/v1/10-project-intent.md#storage-and-durability rests-on: ["0005"] --- # Every volume declares a Durability Class +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on What a volume's data is worth cannot be inferred from anything the platform can observe about it. False if: a rule over cluster-observable facts alone (size, storage class, access mode, mount path, `stateful`) reproduces the owners' classification of every PVC in the estate. Settled by: classify all fourteen -PVCs from `kubectl get pvc -A -o json` and the rendered Workloads only, then +PVCs from `kubectl get pvc -A -o json` and the rendered Processes only, then diff against the owners' declarations; one mismatch on a volume an owner calls `irreplaceable` settles it. @@ -26,7 +31,7 @@ There is no platform-level durability here to fall back on. Storage is [workspace ADR-0011](https://github.com/JorisJonkers-dev/workspace/blob/main/docs/decisions/ADR-0011-backup-coverage-gaps.md) records that *"PVC-level snapshots are impossible here: no VolumeSnapshot CRDs, and `local-path` has no CSI snapshot support. The job that pretended otherwise -was deleted."* Two consequences follow: a volume pins its workload to one node +was deleted."* Two consequences follow: a volume pins its process to one node permanently, which is why a state-move-plan exists at all, and retention can only be an application-level backup job. The only durability a volume gets is what someone asks for by name. @@ -36,11 +41,11 @@ validated for `minimumDays >= 90` and `acknowledged: true` (`src/deployment/v2-model.ts:182-187`), appears in the readiness scorecard as `rollback_retention_acknowledged` (`schemas/readiness-scorecard.schema.json:13`), is documented in three `PLATFORM.md` files as failing *"never"*, and is read by -no renderer or adapter. Every service declares the identical `{minimumDays: 90, +no renderer or adapter. Every application declares the identical `{minimumDays: 90, acknowledged: true}`, and nothing states whether it retains images or data, a ninety-day rollback guarantee a snapshot-less cluster with fixed-filename backups cannot provide. A Durability Class per volume (`reconstructible`, `recoverable`, -`irreplaceable`) replaces it, naming the one fact only the owning Service knows +`irreplaceable`) replaces it, naming the one fact only the owning Application knows ([0005](0005-derivation-is-total.md)) and leaving schedule, sweep and destination derived. [Workspace ADR-0011](https://github.com/JorisJonkers-dev/workspace/blob/main/docs/decisions/ADR-0011-backup-coverage-gaps.md) already used the vocabulary in prose (*"Valkey is deliberately unbacked as reconstructible cache"*) and the option it @@ -61,7 +66,7 @@ rehearsed before the first production apply. The worked example: | option | cost if taken | why rejected | |---|---|---| -| Keep `rollbackTargetRetention` | zero migration; every Service keeps one identical block and the scorecard keeps passing | it asserts a ninety-day rollback this cluster cannot perform, and out-degree zero means no object ever reflects it | +| Keep `rollbackTargetRetention` | zero migration; every Application keeps one identical block and the scorecard keeps passing | it asserts a ninety-day rollback this cluster cannot perform, and out-degree zero means no object ever reflects it | | Derive the class from observable facts | one classifier plus a growing exception list; a wrong guess is silent | `valkey` and `knowledge-vault-clone` are indistinguishable to the platform (both RWO `local-path` PVCs on the one node) and one is cache, one is irreplaceable | | Two classes, backed / unbacked | one fewer judgement per volume; a simpler renderer | collapses "a nightly job suffices" into "needs an off-cluster copy and approval on relocation", leaving [0057](0057-datastore-and-restore.md)'s rehearsal gate no input to fire on | | A per-volume RPO in hours | owners state numbers the substrate cannot honour: the daily node backup fixes RPO at 24 h | a number nothing enforces is the inert attestation again, with a decimal point | @@ -69,7 +74,7 @@ rehearsed before the first production apply. The worked example: ## Reversibility Undo cost today: the field is authored and read by nothing, so removal is a -schema change plus the declarations in the example Services, under an hour, +schema change plus the declarations in the example Applications, under an hour, zero diff in any rendered object. Becomes irreversible once the [0043](../deferred/0043-delete-authority-durability-gate.md) refusal runs against production: the class is then the only signal separating a cache PVC from the knowledge vault @@ -78,7 +83,7 @@ at delete time, and withdrawing it re-arms the delete path it disarmed. ## Consequences - Every volume carries one more authored line, and the `recoverable` / - `irreplaceable` boundary is a judgement with no safe default: paid by service + `irreplaceable` boundary is a judgement with no safe default: paid by application owners. - Until a backup renderer and retention sweep exist, the field is documentation with a delete gate attached: paid by adapter maintainers. @@ -86,5 +91,5 @@ at delete time, and withdrawing it re-arms the delete path it disarmed. restore is rehearsed: paid by the platform owner, in schedule. - The error is asymmetric: over-declaring costs an off-cluster copy and a stalled delete, under-declaring loses the data silently: paid by whoever declares. -- Every Service drops its `rollbackTargetRetention` block, and the scorecard - entry retires with it: paid by service authors, once. +- Every Application drops its `rollbackTargetRetention` block, and the scorecard + entry retires with it: paid by application authors, once. diff --git a/docs/adr/model/0016-pod-hardening.md b/docs/adr/model/0016-pod-hardening.md index 5e17ff8..f84eeec 100644 --- a/docs/adr/model/0016-pod-hardening.md +++ b/docs/adr/model/0016-pod-hardening.md @@ -4,13 +4,18 @@ status: proposed claim: open owner: joris date: 2026-09-07 -normative: spec/v1/10-service-intent.md#pod-hardening +normative: spec/v1/10-project-intent.md#pod-hardening rests-on: ["0005"] --- # Pod hardening is platform policy, and has no exception surface -> **Amended 2026-09-10.** The exception vocabulary is **deleted**. A Workload +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +> **Amended 2026-09-10.** The exception vocabulary is **deleted**. A Process > authors no hardening at all: it declares the paths it must write, and an image > that cannot meet `restricted` is `E_HARDENING_UNMET`. A per-control relaxation > carried with a reason is an override under another name, and it outlives the @@ -19,21 +24,21 @@ rests-on: ["0005"] > model, kept here only because this record called the resulting list a > deliverable. The estate settled the argument: after > [0092](0092-writable-paths-are-declared.md) and -> [0082](0082-images-lock-carries-uid-and-gid.md), **no Workload in the worked +> [0082](0082-images-lock-carries-uid-and-gid.md), **no Process in the worked > set declares an exception**. The inventory of what an estate cannot harden > belongs in a Bidirectional Ledger with an owner -> ([0055](0055-bidirectional-ledgers.md)), not in the DSL every Service author +> ([0055](0055-bidirectional-ledgers.md)), not in the DSL every Application author > writes. > **Amended 2026-09-09.** The *exceptions* are layer-1 vocabulary; the **class is > not**. `restricted` is the only value that exists, so a field carrying it on -> every Workload restates one estate-wide decision thirty times, which is what +> every Process restates one estate-wide decision thirty times, which is what > [0004](0004-contention-decides-authority.md)'s contention test puts in the > Platform document, and what > [0089](0089-replicas-derived-no-minavailable.md) deleted `minAvailable` for. > The posture moves to > [chapter 14](../../../spec/v1/14-platform-intent.md#hardening-policy) as one -> line; a Workload and a sidecar author only the controls they relax. Nothing +> line; a Process and a sidecar author only the controls they relax. Nothing > else in this record changed at the time; the 2026-09-10 amendment above > supersedes the exception half of it. @@ -54,7 +59,7 @@ The vocabulary does not exist and neither renderer emits the fields. src/ schemas/` returns **0 hits** (verified 2026-08-31; the grep spans *both* renderer generations, so choosing one under [0052](0052-registered-adapters-are-v1.md) does not rescue it). -`src/deployment/render/workloads.ts:130` builds a container from name, image, +`src/deployment/render/processes.ts:130` builds a container from name, image, pullPolicy, ports, command, args, env, envFrom, volumeMounts, probes and resources, and stops. Rendered pods run as their image's UID, with a writable root filesystem and the default capability set. @@ -63,16 +68,16 @@ The security half is the half that is worse to fix late, which is why the review graded it a Blocker. Hardening is a layer-1 field, and layer 1 is hand-authored across about ten repositories ([0001](0001-estate-scale-and-ownership.md)), so the retrofit is a pull request -per Service (about thirty) plus a coordinated image rebuild: the only finding +per Application (about thirty) plus a coordinated image rebuild: the only finding in the review that gets strictly more expensive every week (`review/CONSOLIDATED.md` B6). Done later it is the same thirty pull requests -against running services, each taking a restart. +against running applications, each taking a restart. Hardening becomes vocabulary now, before the first production apply. The class -defaults to `restricted`; a Workload that cannot meet it declares the specific +defaults to `restricted`; a Process that cannot meet it declares the specific exception with a reason, in the shape used for derived-value overrides ([0031](0031-derived-overrides-with-reason.md)). Capacity is a separate question -with a separate answer (raw per-Workload quantities matched against node +with a separate answer (raw per-Process quantities matched against node allocatable ([0061](0061-placement-is-hard-dimensions.md))) and this record no longer carries it. The claim stays open because the estate's images have not been run against the default; the vocabulary's point is that exceptions become @@ -81,9 +86,9 @@ declared and counted, not silent. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Accept root-by-default as an owned risk (the review's own stated alternative) | Nothing today; the same ~30 pull requests and image rebuild later, but against running services, each taking a restart, and after the incident that prompts it | The cost does not stay flat, it is the one finding that rises weekly, and on a single kernel shared by the k3s server, the datastore and the in-cluster deploy runner, root-by-default is the whole isolation story | -| Enforce from the platform only, via Pod Security Admission or an admission mutation | An admission controller on a single-node cluster; PSA can reject but never fill in, so a non-conforming pod fails at apply with no exception path a Service can author | A mutating default is a value the render cannot see, contradicting [0005](0005-derivation-is-total.md); rejection with no declared exception is an outage found at apply time | -| One estate-wide hardening posture with no per-Workload exceptions | The first image that cannot run non-root relaxes the default for all ~30 Services at once | Chosen, with the refusal as the answer instead: the class never relaxes, the image is refused, and the ledger names what the estate cannot harden | +| Accept root-by-default as an owned risk (the review's own stated alternative) | Nothing today; the same ~30 pull requests and image rebuild later, but against running applications, each taking a restart, and after the incident that prompts it | The cost does not stay flat, it is the one finding that rises weekly, and on a single kernel shared by the k3s server, the datastore and the in-cluster deploy runner, root-by-default is the whole isolation story | +| Enforce from the platform only, via Pod Security Admission or an admission mutation | An admission controller on a single-node cluster; PSA can reject but never fill in, so a non-conforming pod fails at apply with no exception path an Application can author | A mutating default is a value the render cannot see, contradicting [0005](0005-derivation-is-total.md); rejection with no declared exception is an outage found at apply time | +| One estate-wide hardening posture with no per-Process exceptions | The first image that cannot run non-root relaxes the default for all ~30 Applications at once | Chosen, with the refusal as the answer instead: the class never relaxes, the image is refused, and the ledger names what the estate cannot harden | ## Reversibility Undo cost today: an exceptions list in the layer-1 schema, one line in the @@ -96,7 +101,7 @@ UID and volumes chowned to match, ownership on the PersistentVolumes then encodes it, and reverting is a data migration. ## Consequences -- Every Workload across the estate's ~30 Services gains a required hardening +- Every Process across the estate's ~30 Applications gains a required hardening field before the first production apply, about thirty pull requests: paid by the one maintainer. - Images that cannot run non-root are rebuilt or excepted in writing, and that diff --git a/docs/adr/model/0017-placement-by-capability.md b/docs/adr/model/0017-placement-by-capability.md index 125e812..3d8d987 100644 --- a/docs/adr/model/0017-placement-by-capability.md +++ b/docs/adr/model/0017-placement-by-capability.md @@ -3,12 +3,17 @@ tier: decision superseded-by: 0061 claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#placement +normative: spec/v1/10-project-intent.md#placement rests-on: ["0005"] --- # Placement is declared as capabilities, never labels +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + Superseded by [0061](0061-placement-is-hard-dimensions.md): placement is now a set of hard dimensions matched against node allocatable, so capabilities are one optional dimension among several and the soft `prefers` half this record defends @@ -42,53 +47,53 @@ Both shapes are needed. The same document records the success case: *"Architecture is a preference, not a requirement… as a weighted `nodeAffinity`, so they schedule on Frankfurt rather than sitting Pending when the Pis are full or down."* Only-hard turns a capacity dip into an outage; only-soft cannot -say a workload needs the ingress node. +say a process needs the ingress node. -Labels are also not the Service's to name. +Labels are also not the Application's to name. `nix-config/generated/node-contract.yml` emits 110 labels for 7 nodes, 55 under `platform.jorisjonkers.dev/*` and the same 55 under `personal-stack/*`, named after an archived repository that rejects pushes. Authored as selectors, -retiring that prefix is an edit in every service repository; as capabilities it +retiring that prefix is an edit in every project repository; as capabilities it touches none. Losing the capability on the way out costs as much: `src/adapters/flux-utils.ts:957-958` finds the host advertising the requested capability and returns a selector on that host's *site*, discarding it, so `test/fixtures/kubernetes-parity/postgres-derived.yaml:13` renders -`personal-stack/site: frankfurt` and a workload requiring `public-ingress` lands +`personal-stack/site: frankfurt` and a process requiring `public-ingress` lands on any node in the site. Pinning already implied stays derived instead: a -`local-path` volume ties its Workload to the node holding the PV and the +`local-path` volume ties its Process to the node holding the PV and the resolver states that ([0015](0015-durability-class-per-volume.md)). ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Node label selectors in Service Intent (the v2 shape) | retiring `personal-stack/*` (55 of the 110 labels on 7 nodes) becomes an edit in ~30 service repositories instead of one node declaration | placement would be coupled to a naming decision no service owner takes part in | +| Node label selectors in Project Intent (the v2 shape) | retiring `personal-stack/*` (55 of the 110 labels on 7 nodes) becomes an edit in ~30 project repositories instead of one node declaration | placement would be coupled to a naming decision no application owner takes part in | | Capabilities, but an unsatisfiable `prefers` is a warning | reproduces the `gtx960m` outcome with a log line nobody reads | the silence is the failure being closed | -| Hard `requires` only, no preferences | arm64 workloads sit `Pending` when the Pis are full or down instead of scheduling on Frankfurt, the case the divergence document records as working | availability traded away for a distinction the model can afford | -| Declare node pinning for `local-path` volumes explicitly | every stateful Service restates a constraint the resolver already derives from the volume, and the two disagree the first time a PV moves | derivable from declared intent ([0005](0005-derivation-is-total.md)) | +| Hard `requires` only, no preferences | arm64 processes sit `Pending` when the Pis are full or down instead of scheduling on Frankfurt, the case the divergence document records as working | availability traded away for a distinction the model can afford | +| Declare node pinning for `local-path` volumes explicitly | every stateful Application restates a constraint the resolver already derives from the volume, and the two disagree the first time a PV moves | derivable from declared intent ([0005](0005-derivation-is-total.md)) | ## Reversibility Undo cost today: one intent-schema block plus the selector/affinity derivation (`src/adapters/kubernetes.ts:476` already emits `/capability-`). -Under a day, then re-authoring placement in every Service that declares it; -blast radius is a reschedule per workload whose `nodeSelector` changes. Becomes +Under a day, then re-authoring placement in every Application that declares it; +blast radius is a reschedule per process whose `nodeSelector` changes. Becomes irreversible once: the hand-authored node inventory is deleted for the generated contract ([0056](0056-node-facts-single-source.md)): no hand-maintained label list then exists to write a selector against. ## Consequences -- A capability must exist on some node before a Service may require or prefer +- A capability must exist on some node before an Application may require or prefer it; adding one is a node declaration, paid by the platform owner. - Composition fails the build when `requires` or `prefers` names a capability - no node advertises, paid by service authors, who lose the silent no-op. + no node advertises, paid by application authors, who lose the silent no-op. - Adapters must carry the capability into the selector key instead of collapsing it to a site match, and the renderer's two label schemes become one, paid by adapter maintainers. -- Volume pinning is stated once by the resolver, so stateful Services declare +- Volume pinning is stated once by the resolver, so stateful Applications declare durability and nothing about nodes, paid by resolver maintainers. -- Retiring the archived prefix touches no service repository, but does mean +- Retiring the archived prefix touches no project repository, but does mean relabelling live nodes through the generated contract, since a hand-applied label drifts back on the next reconcile, paid by the platform owner. - "Just run it on that machine" is no longer expressible without naming a diff --git a/docs/adr/model/0018-exposure-by-audience.md b/docs/adr/model/0018-exposure-by-audience.md index acb41cb..f1effa1 100644 --- a/docs/adr/model/0018-exposure-by-audience.md +++ b/docs/adr/model/0018-exposure-by-audience.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#exposure +normative: spec/v1/10-project-intent.md#exposure rests-on: ["0004"] --- # Exposure is declared by Audience, in one closed vocabulary +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** "entryPoint, TLS" below are Traefik's words for what > a tier now declares as `listener` and `certificates` in the Platform document > ([chapter 14](../../../spec/v1/14-platform-intent.md#tiers), @@ -17,12 +22,12 @@ rests-on: ["0004"] > fragment producers named in the evidence are deleted > ([0098](0098-one-publication-path.md)). -A **Service** declares exposure: a hostname, authored as the full FQDN, carrying -an **Audience**, with routes to the Workloads behind it and per-path audiences +An **Application** declares exposure: a hostname, authored as the full FQDN, carrying +an **Audience**, with routes to the Processes behind it and per-path audiences where they differ. The host is written once and referenced by placeholder thereafter; forward-auth, the security-headers baseline, entryPoint, TLS, the middleware chain, the reachability entry and the health endpoint all derive from -the audience and the tier. `provides` stays on the Workload: a port is a +the audience and the tier. `provides` stays on the Process: a port is a property of a process, a hostname is not. ## Rests on @@ -30,10 +35,10 @@ property of a process, a hostname is not. An authored host with a uniqueness check arbitrated at composition is safer than a derived one, because a duplicate fails loudly and a wrong derivation does not. False if: a derivation exists that is right for every live host. Settled by: the -live host list read against Service ids: `knowledge.jorisjonkers.dev` and +live host list read against Application ids: `knowledge.jorisjonkers.dev` and `kb.jorisjonkers.dev` both resolve; `platform-rabbitmq` serves `rabbitmq.jorisjonkers.dev`; `root`, `status`, `dashboard` and `faro` belong to -no Service at all. A derivation would be right for most and silently wrong for +no Application at all. A derivation would be right for most and silently wrong for the rest, and the wrong ones are the ones nobody checks. Uniqueness is contended and so is arbitrated by the platform, which is [0004](0004-contention-decides-authority.md) as restated: contention decides who @@ -45,31 +50,31 @@ and so is declared, and one closed vocabulary of four values (`anonymous`, False if: a routed surface needs an audience the set cannot express, or two consumers of one surface need audiences that cannot both derive from one declaration. Settled by: mapping every `route.authMode`, `auth.scope` and tier -`authModes` value onto exactly one audience, rendering all four routed services, +`authModes` value onto exactly one audience, rendering all four routed applications, and asserting the derived IngressRoutes match those served today, zero unmapped. ## Why One hostname, `kb.jorisjonkers.dev`, was declared in seven authoritative places. That evidence stands, and it is the reason the host is declared **once** (on the -Service's exposure entry) and referenced by placeholder everywhere else. Six of +Application's exposure entry) and referenced by placeholder everywhere else. Six of the seven derive from that one declaration: the reachability channel, both edge catalogs, both Traefik IngressRoutes and the Gatus endpoint, with consumers -resolving `${exposure:.#url}` instead of repeating the literal. +resolving `${exposure:.#url}` instead of repeating the literal. The two conformance tests that existed only to detect their disagreement become unnecessary, not merely green. The defect was seven authorities for one value; the fix was never derivation, it was single declaration. -Exposure sits on the Service because a hostname can front more than one Workload +Exposure sits on the Application because a hostname can front more than one Process and that case is unexpressible one level down: `auth.jorisjonkers.dev/api` routes -to `auth-api` and `/` to `auth-ui`. At the Workload level the two can only reach +to `auth-api` and `/` to `auth-ui`. At the Process level the two can only reach one host by each repeating its name. The deeper defect was that one concept carried three disjoint vocabularies: | where | values | |---|---| -| service `route.authMode` | `anonymous`, `sso`, `forward-auth` | +| application `route.authMode` | `anonymous`, `sso`, `forward-auth` | | tier `authModes` | `forward-auth`, `internal`, `lan` | | rule `auth.scope` | `anonymous`, `authenticated`, `application` | @@ -80,13 +85,13 @@ values were never comparable, the gate that should have caught this could not, and it did not fire anyway. `validateDeploymentSemantics` checks `authMode` against a tier only `if (tier && …)`: `src/deployment/v2-model.ts:199-203` takes the tier from the optional `route.expose?.tier` and skips the check when it is -absent, and three of the four routed services declare no `expose.tier` at all: +absent, and three of the four routed applications declare no `expose.tier` at all: `auth-api` (`anonymous`), `agents-api` (`sso`) and `home-portal` (`anonymous`), every one on a public `*.jorisjonkers.dev` hostname whose only tier permits `forward-auth` alone. `E_ROUTE_AUTH_MODE_NOT_IN_TIER` was implemented, had an error code, and was vacuous exactly where it mattered. -One Audience vocabulary, shared by Services and route tiers, makes that class of +One Audience vocabulary, shared by Applications and route tiers, makes that class of bug impossible: the two can no longer say the same thing in different words. Undeployed hostnames are bounded by [0019](0019-registered-unmanaged-surfaces.md). @@ -103,16 +108,16 @@ or circuit breaker exists anywhere in the estate, and none is invented here. | option | cost if taken | why rejected | |---|---|---| -| Keep the three vocabularies; make `expose.tier` required and add a mapping table between them | every routed service repository is edited (the same migration cost as this decision) plus a hand-maintained 3×7 mapping table | the table is a fourth authority free to drift from the three it joins, and the gate it repairs already existed, had an error code, and was vacuous | -| Derive `.` from a zone in the Platform Intent | no host is authored anywhere, and a zone mapping already exists in the reachability channels; right for `auth` and `knowledge` today | silently wrong for `kb`, for `rabbitmq` under Service `platform-rabbitmq`, and for every platform host belonging to no Service, and the wrong ones are the ones nobody checks. A derivation right for most is worse than none, because it is trusted | -| Author `host` on the Workload, repeated by each Workload behind it | no new nesting; the exposure block stays where it already sits | two Workloads can disagree about their own hostname, and the disagreement renders as two IngressRoutes rather than an error. One host fronting several Workloads is expressible only by repeating the string | +| Keep the three vocabularies; make `expose.tier` required and add a mapping table between them | every routed project repository is edited (the same migration cost as this decision) plus a hand-maintained 3×7 mapping table | the table is a fourth authority free to drift from the three it joins, and the gate it repairs already existed, had an error code, and was vacuous | +| Derive `.` from a zone in the Platform Intent | no host is authored anywhere, and a zone mapping already exists in the reachability channels; right for `auth` and `knowledge` today | silently wrong for `kb`, for `rabbitmq` under Application `platform-rabbitmq`, and for every platform host belonging to no Application, and the wrong ones are the ones nobody checks. A derivation right for most is worse than none, because it is trusted | +| Author `host` on the Process, repeated by each Process behind it | no new nesting; the exposure block stays where it already sits | two Processes can disagree about their own hostname, and the disagreement renders as two IngressRoutes rather than an error. One host fronting several Processes is expressible only by repeating the string | ## Reversibility Undo cost today: hours, one spec edit, the exposure block moving back to the -Workload, and four modules that read `authMode` (`src/deployment/v2-model.ts`, +Process, and four modules that read `authMode` (`src/deployment/v2-model.ts`, `src/adapters/fragment-model.ts`, and the Traefik route and edge-catalog fragment -adapters), old fields still standing alongside, four routed services affected. +adapters), old fields still standing alongside, four routed applications affected. Becomes irreversible once: those repositories drop `authMode` and `auth.scope`, consumers replace literal hostnames with `${exposure:…}` placeholders, and the edge catalogs and Gatus ConfigMap stop being authored, restoring them means @@ -122,14 +127,14 @@ them. ## Consequences - Six of seven hostname declarations stop being authored and become renders of - the one on the Service, with consumers referencing it by placeholder: paid by - service owners, who write the host once and may no longer paste it. -- `E_DUPLICATE_HOST` is now the whole of what stands between two Services + the one on the Application, with consumers referencing it by placeholder: paid by + application owners, who write the host once and may no longer paste it. +- `E_DUPLICATE_HOST` is now the whole of what stands between two Applications claiming one hostname, and it is a composition check over the composed union together with Registered Unmanaged Surfaces, not a structural guarantee. A fragment that never reaches the union is never checked: paid by whoever composes, and by anyone reading a per-repository build as proof. -- `exposure[].name` is finally defined (required, unique within the Service), +- `exposure[].name` is finally defined (required, unique within the Application), so `E_DUPLICATE_EXPOSURE_NAME` stops checking a name nothing declared, and `E_DUPLICATE_ROUTE_MATCH` catches the identical IngressRoute matches the auth example renders today: paid by reviewers, who lose two vacuous greens. @@ -137,16 +142,16 @@ them. `redirectTo`. A genuinely new edge case takes a field and its own decision record, not a provider-shaped passthrough, deliberately slower, paid by whoever meets that case first. -- `exposure` moves from the Workload to the Service while `provides` stays, so - every routed service repository re-nests one block: paid by the four routed - service owners, in the same coordinated migration. -- The hostname no longer leaves the Service repository, so publish-back +- `exposure` moves from the Process to the Application while `provides` stays, so + every routed project repository re-nests one block: paid by the four routed + application owners, in the same coordinated migration. +- The hostname no longer leaves the Project repository, so publish-back ([0033](0033-assignments-published-back.md)) is still owed for arbitrated values but is no longer how an owner learns their own host: paid by nobody; it refunds a dependency this decision used to create. - `authMode`, `auth.scope` and tier `authModes` are all replaced, and the edge catalogs plus the Gatus ConfigMap stop being authored: paid by the four routed - service owners, in one coordinated migration. + application owners, in one coordinated migration. - The health endpoint derives from the same declaration as the route, so a monitored and a served surface can no longer disagree: paid by observability authors ([0021](0021-observability-scrape-and-alert-class.md)). diff --git a/docs/adr/model/0019-registered-unmanaged-surfaces.md b/docs/adr/model/0019-registered-unmanaged-surfaces.md index 0884d3e..c741ce9 100644 --- a/docs/adr/model/0019-registered-unmanaged-surfaces.md +++ b/docs/adr/model/0019-registered-unmanaged-surfaces.md @@ -9,7 +9,12 @@ rests-on: ["0004"] # Un-deployed hostnames are Registered Unmanaged Surfaces -Service Intent covers Kubernetes workloads only. Any hostname the model does +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +Project Intent covers Kubernetes processes only. Any hostname the model does not deploy is listed as a **Registered Unmanaged Surface** carrying an owner, a reason and a review date, and composition asserts that estate reachability equals the derived set plus the registered set exactly. An unregistered @@ -21,7 +26,7 @@ A hostname must be unique estate-wide, so per [0004](0004-contention-decides-authority.md) it is contended and takes exactly one authoritative form, which for a host the model does not deploy can only be a registration, since there is no derivation to be the authority. False if: a -hostname exists that is neither derivable from a Service nor attributable to +hostname exists that is neither derivable from an Application nor attributable to one owner with a stated reason: a wildcard, a dynamically allocated name, or a host two parties both claim. Settled by: composing the full participants list and diffing `derived ∪ registered` against @@ -34,11 +39,11 @@ difference is empty and that every registered entry carries a non-empty The estate has three deployment targets, not one. `samba` exists only as a NixOS module yet owns `samba.lan.jorisjonkers.dev`; `wolf` exists in neither target and owns `wolf.jorisjonkers.dev`; `adguard` and `ollama` exist in both -Kubernetes and nix. Host-level services (`tailscale`, `media-storage`, +Kubernetes and nix. Host-level applications (`tailscale`, `media-storage`, `backup-storage`, `btrfs-backup-snapshots`) have no cluster presence at all. Modelling all of them was rejected: rendering NixOS is not writing a file but producing a build and an activation, an order of magnitude larger v1. So -Service Intent stays Kubernetes-only. +Project Intent stays Kubernetes-only. That leaves a remainder, and an unbounded remainder is how the seven-way split of `kb.jorisjonkers.dev` began: a scope boundary silent about what falls @@ -85,6 +90,6 @@ a route and its monitoring instead of raising an error. reachability set, who inherit a claim no check validates. - Review dates make registrations expire, so the register is recurring work rather than a one-time backfill, paid by each registration's named owner. -- Service Intent stays Kubernetes-only, so NixOS-only services get no probes, +- Project Intent stays Kubernetes-only, so NixOS-only applications get no probes, no policy and no grants from this model, paid by their owners, who keep two toolchains and gain only a hostname entry from this one. diff --git a/docs/adr/model/0020-dependency-edges-carry-surface.md b/docs/adr/model/0020-dependency-edges-carry-surface.md index 4a4cf02..e2ae55b 100644 --- a/docs/adr/model/0020-dependency-edges-carry-surface.md +++ b/docs/adr/model/0020-dependency-edges-carry-surface.md @@ -9,15 +9,20 @@ rests-on: ["0005"] # A dependency edge names the provider, the surface, and necessity +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -Every cross-service connection in the live estate is expressible as a triple: -provider Service Id, one surface that provider already declares, and whether the +Every cross-application connection in the live estate is expressible as a triple: +provider Application Id, one surface that provider already declares, and whether the consumer requires it. False if: a live connection needs an address or port that no provider `provides` entry can name, or fans out to more than one provider -Service under a single consumer reference. Settled by: extract every host:port +Application under a single consumer reference. Settled by: extract every host:port literal and every endpoint-shaped environment key from the first-party -workloads' env files and configuration, and match each to a `provides` entry on -the Service that owns that address, any reference matching no declared surface +processes' env files and configuration, and match each to a `provides` entry on +the Application that owns that address, any reference matching no declared surface is a counterexample. ## Why @@ -33,49 +38,49 @@ credential claims matched to provider exports carrying an endpoint: by `credential.claim === provider.name || credential.claim.endsWith('.' + provider.name)`. A dependency with no credential (`knowledge` calling `auth-api` over HTTP), therefore produces neither a policy -nor a coordinate. That value has to live somewhere, and [0011](0011-configuration-env-files-per-workload.md) +nor a coordinate. That value has to live somewhere, and [0011](0011-configuration-env-files-per-process.md) makes writing a derived value as a literal a build error, so an id-only edge leaves the no-credential dependency with no legal home at all: the consumer may not author `AUTH_API_URL`, and nothing derives it for them. Naming the surface also puts the port in one place. The provider declares its surfaces once; every consumer refers to them by name rather than restating -`5432`, and the Service Id stays the only referencable identity -([0010](0010-flat-service-identity.md)). That single declaration is then enough +`5432`, and the Application Id stays the only referencable identity +([0010](0010-flat-application-identity.md)). That single declaration is then enough for four derivations that today are four separate hand-maintained artefacts: Reconcile Unit ordering ([0032](0032-reconcile-unit-derived.md)), dependency coordinates, NetworkPolicy egress, and co-test membership ([0049](../deferred/0049-aggregator-owned-tests.md)). The estate's current state is the argument for completeness: three NetworkPolicy objects exist for roughly thirty -workloads, so the cluster is effectively open east-west, and no default-deny +processes, so the cluster is effectively open east-west, and no default-deny posture ([0035](0035-network-policy-default-deny.md)) is even expressible until the edge set describes every legal flow. Necessity is a third axis, not a restatement of the first two. `required: false` yields an allow rule but no reconcile ordering and no startup gate, so an optional dependency cannot deadlock a rollout; `required: true` (the default) buys both. Folding reachability into ordering would force every consumer that -merely talks to a Service to also block on it. +merely talks to an Application to also block on it. ## Alternatives | option | cost if taken | why rejected | |---|---|---| | `dependsOn: [auth-api]`, surface inferred from the provider's sole or primary export | Zero authoring cost; ambiguous the first time a provider declares two surfaces: `platform-postgres` already exports more than a database port | Leaves the no-credential dependency with no coordinate and no policy, the exact hole the unregistered `networkpolicy.ts` attempt already had | -| Consumer restates the port: `{service: platform-postgres, port: 5432}` | One extra literal per edge; ~30 workloads to update whenever a provider moves a port | Duplicates the provider's own declaration in every consumer, so a port change is an estate-wide edit that no tool can verify | +| Consumer restates the port: `{application: platform-postgres, port: 5432}` | One extra literal per edge; ~30 processes to update whenever a provider moves a port | Duplicates the provider's own declaration in every consumer, so a port change is an estate-wide edit that no tool can verify | | Provider enumerates its consumers (`allowedConsumers:`) | The provider repository gains a merge on every new consumer; two PRs in two repos per edge | A provider never knows its own consumers; the list is stale from the first unmerged branch, and staleness fails open | | Derive the edge set from observed traffic | Needs a flow-log pipeline the cluster does not run | Captures accident as intent; cannot tell a legal flow from a leak | ## Reversibility Undo cost today: `surface` and `required` are two fields on one list in the -Service schema plus the four derivation sites that read them; with no production +Application schema plus the four derivation sites that read them; with no production edges authored yet, removing them is a schema edit and a renderer change measured in hours. The cost is not the edit: the undo reinstates the coordinate hole, so -every Service already migrated off a hand-written endpoint literal has to have +every Application already migrated off a hand-written endpoint literal has to have that literal written back by hand. Becomes irreversible once: the derived edge set is the enforced description of legal east-west traffic. From the moment policy runs in enforce ([0035](0035-network-policy-default-deny.md)), widening or dropping the edge -shape re-renders every workload's policy at once, and the only safe path back is +shape re-renders every process's policy at once, and the only safe path back is allow-all across the cluster. ## Consequences @@ -85,7 +90,7 @@ allow-all across the cluster. change, paid by the provider, who now owns a name others depend on. - Credential-free dependencies become declarable and therefore visible; the `knowledge` → `auth-api` edge acquires a coordinate and a policy, - paid by every Service relying on an undeclared path. + paid by every Application relying on an undeclared path. - One `surface` name is load-bearing for four outputs, so a typo mis-renders ordering, coordinates, policy and co-tests at once, paid by the on-call. - `required: false` gives an allow rule with no ordering and no startup gate, so diff --git a/docs/adr/model/0021-observability-scrape-and-alert-class.md b/docs/adr/model/0021-observability-scrape-and-alert-class.md index 5e437a9..95f142d 100644 --- a/docs/adr/model/0021-observability-scrape-and-alert-class.md +++ b/docs/adr/model/0021-observability-scrape-and-alert-class.md @@ -3,20 +3,25 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#observability +normative: spec/v1/10-project-intent.md#observability rests-on: ["0005"] --- # Observability is a scrape surface plus an Alert Class +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-10.** The two facts this decision names are now **one -> optional block on the Service**, `observability: {alertClass, scrape}`, whole +> optional block on the Application**, `observability: {alertClass, scrape}`, whole > or absent. Three things follow. > -> `scrape` names a `{workload, surface, path}` rather than a port, because +> `scrape` names a `{process, surface, path}` rather than a port, because > `provides` already declares the port and a second statement of it is a second > declaring site. `none` leaves the vocabulary: an omitted block is how a -> Service says it wants no monitoring, and a value meaning "I wrote the field to +> Application says it wants no monitoring, and a value meaning "I wrote the field to > say I did not want the field" is ceremony. > > The split of what derives is sharper than this record originally drew it. The @@ -25,7 +30,7 @@ rests-on: ["0005"] > so it stays a Deliverable and takes part in the derivation map's properties. > Rule expressions, severity and receivers derive **nothing here at all**: they > are read from the published projection by a stack this model does not operate -> ([chapter 10](../../../spec/v1/10-service-intent.md#observability)). +> ([chapter 10](../../../spec/v1/10-project-intent.md#observability)). ## Rests on @@ -34,7 +39,7 @@ declared class always reaches whoever reads the projection; a hand-written monitor does neither reliably. False if: a `ServiceMonitor` rendered from a declared `scrape` is in the cluster but absent from Prometheus's targets, or a class reaches no receiver in the stack that reads it. Settled by: render one -Service at `alertClass: urgent`, diff +Application at `alertClass: urgent`, diff `kubectl get prometheusrule -A -o jsonpath='{.items[*].metadata.name}'` against `curl -s http://prometheus:9090/api/v1/rules | jq -r '.data.groups[].rules[].name'`, then `amtool config routes test alertclass=urgent`. @@ -44,15 +49,15 @@ then `amtool config routes test alertclass=urgent`. Observability had no authoring vocabulary at all in v2. The resolved schema carried `observability: {metrics[], status[]}` and collections carried `observability: {metrics[], gatus[]}` (two shapes, neither used by a single -service repository) so everything real was hand-written, leaving two silent +project repository) so everything real was hand-written, leaving two silent holes. **Gatus monitors 41 endpoints and notifies nobody**: `gatus-config-configmap.yaml` contains `storage` and `ui` and no `alerting` section whatsoever. And **8 ServiceMonitors plus 2 PodMonitors cover roughly -thirty workloads**, with exactly one `PrometheusRule` in the estate, the 8/2/1 +thirty processes**, with exactly one `PrometheusRule` in the estate, the 8/2/1 count `spec/v1/30-deliverables.md:82` records. Deriving routing from a declared Alert Class makes monitored-but-unrouted impossible to express. -The scrape path stays service-declared because it is genuinely service +The scrape path stays application-declared because it is genuinely application knowledge and it varies (`/actuator/prometheus`, `/api/actuator/prometheus`, `/metrics`) so a platform that guessed would silently collect nothing and report success. That is the residue [0005](0005-derivation-is-total.md) leaves: @@ -62,9 +67,9 @@ a port and a path only the framework inside the container knows. derivation.** Which monitor kind, what cadence, which external checks, what PromQL and which receiver a severity routes to are things a monitoring stack knows and a deployment model does not. A versioned configuration owned by the -observability Service consumes the resolved Service facts and produces them; it -is authored once for the estate rather than restated per Service, and it is -configuration of a system rather than a second Service DSL. Putting PromQL and +observability Application consumes the resolved Application facts and produces them; it +is authored once for the estate rather than restated per Application, and it is +configuration of a system rather than a second Application DSL. Putting PromQL and receiver names in the Intent model made layer 1 own the configuration of a stack it does not operate, which is the same category error as a `backup.sh` string in a platform file ([0012](0012-assets-not-code.md)). @@ -85,8 +90,8 @@ missing there exactly as it is missing in the cluster. | option | cost if taken | why rejected | |---|---|---| | Derive the scrape path from a convention (`/metrics`) | breaks the live `/actuator/prometheus` paths; a wrong guess yields an empty target behind a green render | fails silently, the exact failure mode this decision removes | -| Let a Service declare its notifier or receiver | a notifier is a shared resource: N services × routes to maintain, and a Service can name a channel nobody reads without anything noticing | routing is estate knowledge; urgency is service knowledge | -| Keep the v2 `observability` shapes and hand-write the rest | zero service repositories use either shape today; ~30 workloads stay covered by 10 hand-written monitors and 1 rule | unused vocabulary is not vocabulary | +| Let an Application declare its notifier or receiver | a notifier is a shared resource: N applications × routes to maintain, and an Application can name a channel nobody reads without anything noticing | routing is estate knowledge; urgency is application knowledge | +| Keep the v2 `observability` shapes and hand-write the rest | zero project repositories use either shape today; ~30 processes stay covered by 10 hand-written monitors and 1 rule | unused vocabulary is not vocabulary | | Alert Class optional, defaulting to `none` | the honest `none` and the forgotten field become indistinguishable: exactly the 41-endpoint hole, re-created in the schema | absence must be declared, not inferred | ## Reversibility @@ -102,18 +107,18 @@ leaves `urgent` and `page` with no receiver, and the symptom is silence. ## Consequences -- Every Service declares `alertClass`, including those whose honest answer is - `none`, which puts that answer on the record: paid by service authors. -- Roughly thirty workloads need a scrape declaration to close the 8 + 2 gap, and - a wrong path shows an empty target rather than nothing: paid by service owners. -- One `PrometheusRule` in the estate becomes one per non-`none` Service, all +- Every Application declares `alertClass`, including those whose honest answer is + `none`, which puts that answer on the record: paid by application authors. +- Roughly thirty processes need a scrape declaration to close the 8 + 2 gap, and + a wrong path shows an empty target rather than nothing: paid by application owners. +- One `PrometheusRule` in the estate becomes one per non-`none` Application, all evaluated every interval: paid by the metrics stack's capacity budget. - A bespoke SLI or custom expression needs an escape hatch that names what it supplements rather than replacing the generated rule: paid by adapters. - Gatus's UI strings still read "personal-stack" and reference `inventory/fleet.yaml`; both become derived and stop naming an archived repository: paid by whoever lands the Gatus adapter. -- Routing derives from facts only a Service carries, so the delivery machinery +- Routing derives from facts only an Application carries, so the delivery machinery has no Alert Class here; [0058](../deferred/0058-delivery-machinery-observability.md) closes that gap: paid by platform. - The Intent model no longer carries a cadence, a catalog or a receiver map, so @@ -122,4 +127,4 @@ leaves `urgent` and `page` with no receiver, and the symptom is silence. stack does not read; paid by whoever moves the four values. - The runner must fail rather than warn when a class and signal cannot be mapped, which is what keeps "monitored but unrouted" impossible without the - model owning PromQL: paid by the observability Service's configuration. + model owning PromQL: paid by the observability Application's configuration. diff --git a/docs/adr/model/0022-grants-live-on-the-application.md b/docs/adr/model/0022-grants-live-on-the-application.md new file mode 100644 index 0000000..41c980b --- /dev/null +++ b/docs/adr/model/0022-grants-live-on-the-application.md @@ -0,0 +1,98 @@ +--- +tier: decision +status: proposed +claim: settled +date: 2026-08-31 +normative: spec/v1/10-project-intent.md#secrets +rests-on: ["0009"] +--- + +# Secret grants live on the Application document, at two levels + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +Secrets are declared in the Application document as a `secrets` list, at two +possible levels: on the Application, where **every** Process receives them, or on a +Process, where **only it** does. A Process's effective grant set is the +Application-level list plus its own. There is no override or removal syntax, a +Process that must *not* hold a shared secret is evidence the secret was not +shared, and it moves down a level. What an entry names is decided elsewhere +([0023](0023-grant-unit-is-the-path.md), [0025](0025-access-tiers-derive-policy.md), +[0026](0026-delivery-env-file-self.md)); this fixes only where grants live and +what nesting means. + +## Rests on + +A Process's grant set is the union of the two levels in one file, and the +identity presenting it to the store is per Process +([0024](0024-identity-per-process.md)), so the level a grant is written at is +the level the store enforces. False if: two Processes of one Application +authenticate as the same principal. Settled by: render a two-Process Application +into the lab cluster, confirm `.spec.applicationAccountName` differs between the two +pods, then exchange each ServiceAccount token for a store token and +`vault kv get` the *sibling's* path, the decision falls if that read succeeds. +This claim inherits [0009](0009-vault-read-is-per-path.md) only through +[0024](0024-identity-per-process.md): grants are unioned as whole paths, and it +is the per-Process principal that makes the two levels enforceable at all. + +## Why + +Sharing is the common case and duplication is what drifts. `knowledge` holds six +grants across two Processes, and two of them (`platform/postgres` and +`platform/rabbitmq`) are identical for both. Declaring those once is the +difference between one edit and two when a key is added. The absence of a +removal operator is deliberate: a subtract syntax would make the effective set +readable only by executing the document. + +The declaration is not a separate document because the estate has already paid +for that shape. Four rival vocabularies and a fifth live mechanism existed at +once: the `round3` `vault-dynamic-secrets` schema, fully designed and unused; +the resolved schema's `credentials[].claim`, validated against a registry +(`homelab-inventory/vault/claims.yml`) that was `claims: {}`, so every claim +failed; the v2 authoring type `Array<{ kind: string; [k: string]: unknown }>`, +an untyped passthrough; and hand-written Vault Agent Injector annotations in +twelve files, which is what actually ran. A separate `SecretAccess` document was +rejected: it puts grants in a third file keyed by Process name (a join key +that can drift) for no compensating benefit, since the access-versus-binding +split is already achieved by the env-file placeholder +([0027](0027-secret-reference-join-key.md)). Grants on the Application also sit +beside the `dependsOn` edges that motivate them, where a reviewer looks. + +The two levels are an access boundary **only** because identity is per Process. +At review time they were not one: `applicationAccountName()` in +`src/adapters/kubernetes.ts:665-669` returns `applicationName` for any Application +holding a non-Kubernetes secret, and the previous `16-dependencies.md` derived +the account from `id` to match. Two Processes of one Application therefore +authenticated as the same principal and received the union of both policies +whatever level a grant was written at, verified against the implementation, not +suspected (finding B7). The nesting was documentation. This decision ships +paired with [0024](0024-identity-per-process.md) or not at all. + +## Alternatives + +| option | cost if taken | why rejected | +|---|---|---| +| A separate `SecretAccess` document keyed by Process name | a third file per Application, a Process-name join key to validate and keep in step with every rename, and a reviewer reading three files to answer "what may this pod read" | the access/binding split it buys already exists in the env-file placeholder; the join key drifts and buys nothing | +| One level only, every grant written on the Process | `knowledge` alone restates two grants twice; a key added to a shared path becomes one edit per Process, and the copies diverge silently | duplication is the failure mode already observed in the estate | +| Two levels plus an override/removal syntax | the effective set stops being readable and must be evaluated; exceptions accumulate as subtractions instead of being fixed | a Process that must not hold a shared secret is evidence it was never shared | + +## Reversibility + +Undo cost today: the Process level is one optional schema property and one +union in the renderer, so collapsing to a single level is a schema edit, a +renderer edit and a mechanical rewrite of the Application documents using it, +hours, with the rendered policy set unchanged because it is already the union. +Becomes irreversible once: store policies are cut per Process and live +credentials exist under paths only one Process holds, flattening then either +widens a live grant or forces re-issue of every credential under those paths. + +## Consequences + +- An Application-level grant is held by every Process, including ones added later, so adding a Process silently widens the blast radius of shared secrets unless the author moves them down, paid by the Application author, and by every other reader of that path. +- The boundary binds only while identity is per Process; if [0024](0024-identity-per-process.md) is reversed, the Process level must be deleted from the schema rather than kept as advice, paid by the platform owner. +- An exception is expressed by moving a grant down a level, editing two places in one file, with no subtract escape hatch, paid by the Application author. +- Grants sit beside the `dependsOn` edges that motivate them, so one file answers "what may this pod read", paid by the schema, which carries a nested list it could have flattened. +- A reviewer reading only a Process block under-counts its grants; the renderer must print the effective union for review to be honest, paid by the renderer. diff --git a/docs/adr/model/0022-grants-live-on-the-service.md b/docs/adr/model/0022-grants-live-on-the-service.md deleted file mode 100644 index b3f83a1..0000000 --- a/docs/adr/model/0022-grants-live-on-the-service.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -tier: decision -status: proposed -claim: settled -date: 2026-08-31 -normative: spec/v1/10-service-intent.md#secrets -rests-on: ["0009"] ---- - -# Secret grants live on the Service document, at two levels - -Secrets are declared in the Service document as a `secrets` list, at two -possible levels: on the Service, where **every** Workload receives them, or on a -Workload, where **only it** does. A Workload's effective grant set is the -Service-level list plus its own. There is no override or removal syntax, a -Workload that must *not* hold a shared secret is evidence the secret was not -shared, and it moves down a level. What an entry names is decided elsewhere -([0023](0023-grant-unit-is-the-path.md), [0025](0025-access-tiers-derive-policy.md), -[0026](0026-delivery-env-file-self.md)); this fixes only where grants live and -what nesting means. - -## Rests on - -A Workload's grant set is the union of the two levels in one file, and the -identity presenting it to the store is per Workload -([0024](0024-identity-per-workload.md)), so the level a grant is written at is -the level the store enforces. False if: two Workloads of one Service -authenticate as the same principal. Settled by: render a two-Workload Service -into the lab cluster, confirm `.spec.serviceAccountName` differs between the two -pods, then exchange each ServiceAccount token for a store token and -`vault kv get` the *sibling's* path, the decision falls if that read succeeds. -This claim inherits [0009](0009-vault-read-is-per-path.md) only through -[0024](0024-identity-per-workload.md): grants are unioned as whole paths, and it -is the per-Workload principal that makes the two levels enforceable at all. - -## Why - -Sharing is the common case and duplication is what drifts. `knowledge` holds six -grants across two Workloads, and two of them (`platform/postgres` and -`platform/rabbitmq`) are identical for both. Declaring those once is the -difference between one edit and two when a key is added. The absence of a -removal operator is deliberate: a subtract syntax would make the effective set -readable only by executing the document. - -The declaration is not a separate document because the estate has already paid -for that shape. Four rival vocabularies and a fifth live mechanism existed at -once: the `round3` `vault-dynamic-secrets` schema, fully designed and unused; -the resolved schema's `credentials[].claim`, validated against a registry -(`homelab-inventory/vault/claims.yml`) that was `claims: {}`, so every claim -failed; the v2 authoring type `Array<{ kind: string; [k: string]: unknown }>`, -an untyped passthrough; and hand-written Vault Agent Injector annotations in -twelve files, which is what actually ran. A separate `SecretAccess` document was -rejected: it puts grants in a third file keyed by Workload name (a join key -that can drift) for no compensating benefit, since the access-versus-binding -split is already achieved by the env-file placeholder -([0027](0027-secret-reference-join-key.md)). Grants on the Service also sit -beside the `dependsOn` edges that motivate them, where a reviewer looks. - -The two levels are an access boundary **only** because identity is per Workload. -At review time they were not one: `serviceAccountName()` in -`src/adapters/kubernetes.ts:665-669` returns `serviceName` for any Service -holding a non-Kubernetes secret, and the previous `16-dependencies.md` derived -the account from `id` to match. Two Workloads of one Service therefore -authenticated as the same principal and received the union of both policies -whatever level a grant was written at, verified against the implementation, not -suspected (finding B7). The nesting was documentation. This decision ships -paired with [0024](0024-identity-per-workload.md) or not at all. - -## Alternatives - -| option | cost if taken | why rejected | -|---|---|---| -| A separate `SecretAccess` document keyed by Workload name | a third file per Service, a Workload-name join key to validate and keep in step with every rename, and a reviewer reading three files to answer "what may this pod read" | the access/binding split it buys already exists in the env-file placeholder; the join key drifts and buys nothing | -| One level only, every grant written on the Workload | `knowledge` alone restates two grants twice; a key added to a shared path becomes one edit per Workload, and the copies diverge silently | duplication is the failure mode already observed in the estate | -| Two levels plus an override/removal syntax | the effective set stops being readable and must be evaluated; exceptions accumulate as subtractions instead of being fixed | a Workload that must not hold a shared secret is evidence it was never shared | - -## Reversibility - -Undo cost today: the Workload level is one optional schema property and one -union in the renderer, so collapsing to a single level is a schema edit, a -renderer edit and a mechanical rewrite of the Service documents using it, -hours, with the rendered policy set unchanged because it is already the union. -Becomes irreversible once: store policies are cut per Workload and live -credentials exist under paths only one Workload holds, flattening then either -widens a live grant or forces re-issue of every credential under those paths. - -## Consequences - -- A Service-level grant is held by every Workload, including ones added later, so adding a Workload silently widens the blast radius of shared secrets unless the author moves them down, paid by the Service author, and by every other reader of that path. -- The boundary binds only while identity is per Workload; if [0024](0024-identity-per-workload.md) is reversed, the Workload level must be deleted from the schema rather than kept as advice, paid by the platform owner. -- An exception is expressed by moving a grant down a level, editing two places in one file, with no subtract escape hatch, paid by the Service author. -- Grants sit beside the `dependsOn` edges that motivate them, so one file answers "what may this pod read", paid by the schema, which carries a nested list it could have flattened. -- A reviewer reading only a Workload block under-counts its grants; the renderer must print the effective union for review to be honest, paid by the renderer. diff --git a/docs/adr/model/0023-grant-unit-is-the-path.md b/docs/adr/model/0023-grant-unit-is-the-path.md index fe88e2b..cd67288 100644 --- a/docs/adr/model/0023-grant-unit-is-the-path.md +++ b/docs/adr/model/0023-grant-unit-is-the-path.md @@ -4,12 +4,17 @@ status: proposed claim: open owner: joris date: 2026-08-31 -normative: spec/v1/10-service-intent.md#grant-unit +normative: spec/v1/10-project-intent.md#grant-unit rests-on: ["0009"] --- # The grant unit is the path; the subtree splits per reader set +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every path in the live Secret Subtree can be split so no path holds keys for more @@ -58,14 +63,14 @@ live Vault contents, which the pinned-input rule forbids. | option | cost if taken | why rejected | |---|---|---| | Per-key grants: `keys:` is the access boundary and the policy narrows to it | a policy generator emitting stanzas KV-v2 has no syntax for, or a broker holding full `read` on every document it slices | impossible under [0009](0009-vault-read-is-per-path.md); the estate chose `patch` over `update` for exactly this reason | -| Keys as documentation of an unenforced narrowing, the state the review found | zero migration today; reader sets stay undecidable, roll impact stays under-reported, and three Services keep silent `read` on each other's credentials | it is the finding, not a design; a boundary nothing enforces is worse than none, because authors act on it | -| The renderer copies the needed keys into a per-Service path | a copy pipeline plus a second document to rotate per consumer: three copies for `platform/postgres` alone, each with its own staleness | duplicates the value and moves custody into the toolkit; the copy is a new secret nobody declared | +| Keys as documentation of an unenforced narrowing, the state the review found | zero migration today; reader sets stay undecidable, roll impact stays under-reported, and three Applications keep silent `read` on each other's credentials | it is the finding, not a design; a boundary nothing enforces is worse than none, because authors act on it | +| The renderer copies the needed keys into a per-Application path | a copy pipeline plus a second document to rotate per consumer: three copies for `platform/postgres` alone, each with its own staleness | duplicates the value and moves custody into the toolkit; the copy is a new secret nobody declared | ## Reversibility Undo cost today: the vocabulary half is a spec edit in chapters 10 and 40 plus the policy renderer, hours. The layout half is larger but still small: three grants -across three example Services, one live shared document, three readers; a split is +across three example Applications, one live shared document, three readers; a split is a Vault write, a grant edit, a `${secret:...}` placeholder edit and one rollout per consumer. Becomes irreversible once: production consumers reference the split paths and the merged documents are deleted, re-merging then means rewriting every grant, @@ -74,15 +79,15 @@ placeholder and policy that names them, with no period during which both resolve ## Consequences - `keys:` must be read as documentation and a validation input, never as an access - boundary: paid by service authors, who lose a narrowing they believed they had. + boundary: paid by application authors, who lose a narrowing they believed they had. - One path per reader set multiplies paths, policies and sync objects as reader sets diverge: paid by the platform, in object count and policy churn. - `secret/data/platform/postgres` and `secret/platform/observability` must be split before the blast radius closes: paid by joris, as migration work. - `E_ROLL_AFFECTS_OTHER_READERS` becomes honest and fires more often, including on - grants that compose cleanly today: paid by service authors. + grants that compose cleanly today: paid by application authors. - `keys: ['*']` is removed, so `auth-api` must enumerate the keys of - `secret/data/auth-api`, and adding a key becomes a Service edit: paid by the + `secret/data/auth-api`, and adding a key becomes an Application edit: paid by the `auth-api` owner. - Reader sets become computable from the composed union without reading Vault, at the price of a new rejection for a grant naming an undeclared key: paid by the diff --git a/docs/adr/model/0024-identity-per-process.md b/docs/adr/model/0024-identity-per-process.md new file mode 100644 index 0000000..6268ddc --- /dev/null +++ b/docs/adr/model/0024-identity-per-process.md @@ -0,0 +1,105 @@ +--- +tier: decision +status: proposed +claim: settled +date: 2026-09-07 +normative: spec/v1/16-dependencies.md#process-identity +rests-on: ["0009"] +--- + +# Processes hold their own identity + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +The ServiceAccount and the Vault Kubernetes auth role are derived **per +Process** and named for the Process alone: `auth-system.auth-api`, never +`auth-system.auth-auth-api`. The policy bound to a Process's role is exactly +its effective grant set ([0022](0022-grants-live-on-the-application.md)) (never a +sibling's) and no author writes an identity name +([0030](0030-runtime-mechanics-derived.md)). + +## Rests on + +Vault's Kubernetes auth method binds a role to ServiceAccount names and +namespaces and to nothing finer, so two Pods presenting the same ServiceAccount +token are one principal holding the union of the policies bound to it. False if: +a role can bind below the ServiceAccount (to a Pod name, label or controller) +and give two Pods of one ServiceAccount different policies. Settled by: `vault +read auth/kubernetes/role/` and the parameters its create path accepts on +the pinned Vault version, the claim falls if any binding parameter selects finer +than `bound_application_account_names` × `bound_application_account_namespaces`; then the +review's tiebreaker, rendering the two-Process `knowledge` example and counting +ServiceAccounts and Vault roles, which must be two of each. + +## Why + +The two-level grant declaration was a documentation boundary, not an access +boundary. The old credential-provisioning record let a grant sit on the Application +(*"every Process receives them"*) or on a Process (*"only it does"*), +while `spec/v1/16-dependencies.md:104` and `:122` derive the ServiceAccount from +`id`, an Application field, drawing `d_id --> k_sa`. The implementation agrees: +`src/adapters/kubernetes.ts:665-669`, `applicationAccountName`, returns +`applicationName`, one ServiceAccount per Application. Two Processes of one Application +therefore authenticated as the same Vault principal and received the union of +both policies regardless of which level the grant was declared at. Verifying the +implementation upgraded the review's DAT-004 from *Likely* to *Certain*. + +The cost is concrete in the worked example. `knowledge` has two Processes: +`knowledge-api`, which serves anonymous paths (`/mcp`, `/install.sh`) from the +public internet, and `knowledge-ingest-worker`, which holds +`secret/data/knowledge-system/vault-deploy-key` at `fileMode: "0400"` as a +Process-level grant annotated *"only the worker pushes to the knowledge +vault"*. Under one identity per Application the internet-facing Process +authenticated as the principal holding `read` on that SSH private key, and +[0009](0009-vault-read-is-per-path.md) leaves the store no way to narrow a read +below the path, so the identity is the only place that boundary can exist. + +Splitting the identity makes [0022](0022-grants-live-on-the-application.md)'s levels +mean something: Application-level *is* shared, Process-level *is not*, both enforced +by the token the Pod presents, and the dead-grant and unauthorised-reference +checks ([0027](0027-secret-reference-join-key.md)) gain a subject: the Process. + +The derived name is the Process's own. Under project files +([0063](0063-intent-authored-per-project.md)) Application `auth` holds Process +`auth-api`, so `-` would render `auth-system.auth-auth-api` +for no gain: the Process name is already the process name and already what +runs, `auth-api` presenting `VAULT_KUBERNETES_ROLE: auth-api` today. Uniqueness +moves to the project file, where two Processes may not share a name +(`E_DUPLICATE_PROCESS_NAME`), the guarantee the prefix existed to give, +enforced where a reader can check it. + +## Alternatives + +| option | cost if taken | why rejected | +|---|---|---| +| One ServiceAccount per Application (today's implementation) | `knowledge-api` holds `read` on the worker's `0400` deploy key, and every Process added later silently inherits the union | The declaration promises a boundary the store never enforces, the defect this closes | +| Drop the Process level; all grants Application-wide | Honest, but blast radius only widens, and splitting `knowledge` then costs a second Application: duplicated namespace, exposure and observability declarations for one secret | Real Applications do hold Processes with disjoint secrets | +| One identity, a per-Process token broker | A new component holding every Application's full grant set, whose slicing Vault's ACL never audits, a fresh single point of compromise | Moves the boundary into unaudited code to avoid emitting a second ServiceAccount | + +## Reversibility + +Undo cost today: one adapter function (`applicationAccountName`, +`src/adapters/kubernetes.ts:665-669`), the Vault role and policy derivation +beside it, and `## Process identity` in `../../spec/v1/16-dependencies.md`, +hours, blast radius is object count, not authoring. Becomes irreversible once: +production Vault policies and auth roles carry per-Process names and tokens +are issued against them; collapsing back re-binds every role to the union +of its Processes' grants, widening live access silently rather than loudly. + +## Consequences + +- An Application with *n* Processes renders *n* ServiceAccounts, policies and auth + roles instead of one of each, each named for its Process, so live identities + such as `auth-api` survive unchanged, paid by the platform in object count. +- Identity uniqueness now comes from the project file, not the name's shape: two + Processes in one project sharing a name is `E_DUPLICATE_PROCESS_NAME` at + composition, paid by authors, in one more invariant to satisfy. +- A Process-level grant becomes a boundary a sibling cannot cross, paid by + nobody; it is the benefit the declaration always claimed. +- Renaming a Process renames its identity: role, policy and bindings churn, and + the new identity must be granted before it starts, paid by application owners. +- Applications running one ServiceAccount must be migrated before their grants mean + what they say; until then the old union stands, paid by the migration owner. diff --git a/docs/adr/model/0024-identity-per-workload.md b/docs/adr/model/0024-identity-per-workload.md deleted file mode 100644 index 5114724..0000000 --- a/docs/adr/model/0024-identity-per-workload.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -tier: decision -status: proposed -claim: settled -date: 2026-09-07 -normative: spec/v1/16-dependencies.md#workload-identity -rests-on: ["0009"] ---- - -# Workloads hold their own identity - -The ServiceAccount and the Vault Kubernetes auth role are derived **per -Workload** and named for the Workload alone: `auth-system.auth-api`, never -`auth-system.auth-auth-api`. The policy bound to a Workload's role is exactly -its effective grant set ([0022](0022-grants-live-on-the-service.md)) (never a -sibling's) and no author writes an identity name -([0030](0030-runtime-mechanics-derived.md)). - -## Rests on - -Vault's Kubernetes auth method binds a role to ServiceAccount names and -namespaces and to nothing finer, so two Pods presenting the same ServiceAccount -token are one principal holding the union of the policies bound to it. False if: -a role can bind below the ServiceAccount (to a Pod name, label or controller) -and give two Pods of one ServiceAccount different policies. Settled by: `vault -read auth/kubernetes/role/` and the parameters its create path accepts on -the pinned Vault version, the claim falls if any binding parameter selects finer -than `bound_service_account_names` × `bound_service_account_namespaces`; then the -review's tiebreaker, rendering the two-Workload `knowledge` example and counting -ServiceAccounts and Vault roles, which must be two of each. - -## Why - -The two-level grant declaration was a documentation boundary, not an access -boundary. The old credential-provisioning record let a grant sit on the Service -(*"every Workload receives them"*) or on a Workload (*"only it does"*), -while `spec/v1/16-dependencies.md:104` and `:122` derive the ServiceAccount from -`id`, a Service field, drawing `d_id --> k_sa`. The implementation agrees: -`src/adapters/kubernetes.ts:665-669`, `serviceAccountName`, returns -`serviceName`, one ServiceAccount per Service. Two Workloads of one Service -therefore authenticated as the same Vault principal and received the union of -both policies regardless of which level the grant was declared at. Verifying the -implementation upgraded the review's DAT-004 from *Likely* to *Certain*. - -The cost is concrete in the worked example. `knowledge` has two Workloads: -`knowledge-api`, which serves anonymous paths (`/mcp`, `/install.sh`) from the -public internet, and `knowledge-ingest-worker`, which holds -`secret/data/knowledge-system/vault-deploy-key` at `fileMode: "0400"` as a -Workload-level grant annotated *"only the worker pushes to the knowledge -vault"*. Under one identity per Service the internet-facing Workload -authenticated as the principal holding `read` on that SSH private key, and -[0009](0009-vault-read-is-per-path.md) leaves the store no way to narrow a read -below the path, so the identity is the only place that boundary can exist. - -Splitting the identity makes [0022](0022-grants-live-on-the-service.md)'s levels -mean something: Service-level *is* shared, Workload-level *is not*, both enforced -by the token the Pod presents, and the dead-grant and unauthorised-reference -checks ([0027](0027-secret-reference-join-key.md)) gain a subject: the Workload. - -The derived name is the Workload's own. Under domain files -([0063](0063-intent-authored-per-domain.md)) Service `auth` holds Workload -`auth-api`, so `-` would render `auth-system.auth-auth-api` -for no gain: the Workload name is already the process name and already what -runs, `auth-api` presenting `VAULT_KUBERNETES_ROLE: auth-api` today. Uniqueness -moves to the domain file, where two Workloads may not share a name -(`E_DUPLICATE_WORKLOAD_NAME`), the guarantee the prefix existed to give, -enforced where a reader can check it. - -## Alternatives - -| option | cost if taken | why rejected | -|---|---|---| -| One ServiceAccount per Service (today's implementation) | `knowledge-api` holds `read` on the worker's `0400` deploy key, and every Workload added later silently inherits the union | The declaration promises a boundary the store never enforces, the defect this closes | -| Drop the Workload level; all grants Service-wide | Honest, but blast radius only widens, and splitting `knowledge` then costs a second Service: duplicated namespace, exposure and observability declarations for one secret | Real Services do hold Workloads with disjoint secrets | -| One identity, a per-Workload token broker | A new component holding every Service's full grant set, whose slicing Vault's ACL never audits, a fresh single point of compromise | Moves the boundary into unaudited code to avoid emitting a second ServiceAccount | - -## Reversibility - -Undo cost today: one adapter function (`serviceAccountName`, -`src/adapters/kubernetes.ts:665-669`), the Vault role and policy derivation -beside it, and `## Workload identity` in `../../spec/v1/16-dependencies.md`, -hours, blast radius is object count, not authoring. Becomes irreversible once: -production Vault policies and auth roles carry per-Workload names and tokens -are issued against them; collapsing back re-binds every role to the union -of its Workloads' grants, widening live access silently rather than loudly. - -## Consequences - -- A Service with *n* Workloads renders *n* ServiceAccounts, policies and auth - roles instead of one of each, each named for its Workload, so live identities - such as `auth-api` survive unchanged, paid by the platform in object count. -- Identity uniqueness now comes from the domain file, not the name's shape: two - Workloads in one domain sharing a name is `E_DUPLICATE_WORKLOAD_NAME` at - composition, paid by authors, in one more invariant to satisfy. -- A Workload-level grant becomes a boundary a sibling cannot cross, paid by - nobody; it is the benefit the declaration always claimed. -- Renaming a Workload renames its identity: role, policy and bindings churn, and - the new identity must be granted before it starts, paid by service owners. -- Services running one ServiceAccount must be migrated before their grants mean - what they say; until then the old union stands, paid by the migration owner. diff --git a/docs/adr/model/0025-access-tiers-derive-policy.md b/docs/adr/model/0025-access-tiers-derive-policy.md index 2760ded..d397460 100644 --- a/docs/adr/model/0025-access-tiers-derive-policy.md +++ b/docs/adr/model/0025-access-tiers-derive-policy.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#access-tiers +normative: spec/v1/10-project-intent.md#access-tiers rests-on: ["0009"] --- # Access tiers derive the Vault policy +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-07.** The four tiers are **KV intents** and apply to the > `kv` engine only ([0085](0085-a-grant-is-a-union-on-engine.md)). A `transit` > grant declares `operations`, because `self-roll`'s `patch` permits neither @@ -18,10 +23,10 @@ rests-on: ["0009"] ## Rests on -Every secret-touching workload in the estate holds exactly one of four +Every secret-touching process in the estate holds exactly one of four intents (`read`, `self-renew`, `self-roll`, `custody`), and the least -privilege each needs follows from the intent alone. False if: a workload needs -a capability set no tier's derivation produces, or two workloads on the same +privilege each needs follows from the intent alone. False if: a process needs +a capability set no tier's derivation produces, or two processes on the same tier need different capabilities. Settled by: enumerate every Vault policy under `cluster/flux/apps/data/vault/` and every hand-written Agent Injector annotation in the twelve files carrying them, classify each into a tier, and @@ -68,29 +73,29 @@ cell-by-cell table lives in chapter 10. |---|---|---| | a single `read`/`write` axis | one enum value fewer and no derivation branch, at the price of granting write privilege to the renewal job that today needs none, and of losing the lease-extension-versus-value-replacement distinction that drives rollout restarts: restart targets would have to be declared by hand on every grant | the production evidence contradicts it: the renewal job has a Vault identity *only* to mint, and its own file says renewal needs no privilege | | authors declare Vault capabilities directly | maximum expressiveness, no tier vocabulary to maintain; every author must know that `patch` beats `update` on a shared document, and every review must re-derive it | it makes a one-time least-privilege choice a per-author decision; the estate's own annotations show the failure mode: twelve hand-written injector files, none reviewed against each other | -| `custody` as an enumerated path list | the grant stays a path grant, so [0023](0023-grant-unit-is-the-path.md) needs no prefix case and roll-impact stays exact | `agents-api` mints paths keyed by runtime ids; enumeration would require a control loop rewriting Service documents from cluster state, which inverts the authoring direction | +| `custody` as an enumerated path list | the grant stays a path grant, so [0023](0023-grant-unit-is-the-path.md) needs no prefix case and roll-impact stays exact | `agents-api` mints paths keyed by runtime ids; enumeration would require a control loop rewriting Application documents from cluster state, which inverts the authoring direction | ## Reversibility Undo cost today: the tier vocabulary is a schema enum, one derivation branch in the policy renderer, and one field per declared grant. Collapsing it to a read/write axis is a few hours of work plus a rewrite of every `secrets` entry -in the estate's Service documents; every rendered Vault policy re-renders. +in the estate's Application documents; every rendered Vault policy re-renders. Becomes irreversible once: production tokens authenticate against derived -policies and the `self-renew` workloads run with no Vault privilege at all. +policies and the `self-renew` processes run with no Vault privilege at all. widening the axis then means re-granting write privilege to identities that hold none, and re-auditing every path they can reach. ## Consequences -- A self-renewing workload gets an identity with no capability on its path, +- A self-renewing process gets an identity with no capability on its path, matching what the renewal job needs today, paid by the renderer, which emits a role with an empty policy rather than skipping it. - Rollout restart targets follow from tier and rotation tolerance rather than being declared, paid by the platform, which owns the derivation. - Four tiers times three deliveries is twelve cells and not all are legal, so authors meet refusals for combinations that read as plausible, paid by - service authors and by chapter 10, which keeps the table current. + application authors and by chapter 10, which keeps the table current. - `custody` is unvalidatable against a placeholder or path list, so its blast radius is bounded only by the prefix, paid by whoever reviews such a grant. - An intent no tier expresses cannot be worked around by an author: it is a diff --git a/docs/adr/model/0026-delivery-env-file-self.md b/docs/adr/model/0026-delivery-env-file-self.md index d263146..f123f90 100644 --- a/docs/adr/model/0026-delivery-env-file-self.md +++ b/docs/adr/model/0026-delivery-env-file-self.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#delivery +normative: spec/v1/10-project-intent.md#delivery rests-on: ["0009"] --- # Secret delivery is env, file, or self +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every secret this estate consumes arrives by one of three mechanisms (an @@ -25,7 +30,7 @@ policy is granted per path, which is what makes delivery independent of keys. ## Why The three deliveries render three object sets. `env` renders a VSO sync and a -Secret, with the Workload's env file placeholders resolving to `envFrom` +Secret, with the Process's env file placeholders resolving to `envFrom` secretRef entries, not literal values. `file` renders a projected file at `mountAt` with `fileMode`. `self` renders a Vault policy, a Kubernetes auth role and the application's own client wiring: no Secret, no env var, nothing @@ -58,8 +63,8 @@ also feeds the rollout: `rolloutRestartTargets` derives from | option | cost if taken | why rejected | |---|---|---| -| One delivery (`env` only), everything else a workaround | Rewrite `auth-api`'s runtime credential path onto restart-based rotation, losing zero-downtime rotation on the estate's authentication service; ship the deploy key by an entrypoint shim that writes `$SSH_KEY` to a file at `0400` | Falsified by two live consumers before it is written: an SSH private key is not an env var, and a pod's environment is fixed for its lifetime | -| Keep the Vault Agent Injector as a fourth delivery | A templating sidecar per pod across ~30 Workloads, plus the annotation surface hand-written in twelve files today: the untyped mechanism this vocabulary replaces | It is a projector, not an authoring intent: it renders a file, so it is `file` by another means, and it does not remove the gate for `env`, which still needs a shim to become variables | +| One delivery (`env` only), everything else a workaround | Rewrite `auth-api`'s runtime credential path onto restart-based rotation, losing zero-downtime rotation on the estate's authentication application; ship the deploy key by an entrypoint shim that writes `$SSH_KEY` to a file at `0400` | Falsified by two live consumers before it is written: an SSH private key is not an env var, and a pod's environment is fixed for its lifetime | +| Keep the Vault Agent Injector as a fourth delivery | A templating sidecar per pod across ~30 Processes, plus the annotation surface hand-written in twelve files today: the untyped mechanism this vocabulary replaces | It is a projector, not an authoring intent: it renders a file, so it is `file` by another means, and it does not remove the gate for `env`, which still needs a shim to become variables | | Derive delivery from `access` and `rotation.tolerates` instead of declaring it | The renderer must invent `mountAt` and `fileMode`, which no other field supplies | `tolerates` constrains delivery without determining it: the deploy key tolerates `restart` and must still be a file, while `platform/postgres` tolerates `restart` as `env` | ## Reversibility @@ -68,23 +73,23 @@ Undo cost today: delivery is one enum on the grant, three render branches and a schema union carrying `mountAt`/`fileMode`. Adding a fourth value is additive: a branch, an enum member, a spec section, hours. Removing one is not: `self` is what `auth-api` runs in production, so dropping it means rewriting that -service's credential path and accepting restart-based rotation on the estate's +application's credential path and accepting restart-based rotation on the estate's front door. Becomes irreversible once: spring-cloud-vault wiring and dynamic -database backends are compiled into service repositories. The undo is then +database backends are compiled into project repositories. The undo is then application code, not a render change. ## Consequences - Until the secrets-at-rest gate ([0028](0028-secrets-at-rest-gate.md)) is satisfied only `self` ships, so every author whose grants are `env` waits, - paid by service authors and the gate's owner. + paid by application authors and the gate's owner. - `self` requires a Vault client in the process, so a stack without one cannot reach zero-downtime rotation and must tolerate `restart`, paid by teams on runtimes with no spring-cloud-vault equivalent. - `rolloutRestartTargets` stops being hand-maintained, so a one-word edit to `rotation.tolerates` silently changes restart behaviour, paid by reviewers. - `file` puts `mountAt` and `fileMode` in the vocabulary, and a wrong mode leaves - a readable credential on disk, paid by service authors. + a readable credential on disk, paid by application authors. - Not every tier × delivery cell is legal: `custody`+`env`, `custody`+`file` and `self-renew`+`env` must be refused by the schema, not left as traps, paid by the schema, per the review finding on the old tier table. diff --git a/docs/adr/model/0027-secret-reference-join-key.md b/docs/adr/model/0027-secret-reference-join-key.md index 3a0e23c..99455d2 100644 --- a/docs/adr/model/0027-secret-reference-join-key.md +++ b/docs/adr/model/0027-secret-reference-join-key.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-08-31 -normative: spec/v1/10-service-intent.md#secret-references +normative: spec/v1/10-project-intent.md#secret-references rests-on: ["0009"] --- # A secret placeholder byte-matches a granted path +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-07.** The join key is the grant's **derived read path**, not > its declared path ([0085](0085-a-grant-is-a-union-on-engine.md)). For a `kv` > grant the two are the same string, so this decision is unchanged in every case @@ -63,8 +68,8 @@ into an env var or a file, so `delivery: self` is its only legal delivery | option | cost if taken | why rejected | |---|---|---| | Specify the strip rule (`secret/`, then `data/`) in chapter 10 | the composer carries a mount-aware rewrite table, one row per engine, extended whenever a mount is added; `transit/` needs its own row today and an unknown future mount has no row at all, leaving the check undecidable for it | it re-encodes Vault's API-path layout inside the composer to save authors 12 characters, and the review found the unstated version already produced a check satisfiable by the wrong grant | -| Placeholder names a per-Service alias (`alias: pg` on the grant, `${secret:pg#kb.user}`) | a second name-space per Service, unique across both declaration levels, and a join key that drifts when the alias is renamed on one side only | the old credential-provisioning ADR rejected a separate `SecretAccess` document for exactly this reason: "a join key that can drift, with no compensating benefit", and an alias reintroduces it inside one file | -| Grant declares the env var name; the env file carries only the key | the shared Service-level grant on `platform/postgres` can no longer serve two Workloads that name the variable differently, so shared grants split per Workload and the sharing the two levels exist for is lost | `knowledge` writes `DB_HOST` and `n8n` writes `DB_POSTGRESDB_HOST` from the same Postgres; keeping the variable name in the env file is the whole point of the placeholder mechanism | +| Placeholder names a per-Application alias (`alias: pg` on the grant, `${secret:pg#kb.user}`) | a second name-space per Application, unique across both declaration levels, and a join key that drifts when the alias is renamed on one side only | the old credential-provisioning ADR rejected a separate `SecretAccess` document for exactly this reason: "a join key that can drift, with no compensating benefit", and an alias reintroduces it inside one file | +| Grant declares the env var name; the env file carries only the key | the shared Application-level grant on `platform/postgres` can no longer serve two Processes that name the variable differently, so shared grants split per Process and the sharing the two levels exist for is lost | `knowledge` writes `DB_HOST` and `n8n` writes `DB_POSTGRESDB_HOST` from the same Postgres; keeping the variable name in the env file is the whole point of the placeholder mechanism | ## Reversibility @@ -81,7 +86,7 @@ the other form. - Placeholders get longer: `${secret:secret/data/platform/postgres#kb.user}` where the old form wrote `${secret:platform/postgres#kb.user}`, paid by - service authors, once per placeholder, at authoring time. + application authors, once per placeholder, at authoring time. - `E_UNAUTHORISED_SECRET_REFERENCE` and the dead-grant check become string comparisons over the composed union, with no mount table and no Vault read, paid by the composer, which gets smaller. @@ -89,7 +94,7 @@ the other form. the reader-set roll-impact model of [0009](0009-vault-read-is-per-path.md) auditable from the repository, paid by nobody. - A `transit/` or other non-KV grant with `delivery: env` or `file` is a build - error, paid by authors of self-rotating services, who must fetch at runtime. + error, paid by authors of self-rotating applications, who must fetch at runtime. - Renaming a path edits the grant and every placeholder naming it in lockstep, paid by whoever moves paths during the Secret Subtree layout ([0023](0023-grant-unit-is-the-path.md)). diff --git a/docs/adr/model/0028-secrets-at-rest-gate.md b/docs/adr/model/0028-secrets-at-rest-gate.md index 191c58b..94cc1b3 100644 --- a/docs/adr/model/0028-secrets-at-rest-gate.md +++ b/docs/adr/model/0028-secrets-at-rest-gate.md @@ -10,6 +10,11 @@ rests-on: ["0002"] # Secrets at rest gate env and file delivery +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on With `--secrets-encryption` enabled on the pinned k3s version, a Secret written @@ -60,8 +65,8 @@ layer-2 purity ([0034](0034-cluster-state-pinned-input.md)). | option | cost if taken | why rejected | |---|---|---| | Ship `env`/`file` now, keep secrets-at-rest as a checklist line | every Secret rendered before the flag lands is plaintext base64 in the datastore and in every backup taken meanwhile; closing it later needs a `secrets-encrypt reencrypt` pass **and** rotation of everything already written, since the old copies sat readable | the checklist has now been written three times (old ADR, `00-overview` open item 2, `60-setup` pre-apply list) and produced no owner and no date; nothing mechanical stopped a render | -| Restrict v1 to `delivery: self` until encryption lands | each consumer must speak Vault itself: `auth-api` does via spring-cloud-vault, the knowledge ingest worker and the postgres init path do not; the hand-written injector annotations in twelve files stay in service indefinitely | it makes the toolkit unable to express the estate's most common delivery ([0026](0026-delivery-env-file-self.md)) and defers the encryption work rather than dating it | -| Check at apply time (admission policy or a deployer-side probe) | the failure surfaces per Service in a cluster after a merge, and the deployer needs a live cluster read that layer-2 purity forbids | the pinned context already carries the fact; checking it at render costs one predicate and keeps the failure in the author's loop | +| Restrict v1 to `delivery: self` until encryption lands | each consumer must speak Vault itself: `auth-api` does via spring-cloud-vault, the knowledge ingest worker and the postgres init path do not; the hand-written injector annotations in twelve files stay in application indefinitely | it makes the toolkit unable to express the estate's most common delivery ([0026](0026-delivery-env-file-self.md)) and defers the encryption work rather than dating it | +| Check at apply time (admission policy or a deployer-side probe) | the failure surfaces per Application in a cluster after a merge, and the deployer needs a live cluster read that layer-2 purity forbids | the pinned context already carries the fact; checking it at render costs one predicate and keeps the failure in the author's loop | ## Reversibility @@ -78,12 +83,12 @@ cheap to delete, key custody does not. ## Consequences - `delivery: env` and `delivery: file` cannot ship until the flag is on and a - pinned context advertises it, paid by joris, before the first such Service. + pinned context advertises it, paid by joris, before the first such Application. - Until then `delivery: self` is the only delivery for a sensitive value, so a consumer that cannot speak Vault has no path, paid by the authors of `knowledge` and `platform-postgres`. - Every pinned Platform Intent gains one more required fact, asserted rather - than measured: omitting it fails every env-delivering Service at once, and a + than measured: omitting it fails every env-delivering Application at once, and a false `true` defeats the gate silently, so the settling command is run per cluster and recorded, paid by the context maintainer. - The encryption key file becomes restore-critical: a backup without it restores diff --git a/docs/adr/model/0029-resolved-deployment-versioned-artifact.md b/docs/adr/model/0029-resolved-deployment-versioned-artifact.md index 447ebdf..6892606 100644 --- a/docs/adr/model/0029-resolved-deployment-versioned-artifact.md +++ b/docs/adr/model/0029-resolved-deployment-versioned-artifact.md @@ -9,13 +9,18 @@ rests-on: ["0003"] # The Resolved Deployment is a versioned, reviewable artifact +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** The schema family list below includes > `adapter-compat/v1`, which no longer exists: the publish-time producers it > paired with their consumers are deleted ([0098](0098-one-publication-path.md)). > The Resolved Deployment also carries the path plan > ([0070](0070-path-authority-is-layer-2.md)), the release gate's inputs > ([0071](0071-release-gate-inputs-are-layer-2.md)), the override records and the -> per-Workload image digests that were once a separate image-metadata document. +> per-Process image digests that were once a separate image-metadata document. ## Rests on @@ -24,10 +29,10 @@ versioned schema, is byte-stable for identical pinned inputs, so a diff between two renders shows exactly the platform decisions that changed and nothing else. False if: two renders of the same Intent against the same pinned context and locks differ (map ordering, timestamps, absolute paths), because a diff -carrying that noise is not a review surface. Settled by: render one Service +carrying that noise is not a review surface. Settled by: render one Application twice from the same lock and context into `/tmp/a` and `/tmp/b`, then `diff -r /tmp/a /tmp/b` (empty settles it) and -`ajv validate -s schemas/deployment.schema.json -d /tmp/a/.yml`. +`ajv validate -s schemas/deployment.schema.json -d /tmp/a/.yml`. ## Why @@ -84,7 +89,7 @@ decision into layer 3 unnoticed: an empty artifact diff across it proves that. Undo cost today: the schema exists, so undoing means deleting an emit step, a CI validate-and-diff job and the version field: one workflow file, one command path, a handful of fixtures; hours, and the review surface is the whole loss. -Becomes irreversible once: a service repository or a second aggregator pins a +Becomes irreversible once: a project repository or a second aggregator pins a Resolved Deployment schema version or reads a published artifact of its own accord, the middle layer is then a contract with consumers this repository cannot enumerate, and its shape moves only under the compatibility rule. @@ -92,7 +97,7 @@ cannot enumerate, and its shape moves only under the compatibility rule. ## Consequences - A third schema to version and keep honest, paid by this repository's maintainers. -- Every render emits and validates an artifact, and CI gains a diff step per Service, paid by the aggregator's pipeline, in wall time on every change. +- Every render emits and validates an artifact, and CI gains a diff step per Application, paid by the aggregator's pipeline, in wall time on every change. - A reviewer sees what the platform decided on their behalf, including decisions nobody asked for, paid by reviewers, who read a second document per change. - `validate deployment` is ambiguous by construction and needs a per-layer name; scripts spelling the old one break, paid by tooling authors. - Byte-stability stops being an aspiration: any non-determinism in the renderer surfaces as diff noise and must be fixed before the gate is trusted, paid by the renderer's maintainers. diff --git a/docs/adr/model/0030-runtime-mechanics-derived.md b/docs/adr/model/0030-runtime-mechanics-derived.md index 4faf913..03d778e 100644 --- a/docs/adr/model/0030-runtime-mechanics-derived.md +++ b/docs/adr/model/0030-runtime-mechanics-derived.md @@ -9,11 +9,16 @@ rests-on: ["0005"] # Runtime mechanics are derived from declared intent +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-10.** `zeroDowntime` is replaced by the required > `cutover: rolling | recreate` field -> ([chapter 10](../../../spec/v1/10-service-intent.md#cutover-is-declared-not-promised)). +> ([chapter 10](../../../spec/v1/10-project-intent.md#cutover-is-declared-not-promised)). > A boolean that could request continuity while the derived substrate required -> recreation was a silent contradiction; the Workload rendered, reported success, +> recreation was a silent contradiction; the Process rendered, reported success, > and stopped serving during every roll. `cutover` has **no default**, and > `rolling` over storage that cannot surge is refused with > `E_CUTOVER_UNHONOURABLE` instead of silently rendered as `Recreate`. The @@ -22,9 +27,9 @@ rests-on: ["0005"] ## Rests on Rollout strategy is a function of declared volumes and declared cutover intent, -not of a preference: no workload holding a `ReadWriteOnce` volume can roll, so -every such workload must either declare `recreate` or be refused. False if: a -workload holding an RWO volume rolls with `maxSurge: 1` and does so without +not of a preference: no process holding a `ReadWriteOnce` volume can roll, so +every such process must either declare `recreate` or be refused. False if: a +process holding an RWO volume rolls with `maxSurge: 1` and does so without wedging. Settled by: `kubectl get deploy,sts -A -o json | jq -r '.items[]|[.metadata.name, (.spec.strategy.type//"RollingUpdate")]|@tsv'` joined against @@ -33,7 +38,7 @@ by: `kubectl get deploy,sts -A -o json | jq -r '.items[]|[.metadata.name, split, and no rendered `RollingUpdate` may hold an RWO volume. ## Why -A Service declares what only it can know: its cold-start budget, whether it +An Application declares what only it can know: its cold-start budget, whether it requires zero-downtime rolls, which paths answer readiness and liveness, what a volume's data is worth, and what it can survive when an input changes. Probe timings, rollout strategy, surge and unavailability, progress deadlines, health @@ -41,7 +46,7 @@ timeout classes, backup jobs, retention sweeps and node pinning are all derived from those declarations. None of the derived values may be authored. The v2 authoring vocabulary for all of this was `path`, `port`, `timeoutClass`, `mandatory`, `livenessPath`, `probeTimeoutSeconds`; everything else existed only -in hand-written manifests, so each new service either rediscovered the mechanics +in hand-written manifests, so each new application either rediscovered the mechanics or copied them without the reasoning. The rollout configuration is the most carefully-tuned thing in the estate and @@ -49,7 +54,7 @@ the model could not see any of it. All four first-party deployments carry the same pattern: `RollingUpdate` with `maxSurge: 1` and `maxUnavailable: 0`, `startupProbe` at `periodSeconds: 5` and `failureThreshold: 120`, readiness and liveness at `timeoutSeconds: 5`, and `progressDeadlineSeconds: 1800` on the -three JVM services ([0031](0031-derived-overrides-with-reason.md) covers +three JVM applications ([0031](0031-derived-overrides-with-reason.md) covers `app-ui`'s 600), and the comments record what it cost to arrive there: *"under `Recreate` every image roll opened a zero-pod window, so a slow cold start or a flaky ghcr image pull took the MCP fully down (503)"*, *"the old maxSurge=0 @@ -61,31 +66,31 @@ Much of the set is already mechanical. Estate-wide the strategy split is 21 two pods at once. `src/schemas/health-timeout-map.ts:1-6` maps the health timeout class by table (`stateless: 5m`, `stateful: 10m`, `control-plane: 15m`, `job: 10m`) and `resolveHealthTimeout` (line 18) takes the strongest class -across a Service's workloads. Storage is `local-path` and all fourteen PVCs are -`ReadWriteOnce`, so a volume pins its workload to one node permanently and +across an Application's processes. Storage is `local-path` and all fourteen PVCs are +`ReadWriteOnce`, so a volume pins its process to one node permanently and retention can only be an application-level backup job, both falling out of the durability class of [0015](0015-durability-class-per-volume.md) as probe timings fall out of [0014](0014-probes-are-siblings.md). The renderer does not do this yet: `src/adapters/kubernetes.ts:608` reads an authored enum -(`src/schemas/service-intent.ts:165`), inspecting no volume. +(`src/schemas/project-intent.ts:165`), inspecting no volume. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Keep the authored `strategy` enum (today's renderer) | A stateful workload whose author omits `strategy: recreate` gets `maxSurge: 1, maxUnavailable: 0` against an RWO volume; on one node the pods co-schedule and it appears to work, and the moment a second worker exists the rollout hangs until the progress deadline and fails without ever having been able to succeed | The input the platform needs (the volume) is already declared; asking for the conclusion as well makes a silent trap out of a forgotten field | +| Keep the authored `strategy` enum (today's renderer) | A stateful process whose author omits `strategy: recreate` gets `maxSurge: 1, maxUnavailable: 0` against an RWO volume; on one node the pods co-schedule and it appears to work, and the moment a second worker exists the rollout hangs until the progress deadline and fails without ever having been able to succeed | The input the platform needs (the volume) is already declared; asking for the conclusion as well makes a silent trap out of a forgotten field | | Per-field opt-in: derive some mechanics, leave the rest authorable | The derived/authored boundary lives in convention rather than schema; ~30 layer-1 repositories drift apart on which half they use, and a platform-wide tuning change reaches only the derived half | A partial rule cannot be tested; the estate would still hold hand-tuned values nobody can trace to an input | -| A shared manifest template each service copies | Cheapest to build (one blueprint, no derivation code), but reproduces exactly the four identical blocks that exist today, one per service, drifting on every edit | Copies carry values without the reasoning; the 503 that produced `maxSurge: 1` is a comment in four files, not a rule | +| A shared manifest template each application copies | Cheapest to build (one blueprint, no derivation code), but reproduces exactly the four identical blocks that exist today, one per application, drifting on every edit | Copies carry values without the reasoning; the 503 that produced `maxSurge: 1` is a comment in four files, not a rule | ## Reversibility Undo cost today: the authored fields still exist, restoring them means reverting two adapter call sites and the layer-1 schema enum, roughly a day, and the live manifests still carry the tuned values as a fallback. Becomes irreversible once: the hand-written manifests and their explanatory comments are deleted from the -service repositories, after which the rules are the only surviving record of +project repositories, after which the rules are the only surviving record of what the values cost to learn. ## Consequences -- A wrong derivation rule mis-tunes every workload at once rather than one, paid +- A wrong derivation rule mis-tunes every process at once rather than one, paid by the whole estate, on the first rollout after the bad rule ships. - The progress deadline must derive to strictly more than the startup budget; the current renderer emits `600` against a `600s` budget, so a JVM still inside its @@ -94,11 +99,11 @@ what the values cost to learn. before the derivation is trusted. - A wrong `reload` declaration (for change response or rotation tolerance) fails silently, reproducing today's bug for that one input; the default is - `restart` so that omission is safe, paid by the Workload owner. -- An unusual workload cannot hand-tune probes or strategy except through the + `restart` so that omission is safe, paid by the Process owner. +- An unusual process cannot hand-tune probes or strategy except through the named-override hatch of [0031](0031-derived-overrides-with-reason.md), which - costs a recorded reason per override, paid by that workload's owner. -- Service owners lose a lever they had, and every mechanic they can no longer + costs a recorded reason per override, paid by that process's owner. +- Application owners lose a lever they had, and every mechanic they can no longer reach becomes a platform support request, paid by the platform owner. -- New workloads inherit the four-deployment pattern as a rule, the reasoning - stated once instead of copied, paid back to every future service author. +- New processes inherit the four-deployment pattern as a rule, the reasoning + stated once instead of copied, paid back to every future application author. diff --git a/docs/adr/model/0031-derived-overrides-with-reason.md b/docs/adr/model/0031-derived-overrides-with-reason.md index e9ec1a8..4ccc88d 100644 --- a/docs/adr/model/0031-derived-overrides-with-reason.md +++ b/docs/adr/model/0031-derived-overrides-with-reason.md @@ -9,6 +9,11 @@ rests-on: ["0005"] # A derived value has one declaring site; capacity is the sole named exception +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** An override named a derivation by the **derivation's > own name** (`startupDeadline`, `replicas`, `automountToken`) from a closed > set in [chapter 14](../../../spec/v1/14-platform-intent.md), never by the @@ -19,30 +24,30 @@ rests-on: ["0005"] > `E_UNKNOWN_OVERRIDE`. The rule that survives is the one this ADR was always always reaching for (a derived value has exactly one declaring site) and the sole > local exception is the named `replicas: {count, reason}` field -> ([chapter 10](../../../spec/v1/10-service-intent.md#capacity)). The argument +> ([chapter 10](../../../spec/v1/10-project-intent.md#capacity)). The argument > below is kept because it is the evidence for why the hatch was wrong to > generalise, not because the hatch still exists. ## Rests on A derived value is a function of declared inputs, so a value reachable two ways has no single declaring site and cannot be checked. False if: rendering the -estate needs more than one locally restated value per Service, which would mean -the derivation rules are wrong rather than the workloads unusual. Settled by: -render every Service with the single `replicas` exception, and find no derived -value that a Workload must restate to be correctly rendered. +estate needs more than one locally restated value per Application, which would mean +the derivation rules are wrong rather than the processes unusual. Settled by: +render every Application with the single `replicas` exception, and find no derived +value that a Process must restate to be correctly rendered. ## Why [0030](0030-runtime-mechanics-derived.md) forbids authoring derived runtime -mechanics and [0011](0011-configuration-env-files-per-workload.md) forbids +mechanics and [0011](0011-configuration-env-files-per-process.md) forbids authoring derived configuration. The escape was justified by one case: `app-ui` -runs `progressDeadlineSeconds: 600` while the three JVM services run `1800`, and +runs `progressDeadlineSeconds: 600` while the three JVM applications run `1800`, and its comment explains why: *"nginx pods, ~10–20Mi RAM each"*. That case did not justify a general mechanism, and re-reading it shows why. A JVM cold start and a static-bundle start differ by **two orders of magnitude**; -that is not a value only `app-ui`'s owner could know, it is a **workload class** +that is not a value only `app-ui`'s owner could know, it is a **process class** the central rule failed to distinguish. The correct response is a rule that -reads `runtime` (an input every Workload already declares), not a per-Workload +reads `runtime` (an input every Process already declares), not a per-Process exception carrying a number the rule should have produced. The falsified-input argument was sound as far as it went: the deadline derives @@ -66,20 +71,20 @@ is the defect, and it is not fixed by requiring a reason. | No escape at all, and no `replicas` field | Simplest possible rule | Capacity is genuinely irreducible: only the owner knows why a second replica exists, and a reason for it is the whole point | | A narrowly named field per real exception | One field per exception, each with authority and validation | **Taken.** This is the decision: `replicas` is that field, and the bar for a second is deliberately high | | Repair every derivation rule instead | Nothing to except, ever | Not achievable in general: `replicas` is a fact, not a rule outcome | -| Assignments restatable as well | Two Services can claim one hostname, one node label, one Secret path | Reintroduces the collisions [0004](0004-contention-decides-authority.md) exists to prevent | +| Assignments restatable as well | Two Applications can claim one hostname, one node label, one Secret path | Reintroduces the collisions [0004](0004-contention-decides-authority.md) exists to prevent | ## Reversibility Undo cost today: add an `overrides` array to the layer-1 schema and a resolver -branch (hours, since no Workload carries one and the estate has no -override-shaped data to migrate. Becomes irreversible once: Workloads come to +branch (hours, since no Process carries one and the estate has no +override-shaped data to migrate. Becomes irreversible once: Processes come to depend on locally restated values, at which point withdrawing the hatch means tracing each one back to a derivation-rule change. ## Consequences - One less concept, and one less thing to get wrong: a derived value cannot be wrong in two places at once, paid by nobody. -- A wrong derivation is now visible as a wrong render for a whole workload class - rather than hidden behind a per-Workload reason, which is what makes it +- A wrong derivation is now visible as a wrong render for a whole process class + rather than hidden behind a per-Process reason, which is what makes it fixable, paid by the rule's author. - Chapter 16's single-authority property runs over **every** surface; the dead-declaration check no longer has an exemption for hand-tuning, paid by diff --git a/docs/adr/model/0032-reconcile-unit-derived.md b/docs/adr/model/0032-reconcile-unit-derived.md index 45b270f..ba6d481 100644 --- a/docs/adr/model/0032-reconcile-unit-derived.md +++ b/docs/adr/model/0032-reconcile-unit-derived.md @@ -9,44 +9,49 @@ rests-on: ["0005"] # The Reconcile Unit is derived from the dependency graph +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every ordering edge in the live reconcile graph is implied either by a declared `dependsOn` edge or by a credential Claim. False if: the live graph carries an edge between two units that no dependency and no Claim between their member -Services implies, and removing that edge breaks a deploy. Settled by: derive the -unit and order for every first-party Service and diff the derived graph against +Applications implies, and removing that edge breaks a deploy. Settled by: derive the +unit and order for every first-party Application and diff the derived graph against the fourteen nodes in `fleet-infra/cluster/flux/clusters/production/kustomizations.yaml`, an edge present on one side only is a counterexample. ## Why `platform.layer` was a free-form string, `schemas/cluster-state.schema.json:20` -types it `"type": "string"` with no enumeration, and every Service declared +types it `"type": "string"` with no enumeration, and every Application declared `apps-core`. Not one of them reconciled there: `auth-api`, `agents-api` and `app-ui` land in `apps-stateless`, `knowledge` in `apps-knowledge`, `agent-runtime` in `apps-agents`. The field was wrong in every case and nobody -noticed, because it feeds service-registry registration and never placement. A +noticed, because it feeds application-registry registration and never placement. A field that is wrong estate-wide at no cost is a comment, not a declaration. `agents-login`'s objects appear in two Reconcile Units at once, so a -service-level field cannot describe the cluster even in principle: one string +application-level field cannot describe the cluster even in principle: one string cannot name two units. The removal is therefore from the layer-1 authoring -vocabulary, `platform.layer` is deleted from Service Intent, and it extends to +vocabulary, `platform.layer` is deleted from Project Intent, and it extends to the observed side, where the review found the field surviving: `schemas/cluster-state.schema.json:16` still lists `layer` in the `required` set -of each observed service, and it leaves that list. What remains is optional and +of each observed application, and it leaves that list. What remains is optional and observed, the unit the live health document found Flux reconciling an object in, useful only for diffing observation against derivation, never authored, and not part of the pinned snapshot of [0034](0034-cluster-state-pinned-input.md). The derivation is available and exact, because the fourteen-node Flux -`dependsOn` graph is the service dependency graph of -[0020](0020-dependency-edges-carry-surface.md) projected onto Domains: +`dependsOn` graph is the application dependency graph of +[0020](0020-dependency-edges-carry-surface.md) projected onto Projects: `apps-knowledge` depends on `apps-data` because `knowledge` depends on `platform-postgres` and `platform-rabbitmq`; `apps-agents` depends on -`apps-knowledge` because the agent services consume `knowledge`, and on +`apps-knowledge` because the agent applications consume `knowledge`, and on `apps-vso-secrets` because they claim credentials -([0022](0022-grants-live-on-the-service.md)); everything depends on `apps-core`. +([0022](0022-grants-live-on-the-application.md)); everything depends on `apps-core`. The derived unit now has two consumers. For class B, the pack-delivered foundation, it stays a Flux `Kustomization` with a `dependsOn` graph. For class A it is the apply order: an aggregator applies its slice layer by layer in @@ -59,32 +64,32 @@ the number of consumers is. | option | cost if taken | why rejected | |---|---|---| | Keep `platform.layer` as the declaration | ~30 repositories re-author the field, and a validator must check each value against the live graph on every change | It was wrong in 100% of observed cases, and it is not merely wrong but inexpressible for `agents-login`, which spans two units | -| Declare the units centrally in one hand-maintained file (the v2 state) | Every new Service and every new dependency needs a second edit in `fleet-infra`, in a file no Service owner owns; the fourteen nodes stay hand-kept | Two records of one fact drift; the ordering is already implied by `dependsOn` and Claims, so the central copy is a transcription with no independent authority | -| Derive from `dependsOn` only, ignoring Claims | Loses the `apps-agents` → `apps-vso-secrets` edge: agent workloads apply before the Secrets their Claims provision exist, and crash-loop until the next reconcile | A Claim is an ordering edge: a Workload cannot start before the credential it claims is materialised | +| Declare the units centrally in one hand-maintained file (the v2 state) | Every new Application and every new dependency needs a second edit in `fleet-infra`, in a file no Application owner owns; the fourteen nodes stay hand-kept | Two records of one fact drift; the ordering is already implied by `dependsOn` and Claims, so the central copy is a transcription with no independent authority | +| Derive from `dependsOn` only, ignoring Claims | Loses the `apps-agents` → `apps-vso-secrets` edge: agent processes apply before the Secrets their Claims provision exist, and crash-loop until the next reconcile | A Claim is an ordering edge: a Process cannot start before the credential it claims is materialised | ## Reversibility -Undo cost today: reintroduce `platform.layer` into the Service Intent schema and +Undo cost today: reintroduce `platform.layer` into the Project Intent schema and into ~30 authoring repositories, restore `layer` to the `required` list in `schemas/cluster-state.schema.json`, and hand-maintain the fourteen-node graph -again, a day of schema and validator work plus one pull request per service +again, a day of schema and validator work plus one pull request per application repository. The blast radius is authoring, not runtime. Becomes irreversible once: the hand-maintained `kustomizations.yaml` is deleted and the units are rendered instead, because the only recorded ordering is then the derivation's own output, the uniform `apps-core` string is not a fallback to restore. ## Consequences -- A service owner cannot pin their reconcile position; a wrong order is fixed by - correcting the dependency declaration that produced it, paid by the Service +- An application owner cannot pin their reconcile position; a wrong order is fixed by + correcting the dependency declaration that produced it, paid by the Application owner, in one more indirection between symptom and fix. - A dependency cycle becomes a build failure instead of a reconcile deadlock, paid by whoever introduces the cycle, at build time rather than in the cluster. - The fourteen-node graph stops being hand-maintained and is rendered, paid by the platform owner once, in the renderer. - `layer` leaving the cluster-state `required` list is a breaking change for the - one reader that indexes on it, the service registry, paid by that reader's + one reader that indexes on it, the application registry, paid by that reader's owner. - Two consumers must move together: a change to the derivation moves the class B Kustomization DAG and the class A apply order at the same time, paid by whoever changes the derivation, in testing both. -- A Service's objects may split across units with no declaration saying so, as - `agents-login`'s do, paid by whoever debugs a partially-applied Service. +- An Application's objects may split across units with no declaration saying so, as + `agents-login`'s do, paid by whoever debugs a partially-applied Application. diff --git a/docs/adr/model/0033-assignments-published-back.md b/docs/adr/model/0033-assignments-published-back.md index 01ef3ec..1b975b3 100644 --- a/docs/adr/model/0033-assignments-published-back.md +++ b/docs/adr/model/0033-assignments-published-back.md @@ -9,7 +9,12 @@ rests-on: ["0004"] # Assignments are published back to the owning repository -Composition writes every Service's resolved assignments into that Service's own +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +Composition writes every Application's resolved assignments into that Application's own repository as a generated file and opens a pull request when they change. The file is generated, never hand-edited, and guarded by a drift check that fails the build when it disagrees with a fresh compose. @@ -30,11 +35,11 @@ drift check exits non-zero. ## Why [0004](0004-contention-decides-authority.md) makes contended values -platform-assigned, so a Service owner cannot read their own hostname, +platform-assigned, so an Application owner cannot read their own hostname, namespace, placement or Secret Store paths out of their own repository. Two earlier records reached the same conclusion from opposite ends and neither -specified the mechanism: the contention record's consequences state *"A service -owner cannot read their own service's URL out of their own repository. The +specified the mechanism: the contention record's consequences state *"An application +owner cannot read their own application's URL out of their own repository. The Resolved Deployment must therefore be published back to the owning repository, not merely computed during a render"*, and the exposure record (now [0018](0018-exposure-by-audience.md)), repeats it, noting *"This is now the @@ -63,7 +68,7 @@ drift check is that outcome by construction. | option | cost if taken | why rejected | |---|---|---| | Preview comment only (status quo) | zero new machinery, no write credentials, no PR noise | it fires only when the owner opens a pull request; the `auth-api` → `knowledge` case produces no pull request in `knowledge`, so the one class of change publish-back exists for is exactly the class it misses | -| A queryable read-only view of all assignments | a service to build, host, authenticate and keep available, including during the incident when someone needs it | nothing arrives; the consumer must already know to look, and not knowing to look is the failure mode | +| A queryable read-only view of all assignments | an application to build, host, authenticate and keep available, including during the incident when someone needs it | nothing arrives; the consumer must already know to look, and not knowing to look is the failure mode | | Commit straight to the default branch, no pull request | saves review latency and roughly one workflow step per repository | the change lands unseen; the diff *is* the notification, and a silent commit buys the file without buying the visibility | | Publish the file without a drift check | saves one check invocation per CI run | a snapshot nobody verifies is worse than no file: it looks authoritative while being stale, which is the `render-local.sh` outcome quoted above | @@ -81,7 +86,7 @@ removing a convenience. ## Consequences - A commit-back mechanism holds write access to every participating repository, so a bug in composition can open a pull request in all of them at once, paid by the platform owner, in credential custody and blast radius -- Pull-request noise is the cost of visibility: an assignment change nobody needed to see still arrives as a review request, paid by every service owner +- Pull-request noise is the cost of visibility: an assignment change nobody needed to see still arrives as a review request, paid by every application owner - The file is a snapshot and goes stale between composes; the drift check is the only thing making it trustworthy, paid by whoever reads it during an incident if the check is ever skipped - Hand-editing generated assignments becomes a build failure rather than silent divergence, paid by owners who used to patch rendered output in place - "What is my hostname, namespace, or Vault path" becomes a `grep` in the owner's own checkout, with no render and no cluster access, paid for by the composition run that produces the file diff --git a/docs/adr/model/0034-cluster-state-pinned-input.md b/docs/adr/model/0034-cluster-state-pinned-input.md index 1ca1b2b..d77bf8d 100644 --- a/docs/adr/model/0034-cluster-state-pinned-input.md +++ b/docs/adr/model/0034-cluster-state-pinned-input.md @@ -9,6 +9,11 @@ rests-on: ["0006"] # ClusterState is a pinned, digested input +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on The cluster facts layer-2 assignments need are enumerable and change only on @@ -23,13 +28,13 @@ snapshots; unequal digests mean it is not pinnable. Chapter 20 declares at `spec/v1/20-resolved-deployment.md:8-11`, as "the load-bearing property of the whole specification", that *"Every assignment is a -pure function of Service Intent, the pinned Platform Intent, and the pinned +pure function of Project Intent, the pinned Platform Intent, and the pinned locks"*, then breaks it in its own normative `ResolvedService` example. At `:246-249`, under `assigned:`, sits `observed: {node: enschede-t1000-1, because: knowledge-vault-clone PV is bound here, moveRequires: state-move-plan}`, while `inputDigests` at `:217` is `{intent, imagesLock}` with `contextRef` alongside at `:216`. That PV binding is in none of them; it was read live. The collision -recurs across chapters: `spec/v1/10-service-intent.md:463` assigns `replicas` +recurs across chapters: `spec/v1/10-project-intent.md:463` assigns `replicas` "from `minAvailable` and capacity", while `spec/v1/20-resolved-deployment.md:266` says such an assignment "would violate purity outright". Two lenses found this (RED-002; DAT-006/7, B2). diff --git a/docs/adr/model/0035-network-policy-default-deny.md b/docs/adr/model/0035-network-policy-default-deny.md index f034000..0333fad 100644 --- a/docs/adr/model/0035-network-policy-default-deny.md +++ b/docs/adr/model/0035-network-policy-default-deny.md @@ -9,9 +9,14 @@ rests-on: ["0005", "0002"] # Network policy is default-deny, derived from the edge set +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -The flows a workload legitimately needs are exactly its declared edges plus a -fixed platform baseline that no Service authors. False if: a flow observed +The flows a process legitimately needs are exactly its declared edges plus a +fixed platform baseline that no Application authors. False if: a flow observed during the audit window matches no edge and no baseline rule, yet is legitimate, the correct fix being a new baseline rule, not a missing edge. Settled by: render the estate's policy set, load it into the non-enforcing stage @@ -21,7 +26,7 @@ NetworkPolicy carrying `Egress` in `policyTypes` also matches UDP/53. ## Why Opt-in enforcement has already been measured on this estate and it lost. Three -NetworkPolicy objects exist for roughly thirty workloads, so the cluster is +NetworkPolicy objects exist for roughly thirty processes, so the cluster is effectively open east-west; three of thirty is the realistic adoption rate for an opt-in control, and the number is the argument. Default-deny is only expressible because the edge set is complete: an edge names the provider, the @@ -44,7 +49,7 @@ lands, default-deny does not ship. Promotion also gets the number the estate lacked, `spec/v1/00-overview.md:162-163` recorded only that "The criterion for promoting to enforce is unstated": **zero undeclared flows over 14 days**. -The derivation also carries a platform baseline no Service authors, and the dead +The derivation also carries a platform baseline no Application authors, and the dead renderer generation shows why. `providerPolicy` in `src/deployment/render/networkpolicy.ts:86-102` emits an egress policy whose only rule is to the provider's pod; once any policy with `policyTypes: [Egress]` @@ -58,9 +63,9 @@ the spec chapter carries. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Keep policy opt-in, one flag per Service | Zero migration, no baseline needed, no CNI dependency | Measured: three policies for ~30 workloads is what opt-in produces here | +| Keep policy opt-in, one flag per Application | Zero migration, no baseline needed, no CNI dependency | Measured: three policies for ~30 processes is what opt-in produces here | | Default-deny straight to enforce, no audit stage | Unblocks now; no CNI evaluation | Severs the undeclared east-west paths this estate is known to contain, at first render, on one node with no second control plane to debug from | -| One allow-all-within, deny-across policy per namespace | One object per namespace; no edge set needed | a domain's namespace holds every Service in that domain ([0063](0063-intent-authored-per-domain.md)), so the namespace is not the trust boundary | +| One allow-all-within, deny-across policy per namespace | One object per namespace; no edge set needed | a project's namespace holds every Application in that project ([0063](0063-intent-authored-per-project.md)), so the namespace is not the trust boundary | | Replace the audit stage with flow logs off the existing Alloy/Loki pack | A pipeline to build; weeks of work | A flow log says a connection happened, not that the rendered policy would have dropped it. It cannot produce the promotion number | ## Reversibility @@ -70,14 +75,14 @@ promotion gate, hours, blast radius zero: the three hand-written cluster policies are untouched either way. Becomes irreversible once: enforcement is on estate-wide. A change to the edge -shape then re-renders every workload's policy at once, and the only path back is +shape then re-renders every process's policy at once, and the only path back is allow-all per namespace, open east-west in one step, no intermediate posture. ## Consequences - Every legal flow must be declared before promotion; an undeclared path becomes - a broken workload at enforce, paid by the consuming Service's owner. + a broken process at enforce, paid by the consuming Application's owner. - Cluster DNS and the scrape path are baseline rules in the derivation, so no - workload can lose DNS by forgetting one, paid by the renderer, which owns a + process can lose DNS by forgetting one, paid by the renderer, which owns a rule no author can see, and by anyone needing an exception to it. - Default-deny cannot ship before [0036](0036-cni-selection.md); the setup checklist item stays untickable until then, now a stated dependency rather @@ -86,5 +91,5 @@ allow-all per namespace, open east-west in one step, no intermediate posture. calendar before enforce, paid by the security posture in the gap. - A typo in a `surface` name narrows the allow set silently and renders a valid policy, paid by the on-call, who sees a connection timeout, not an error code. -- Roughly thirty workloads each gain policy objects where three exist today, +- Roughly thirty processes each gain policy objects where three exist today, paid by the deployer's apply time and the API server's object count. diff --git a/docs/adr/model/0036-cni-selection.md b/docs/adr/model/0036-cni-selection.md index 8c0b6eb..59f920c 100644 --- a/docs/adr/model/0036-cni-selection.md +++ b/docs/adr/model/0036-cni-selection.md @@ -10,11 +10,16 @@ rests-on: ["0002"] # The CNI is chosen for a non-enforcing policy stage +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Cilium fits these nodes on the pinned k3s version, and its audit stage reports an undeclared flow without dropping it. False if: steady-state agent memory or -CPU per node displaces workloads on the single control-plane host, or the agent +CPU per node displaces processes on the single control-plane host, or the agent will not come up on the pinned version with flannel and the bundled policy controller disabled. Settled by: a lab-cluster evaluation on that version: install with `--flannel-backend=none --disable-network-policy`, sample @@ -28,7 +33,7 @@ The old dependency-edges ADR rested the whole mitigation for default-deny on a stage that does not exist: *"Default-deny must ship in audit mode first. Any connection that exists but is not declared breaks the moment enforcement lands"*, on a cluster it says *"is known to contain undeclared paths"*, with -three NetworkPolicy objects for roughly thirty workloads. The setup checklist +three NetworkPolicy objects for roughly thirty processes. The setup checklist made that stage a hard precondition for the first production apply (`60-setup.md:153`). But `networking.k8s.io/v1` NetworkPolicy has no audit, dry-run or log-only mode (a policy is enforced the moment it selects a pod) @@ -58,9 +63,9 @@ direction is fixed; the fit is open. | option | cost if taken | why rejected | |---|---|---| -| Keep the k3s default (flannel plus the bundled policy controller) | nothing to install; `60-setup.md:153` is deleted instead of satisfied, and default-deny across ~30 workloads with 3 existing policies lands as enforce at the first render, on a node with no second control plane to debug from | it ships precisely the failure the old ADR named and then claimed to have mitigated | +| Keep the k3s default (flannel plus the bundled policy controller) | nothing to install; `60-setup.md:153` is deleted instead of satisfied, and default-deny across ~30 processes with 3 existing policies lands as enforce at the first render, on a node with no second control plane to debug from | it ships precisely the failure the old ADR named and then claimed to have mitigated | | Calico | comparable install and per-node cost; staged policies give the non-enforcing stage | no per-flow observation surface, so the 14-day zero-undeclared-flows criterion needs a second tool or packet capture to evaluate | -| Ship enforce, gated on a hand-built flow inventory | days of sampling per workload, repeated whenever the estate changes | a snapshot, not continuous evidence; a path appearing after the sample is a production outage, the risk the audit stage exists to remove | +| Ship enforce, gated on a hand-built flow inventory | days of sampling per process, repeated whenever the estate changes | a snapshot, not continuous evidence; a path appearing after the sample is a production outage, the risk the audit stage exists to remove | ## Reversibility diff --git a/docs/adr/model/0037-composition-oci-fragments.md b/docs/adr/model/0037-composition-oci-fragments.md index e41f105..1107c4e 100644 --- a/docs/adr/model/0037-composition-oci-fragments.md +++ b/docs/adr/model/0037-composition-oci-fragments.md @@ -9,6 +9,11 @@ rests-on: ["0001", "0005"] # Declarations compose from published OCI fragments +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on No single repository can evaluate the estate-wide properties this specification @@ -18,18 +23,18 @@ cannot run, seven, tabled in [chapter 40](../../../spec/v1/40-composition.md). ## Why -Each domain repository publishes its own declarations (Service Intents, its +Each project repository publishes its own declarations (Application Intents, its Secret Subtree, node facts, test projects) as an OCI artifact on release. Composition resolves the current set at render time, unions it, asserts the estate-wide invariants, and records every resolved digest in the lock beside the -render. Seven properties, spread across six earlier decisions, need that global view: estate-wide Service Id -uniqueness ([0010](0010-flat-service-identity.md)), Secret Subtree union with +render. Seven properties, spread across six earlier decisions, need that global view: estate-wide Application Id +uniqueness ([0010](0010-flat-application-identity.md)), Secret Subtree union with prefix-collision rejection ([0023](0023-grant-unit-is-the-path.md)), reconcile DAG construction ([0032](0032-reconcile-unit-derived.md)), hostname uniqueness and reachability completeness ([0018](0018-exposure-by-audience.md)), inbound edges for co-test sets ([0020](0020-dependency-edges-carry-surface.md)), and test project discovery ([0049](../deferred/0049-aggregator-owned-tests.md)). None works -against one repository, because no Service knows its own consumers, the +against one repository, because no Application knows its own consumers, the totality [0005](0005-derivation-is-total.md) demands of derivation. Git submodules were the obvious candidate, rejected on the estate's own @@ -60,7 +65,7 @@ change takes effect**, and keeps a render reproducible from recorded digests. | Git submodules, pointers bumped centrally | A pointer-bump PR per declaration change, the sync bot that already exists to make those bumps happen, and up to a day of recorded drift | Does not remove the central merge, makes it mandatory for every change | | Live discovery by repository topic | No pinning, so no reproducible render; a repo missing its topic lands in `inbox/` and contributes nothing, silently | Reproducibility, and a failure mode the estate has already observed | | Each fragment pins its peers (lock as input) | Every fragment must carry digests that do not exist at authoring time | Mechanically impossible; `packageDigest: ""` is the evidence in the tree | -| One declarations directory in this repository | Every domain merges here before a change takes effect; independent release cadence is gone | Contradicts [0001](0001-estate-scale-and-ownership.md). It would delete OCI publication, `lockChain`, the participants list, dormancy and both participant errors, and stays the fallback if that premise fails | +| One declarations directory in this repository | Every project merges here before a change takes effect; independent release cadence is gone | Contradicts [0001](0001-estate-scale-and-ownership.md). It would delete OCI publication, `lockChain`, the participants list, dormancy and both participant errors, and stays the fallback if that premise fails | ## Reversibility @@ -74,9 +79,9 @@ needs a merge in every one of them, the cost this decision was taken to avoid. ## Consequences -- A render is only as current as the last publish; a domain that has not - published does not contribute, paid by the domain repository owner. -- Because Flux prunes, a silently omitted domain is deleted from the cluster on +- A render is only as current as the last publish; a project that has not + published does not contribute, paid by the project repository owner. +- Because Flux prunes, a silently omitted project is deleted from the cluster on the next reconcile while the render still validates, so the participants list and its staleness bound ([0038](0038-participants-list-staleness.md)) are load-bearing rather than hygiene, paid by the estate owner. @@ -88,9 +93,9 @@ needs a merge in every one of them, the cost this decision was taken to avoid. so the invariants are evaluated exactly once, paid by the aggregator. - Each participant needs a publish workflow and credentials, and debugging means resolving digests, not reading a tree, paid by that owner and by on-call. -- The fragment unit is the domain file, not the repository: one domain file is - one Intent Fragment ([0063](0063-intent-authored-per-domain.md)), one - repository may hold several, and a domain never spans repositories, so the - union is over domains and a domain has exactly one publisher, paid by the - repository owner, who publishes one fragment per domain held rather than one +- The fragment unit is the project file, not the repository: one project file is + one Intent Fragment ([0063](0063-intent-authored-per-project.md)), one + repository may hold several, and a project never spans repositories, so the + union is over projects and a project has exactly one publisher, paid by the + repository owner, who publishes one fragment per project held rather than one per repository. diff --git a/docs/adr/model/0038-participants-list-staleness.md b/docs/adr/model/0038-participants-list-staleness.md index f310d9a..5f6bfb0 100644 --- a/docs/adr/model/0038-participants-list-staleness.md +++ b/docs/adr/model/0038-participants-list-staleness.md @@ -9,14 +9,19 @@ rests-on: ["0001"] # Participants are listed, bounded by seven days of staleness +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** The Platform document is a **required participant** > under the same seven-day bound > ([0095](0095-platform-intent-is-the-second-authored-document.md)): it -> publishes as an Intent Fragment like any domain, so a stale platform is +> publishes as an Intent Fragment like any project, so a stale platform is > `E_PARTICIPANT_STALE` where before it was a digest nobody compared to a clock. ## Rests on -A domain that has published nothing for seven days has stopped publishing by +A project that has published nothing for seven days has stopped publishing by fault, not by cadence. False if: a non-dormant participant routinely goes more than seven days between publishes while everything about it is healthy, then the bound fires on normal operation and gets ignored, which is worse than no @@ -27,31 +32,31 @@ exceeds 7 days. Baseline today: `CHANGELOG.md` records 26 releases between 2026-06-09 and 2026-08-20, 72 days, one every 2.8 days. ## Why -A render is only as current as the last publish, and a domain that has not +A render is only as current as the last publish, and a project that has not published does not contribute. That absence is dangerous here specifically -because **Flux prunes**: a domain silently omitted from a render is a domain +because **Flux prunes**: a project silently omitted from a render is a project deleted from the cluster on the next reconcile, and the render would look entirely valid. `E_PARTICIPANT_MISSING` and `E_PARTICIPANT_STALE` are what stand between a missed publish and a deletion. Apply-before-prune under [0042](../deferred/0042-apply-before-prune-inventory.md) does not cover this: it makes prune -run over an inventory that is *correct*, and a render missing a whole domain is +run over an inventory that is *correct*, and a render missing a whole project is correct, it simply does not contain it. Deriving the expected set from inbound references was considered and is -insufficient. A leaf Service that nothing depends on can vanish without breaking +insufficient. A leaf Application that nothing depends on can vanish without breaking any reference, and leaves are the majority: `immich`, `jellyfin`, `sonarr`, -`radarr`, `bazarr`, `prowlarr`, `qbittorrent`. Those seven media services are +`radarr`, `bazarr`, `prowlarr`, `qbittorrent`. Those seven media applications are depended on by nothing, so an inbound-edge derivation notices none of them going missing. The expected set is therefore enumerated, and that enumeration is the one central artefact that survives composition by fragments -([0037](0037-composition-oci-fragments.md)): it changes when a domain or test +([0037](0037-composition-oci-fragments.md)): it changes when a project or test project is added or retired, never when a declaration changes. The old decision said "with a staleness bound" and the review flagged the adjective. It is not academic: `spec/v1/40-composition.md:209` currently reads -`intent-media: {maxAge: 180d}`, so the media domain's publishing pipeline can be +`intent-media: {maxAge: 180d}`, so the media project's publishing pipeline can be broken for half a year (roughly 64 observed release intervals), before the -error that protects seven services from deletion fires. The default is +error that protects seven applications from deletion fires. The default is therefore **7 days**, about 2.5 observed intervals: long enough to absorb two consecutive missed releases (2.5 x the observed 2.8-day interval), short enough that a broken publish job is caught in the same week it breaks. A participant may override it with a @@ -64,31 +69,31 @@ version range of [0039](0039-artifact-schema-versioning.md). ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| No list; compose whatever published | Zero to build. A domain whose publish job breaks is pruned from the cluster on the next reconcile, from a render that passes every one of the 26 invariants | Makes a broken pipeline indistinguishable from a retirement, at the one moment the consequence is deletion | +| No list; compose whatever published | Zero to build. A project whose publish job breaks is pruned from the cluster on the next reconcile, from a render that passes every one of the 26 invariants | Makes a broken pipeline indistinguishable from a retirement, at the one moment the consequence is deletion | | Derive the expected set from inbound dependency edges | Free, the edge graph already exists in chapter 16. Costs the seven media leaves, which have no inbound edges and would vanish undetected | The majority of the estate is leaves; a guard blind to the majority is not a guard | -| Keep the written 30/90/180-day per-domain bounds | No edit. `intent-media` tolerates a 180-day outage, `intent-nodes` 30 | A bound 64× the interval it protects fires only after the damage it exists to prevent | -| Let `dormant: true` exempt the version check too | Removes the republish burden on a domain nobody is touching | A dormant fragment still unions into `ComposedIntent`; exempting it means a fragment the toolkit cannot read is merged anyway | +| Keep the written 30/90/180-day per-project bounds | No edit. `intent-media` tolerates a 180-day outage, `intent-nodes` 30 | A bound 64× the interval it protects fires only after the damage it exists to prevent | +| Let `dormant: true` exempt the version check too | Removes the republish burden on a project nobody is touching | A dormant fragment still unions into `ComposedIntent`; exempting it means a fragment the toolkit cannot read is merged anyway | ## Reversibility Undo cost today: `participants.yml` is one file plus the two error paths in the composition resolver that read it. Deleting it removes a guard, not a capability, no rendered manifest changes, no aggregator is touched, an afternoon. Changing the number alone is a one-line default. Becomes irreversible once: retiring a -domain is performed *by* deleting its row, at which point the list is the -estate's only enumeration of expected domains, with nothing left to rebuild it. +project is performed *by* deleting its row, at which point the list is the +estate's only enumeration of expected projects, with nothing left to rebuild it. ## Consequences - A publishing pipeline that breaks fails composition within seven days instead - of deleting a domain from the cluster, paid by the domain owner, who gets a + of deleting a project from the cluster, paid by the project owner, who gets a red compose rather than a silent restore. - One stale participant blocks every aggregator, including the one shipping the fix (OPS-009); the remaining lever is break-glass with an older lock under - [0045](../deferred/0045-break-glass-reporting.md), paid by every other domain owner. -- A domain that genuinely publishes less often than weekly must carry an - override with a written reason, paid by that domain's owner. + [0045](../deferred/0045-break-glass-reporting.md), paid by every other project owner. +- A project that genuinely publishes less often than weekly must carry an + override with a written reason, paid by that project's owner. - Dormancy costs a reason and a review date and is reviewed as a ledger entry, paid by the platform owner at review time. -- A dormant domain must still be republished when it falls out of the accepted +- A dormant project must still be republished when it falls out of the accepted version range, paid by the owner of a repository nobody is otherwise touching, which is the least convenient party. - Seven days is measured against this estate's cadence, not a general constant; diff --git a/docs/adr/model/0039-artifact-schema-versioning.md b/docs/adr/model/0039-artifact-schema-versioning.md index 983565c..7a3ec20 100644 --- a/docs/adr/model/0039-artifact-schema-versioning.md +++ b/docs/adr/model/0039-artifact-schema-versioning.md @@ -9,6 +9,11 @@ rests-on: ["0007"] # The artifact schema is semver; composition accepts a range +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on A minor model bump only adds vocabulary, so a fragment written against minor *m* renders identically under every toolkit minor ≥ *m* within the same major. @@ -49,14 +54,14 @@ toolkit stays a hard stop because the union is silent about what it drops: a fragment using vocabulary an older toolkit cannot read would compose with the unknown fields discarded and exit zero. Two spec artifacts become legitimate rather than accidental here: `spec/v1/40-composition.md:237` and -`spec/v1/10-service-intent.md:36` both write `schemaVersion: 1.0.0`, +`spec/v1/10-project-intent.md:36` both write `schemaVersion: 1.0.0`, satisfiable today only while the package sits at `1.0.0`: it is `0.22.0`. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Equality over the union (the status quo assert) | 26 releases in ten weeks × ~10 Renovate PRs each, all red until both contexts republish; one stale or dormant participant halts every aggregator including the one carrying the fix | The estate already demonstrates skew is survivable: `0.16.0` in four service repos, `0.20.0` in `stalwart-provisioner`, `0.22.0` in the contexts, and it functions; the assert forbids what reality tolerates and fails closed over the whole union | -| Same major, any minor (caret range, minor > toolkit allowed) | A fragment at `1.4.0` composed by a `1.2.0` toolkit renders with the unrecognised fields dropped; a declared volume, grant or dependency edge silently leaves the tree and every gate passes | Trades a loud estate-wide stop for a silent per-Service under-render, which no digest, exit code or ledger detects; a stop is recoverable, a missing PVC is not | +| Equality over the union (the status quo assert) | 26 releases in ten weeks × ~10 Renovate PRs each, all red until both contexts republish; one stale or dormant participant halts every aggregator including the one carrying the fix | The estate already demonstrates skew is survivable: `0.16.0` in four project repos, `0.20.0` in `stalwart-provisioner`, `0.22.0` in the contexts, and it functions; the assert forbids what reality tolerates and fails closed over the whole union | +| Same major, any minor (caret range, minor > toolkit allowed) | A fragment at `1.4.0` composed by a `1.2.0` toolkit renders with the unrecognised fields dropped; a declared volume, grant or dependency edge silently leaves the tree and every gate passes | Trades a loud estate-wide stop for a silent per-Application under-render, which no digest, exit code or ledger detects; a stop is recoverable, a missing PVC is not | | Version the composition rather than each fragment | Every participant republishes whenever the composed model moves, since one number covers all of them | Equality by another name; it reproduces exactly the all-must-merge round this decision removes | | Record the accepted range in the lock instead of exact versions | Lock is smaller and human-readable; replay re-resolves and may legitimately pick a different admitted version | Breaks byte-identical replay at the only moment it matters: reconstructing what production actually ran | diff --git a/docs/adr/model/0040-renovate-ordering-gate.md b/docs/adr/model/0040-renovate-ordering-gate.md index b91f324..b5f3a04 100644 --- a/docs/adr/model/0040-renovate-ordering-gate.md +++ b/docs/adr/model/0040-renovate-ordering-gate.md @@ -9,6 +9,11 @@ rests-on: ["0007"] # Version bumps ride Renovate behind an ordering gate +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every repository holding a version pin already runs Renovate against the file @@ -33,7 +38,7 @@ one PR. It is: publish the new schema, republish the OCI context, then update repo: `stalwart-provisioner`'s PR pipeline never exercises the deploy path."* The 0.20→0.22 bump was proven by replaying `deploy-artifact` by hand, and the first harness reported both configurations failing until a known-good control -was run alongside. Live skew at the time was `0.16.0` in four service repos, +was run alongside. Live skew at the time was `0.16.0` in four project repos, `0.20.0` in `stalwart-provisioner`, `0.22.0` in the contexts; `package.json` still reads `0.22.0`. An earlier attempt to drop the constraint entirely survives as the abandoned `feat/unversioned-contract` branch. diff --git a/docs/adr/model/0052-registered-adapters-are-v1.md b/docs/adr/model/0052-registered-adapters-are-v1.md index 4e80529..db2afcc 100644 --- a/docs/adr/model/0052-registered-adapters-are-v1.md +++ b/docs/adr/model/0052-registered-adapters-are-v1.md @@ -9,6 +9,11 @@ rests-on: ["0003"] # The registered adapters are v1; the second generation is deleted +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Rewritten 2026-09-08** ([0098](0098-one-publication-path.md)). This ADR > records the **rule**, not a count: the registry is the enumeration, nothing > renders that is not registered, and a change to the set is a decision with its @@ -28,7 +33,7 @@ rests-on: ["0003"] > derivations, and moved `flux-root` to the deferred set; and > [0096](0096-the-foundation-is-declared.md) removed what `flux-packs` and > `flux-source` rendered. An `rbac` adapter is **not** coming: -> [0075](0075-no-workload-rbac-in-v1.md). The other half of this decision (the +> [0075](0075-no-process-rbac-in-v1.md). The other half of this decision (the > second generation is deleted) is unchanged. ## Rests on diff --git a/docs/adr/model/0053-adapter-port-contract.md b/docs/adr/model/0053-adapter-port-contract.md index e386998..235df71 100644 --- a/docs/adr/model/0053-adapter-port-contract.md +++ b/docs/adr/model/0053-adapter-port-contract.md @@ -9,6 +9,11 @@ rests-on: ["0003"] # An adapter satisfies one typed port +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** The port has one input shape because there is one > kind of adapter left: every adapter is central and receives the Resolved > Deployment ([0098](0098-one-publication-path.md)). The five publish-time @@ -59,8 +64,8 @@ while `tsconfig.json:11` sets `"strict": true` and `eslint.config.js:33` sets violated. `grep -rn E_PATH_COLLISION src/` returns nothing, while the writer applies each prepared file in turn (`src/render-plan/writer.ts:60-66`): two adapters sharing a path both write, second wins, silently, both reporting -`action: "create"`. `kubernetes-workload-fragment` reads raw manifests from disk -inside render (`src/adapters/kubernetes-workload-fragment.ts:48-49`, `:236-247`); +`action: "create"`. `kubernetes-process-fragment` reads raw manifests from disk +inside render (`src/adapters/kubernetes-process-fragment.ts:48-49`, `:236-247`); under the port that read moves to the caller, which passes the parsed documents in, as `loadFragmentInput` already does at `src/adapters/fragment-model.ts:95-98`. Hence the contract: documents in, attributed Deliverables out, deterministic, no diff --git a/docs/adr/model/0054-adapter-attribution.md b/docs/adr/model/0054-adapter-attribution.md index 3cc5dc0..1ab8972 100644 --- a/docs/adr/model/0054-adapter-attribution.md +++ b/docs/adr/model/0054-adapter-attribution.md @@ -9,10 +9,15 @@ rests-on: ["0003"] # Every Deliverable is attributed to exactly one Adapter +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** Attribution is per **Deliverable**, and every adapter > is central ([0098](0098-one-publication-path.md)): nothing is emitted per -> Service repository at publish time any more, so the "one Fragment per Adapter -> per Service" this text once described no longer exists. The path an adapter +> Project repository at publish time any more, so the "one Fragment per Adapter +> per Application" this text once described no longer exists. The path an adapter > declares is what the path plan assigns from > ([0070](0070-path-authority-is-layer-2.md)). The sixteen-adapter evidence below > is the state this decision was taken in. @@ -51,7 +56,7 @@ consumers to shape it. One owner per path is asserted today and enforced nowhere. Five of the sixteen registered names end in `-fragment` and shadow an earlier adapter, chapter 30 -names four such pairs and misses `kubernetes-workload-fragment`; the twins avoid +names four such pairs and misses `kubernetes-process-fragment`; the twins avoid a literal collision only because they write under `fragments/` while the first generation writes under `platform/cluster/flux/`. Three adapters (`kubernetes`, `flux-packs`, `flux-source`) declare the same `platform/cluster/flux/apps` prefix. @@ -82,5 +87,5 @@ every live object by hand. - Each Adapter must become total for its target subsystem, and today none are, paid by the adapter owner. - The totality gap must be measured **per adapter against the registered generation** ([0052](0052-registered-adapters-are-v1.md)) before any schedule is committed; the old count of 342 files under `fleet-infra/cluster` measured the cluster tree instead of the registry and is not the number to plan against, paid by the programme. - `E_PATH_COLLISION` must be implemented (on the path plan, before any adapter runs) before the coverage assertion can be enforced; the five `-fragment` twins that made it urgent are deleted by [0098](0098-one-publication-path.md), paid by the toolkit maintainer. -- A file no adapter claims cannot ship silently: it becomes a ledger entry with an owner and a reason ([0055](0055-bidirectional-ledgers.md)), paid by the Service owner. +- A file no adapter claims cannot ship silently: it becomes a ledger entry with an owner and a reason ([0055](0055-bidirectional-ledgers.md)), paid by the Application owner. - A reviewer reading a Deliverable diff can name the producing subsystem without reading render code, paid by the adapter owner, who must declare and defend a unique `defaultPath` on every registry entry. diff --git a/docs/adr/model/0055-bidirectional-ledgers.md b/docs/adr/model/0055-bidirectional-ledgers.md index 8e366be..025e071 100644 --- a/docs/adr/model/0055-bidirectional-ledgers.md +++ b/docs/adr/model/0055-bidirectional-ledgers.md @@ -9,6 +9,11 @@ rests-on: ["0003"] # Every accepted hole is a bidirectional ledger +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** Two of the three ledgers this ADR names are unchanged. > The third (registered unmanaged surfaces) now holds only hostnames nobody > deploys and nobody depends on: a provider the estate reaches is a **fact** in @@ -43,7 +48,7 @@ permanent exemption."* That is the best-designed artefact in the estate, and the only thing this decision does is generalise it to every other accepted gap. Both halves are load-bearing because the gaps are large and are meant to shrink. -Of 450 live objects, 364 are derivable from Service Intent, 41 are pack-delivered +Of 450 live objects, 364 are derivable from Project Intent, 41 are pack-delivered and 45 are authored content; of the 364, chapter 30 recorded **328 with a producing adapter and 36 without** (`spec/v1/30-deliverables.md:77-89`), a count [0052](0052-registered-adapters-are-v1.md) shows is wrong on two of its four rows, @@ -51,7 +56,7 @@ so the gap is re-derived from `adapterContract()` before it is planned against; whatever its size, it is large enough that the ledger must shrink on its own. Class C is 31 `GrafanaDashboard` and 14 `GrafanaFolder`; chapter 50 assigns every one of them (14 as Assets, 3 to the Runtime Profile, 14 to the observability -pack, 2 derived per Service) so class C is ledgered only until those owners land, +pack, 2 derived per Application) so class C is ledgered only until those owners land, not permanently. A list failing only on absence would carry those entries after the adapters closing them are registered under [0052](0052-registered-adapters-are-v1.md), and no build would say so. The stale half makes the ledger shrink on its own. @@ -63,8 +68,8 @@ value ([0031](0031-derived-overrides-with-reason.md)). Each entry carries an owner, a reason and a review date, and a date in the past fails the build like an unmatched entry: a review date with no consequence is an adjective. One live drift entry marks where the limit genuinely is: `agent-gateway` cannot be probed -because it is *"a sidecar jar inside agent-runner pods, not a workload of its -own"*, and its per-runner Services are created and destroyed by `agents-api` at +because it is *"a sidecar jar inside agent-runner pods, not a process of its +own"*, and its per-runner Applications are created and destroyed by `agents-api` at runtime. Some objects are outside any declarative model; the ledger is where they belong, with a reason rather than with silence. @@ -89,7 +94,7 @@ hand. ## Consequences - Accepting a hole costs an owner, a reason and a review date at the moment of - acceptance, not later: paid by the service owner taking the exception. + acceptance, not later: paid by the application owner taking the exception. - Closing a hole is two changes, register and delete, and forgetting the second breaks the build: paid by whoever registers the adapter. - Class C entries expire only when their chapter-50 owner lands, so they sit in diff --git a/docs/adr/model/0056-node-facts-single-source.md b/docs/adr/model/0056-node-facts-single-source.md index adf8b7b..97a3a0d 100644 --- a/docs/adr/model/0056-node-facts-single-source.md +++ b/docs/adr/model/0056-node-facts-single-source.md @@ -9,6 +9,11 @@ rests-on: ["0005"] # Node facts are authored once; nix imports them +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every node fact the host build needs can be read from generated data at nix @@ -65,7 +70,7 @@ them. | option | cost if taken | why rejected | |---|---|---| | Keep the three declarations and the drift machinery | every node change stays three edits across two repositories in two casings, and `specs/002-node-contract-drift` plus `scripts/audit-node-labels.sh` stay funded and green forever | a detector that finds the copies disagreeing is cheaper to delete than to run; it reports the problem it exists because of | -| Invert it: nix is the source, the YAML contract generated from `.nix` | composition must evaluate a flake to learn a node’s capabilities, so nix enters CI for every consumer that resolves placement | node facts become readable only through a toolchain no service repository has, and the fact is needed by the resolver, not the machine builder | +| Invert it: nix is the source, the YAML contract generated from `.nix` | composition must evaluate a flake to learn a node’s capabilities, so nix enters CI for every consumer that resolves placement | node facts become readable only through a toolchain no project repository has, and the fact is needed by the resolver, not the machine builder | | Keep `homelab-inventory` authoritative, retain the nix inventory as a cache, keep both label prefixes | two writable copies survive so the audit script survives with them, and 110 labels on 7 nodes persist with half under a name nobody can correct at its source | the second writable copy *is* the drift, and the archived-repository name outlives every attempt to explain it | ## Reversibility @@ -73,7 +78,7 @@ them. Undo cost today: `git revert` restores `nix-config/inventory/` in minutes, but re-authoring labels into 7 `nix/hosts//default.nix` files, re-splitting the casings and rewriting the drift spec and audit script to match is a day; blast -radius is a nix rebuild per host and no service repository either way. Becomes +radius is a nix rebuild per host and no project repository either way. Becomes irreversible once: the live nodes are relabelled to the single surviving prefix through the generated contract, the hand-authored files then no longer describe the cluster, and reverting means relabelling 7 nodes back. @@ -91,10 +96,10 @@ describe the cluster, and reverting means relabelling 7 nodes back. `kubectl describe node`; on the 4096Mi `enschede-pi-2` and `enschede-pi-3` it is a large fraction of the node, and a wrong guess bites there first (a pod the contract says fits and the scheduler refuses), paid by the platform owner. -- Retiring `personal-stack/*` touches no service repository but does mean +- Retiring `personal-stack/*` touches no project repository but does mean relabelling live nodes through the contract, paid by the platform owner. - The selector key comes from the contract’s prefix rather than `platform.name`, - changing output for every workload carrying a `nodeSelector`, paid by + changing output for every process carrying a `nodeSelector`, paid by adapter maintainers. - One casing wins, so consumers reading `cpu_millicores` are repointed at the contract, paid by the owners of the three downstream artifacts. diff --git a/docs/adr/model/0057-datastore-and-restore.md b/docs/adr/model/0057-datastore-and-restore.md index c594255..3d93ea7 100644 --- a/docs/adr/model/0057-datastore-and-restore.md +++ b/docs/adr/model/0057-datastore-and-restore.md @@ -10,6 +10,11 @@ rests-on: ["0002"] # Datastore, server count, and restore are recorded platform facts +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** The facts live in the Platform document's `substrate` > block, named for what they are (`datastore`, `serverCount`, > `kubernetesVersion`, `secretsEncryption`, `cni`, `networkPolicyController`) @@ -20,7 +25,7 @@ rests-on: ["0002"] > an observation; it appears in no authored file. ## Rests on -A `local-path` PersistentVolume can be restored to a running Workload from the +A `local-path` PersistentVolume can be restored to a running Process from the daily node backup, and the time that takes is measurable. False if: a drill cannot reconstruct the volume at all: no per-claim file exists in the off-cluster copy, or the most recent node backup is already younger than the @@ -33,7 +38,7 @@ record rather than adjusting it. ## Why None of the three facts is expressible in any schema here. -`schemas/platform.schema.json` requires only `version`, `name` and `domain`; its +`schemas/platform.schema.json` requires only `version`, `name` and `project`; its `cluster` object carries `kind` (`k3s | kubernetes | custom`), `api` and `bootstrap`, and nothing else. `$defs/host.roles` is an unconstrained array of identifiers, so `k3s-control-plane` is a spelling convention rather than a @@ -74,7 +79,7 @@ So the facts get a home and the restore gets a rehearsal. Platform intent gains four required cluster facts (datastore kind, server count, k3s version, and the server flag set) read by the pre-flight and by any decision that turns on them, instead of discovered by an ssh session per question. RPO is stated rather than -measured: **24 hours**, the daily node backup's period, and a Workload wanting +measured: **24 hours**, the daily node backup's period, and a Process wanting better declares its own application-level backup, which is what `recoverable` renders under [0015](0015-durability-class-per-volume.md). RTO is not asserted at all: the drill produces it or there is no number. And an eighth checklist diff --git a/docs/adr/model/0059-v1-scope-stopping-rule.md b/docs/adr/model/0059-v1-scope-stopping-rule.md index 9cf2c74..b32076b 100644 --- a/docs/adr/model/0059-v1-scope-stopping-rule.md +++ b/docs/adr/model/0059-v1-scope-stopping-rule.md @@ -10,6 +10,11 @@ rests-on: ["0001"] # v1 has a scope and a stopping rule +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** The stopping clause reads: v1 ships when it renders > the live estate, foundation included > ([0096](0096-the-foundation-is-declared.md)), from declared intent, and that @@ -67,7 +72,7 @@ which no longer gates v1. Undo cost today: move files between `docs/adr/` and `deferred/` and update two READMEs, minutes. Becomes irreversible: it does not; the boundary is the one deliberately cheap-to-move line in the set. What does harden is the model's -three demands: once service repositories author Release Units and Durability +three demands: once project repositories author Release Units and Durability Classes, a delivery definition that ignores them breaks declared intent. ## Consequences diff --git a/docs/adr/model/0060-release-unit.md b/docs/adr/model/0060-release-unit.md index b12920f..2654817 100644 --- a/docs/adr/model/0060-release-unit.md +++ b/docs/adr/model/0060-release-unit.md @@ -4,26 +4,31 @@ superseded-by: 0062 claim: open owner: joris date: 2026-09-07 -normative: spec/v1/10-service-intent.md#service-identity +normative: spec/v1/10-project-intent.md#application-identity rests-on: ["0003", "0005"] --- -# Several Services switch as one Release Unit +# Several Applications switch as one Release Unit + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. > **Superseded, and its claim is moot.** This decision was superseded by -> [0062](0062-service-is-the-release-unit.md) before its settling test ran, so +> [0062](0062-application-is-the-release-unit.md) before its settling test ran, so > the `claim: open` in the frontmatter records the state it was in when it was > replaced rather than work outstanding. Nothing settles it; 0062 carries the > question now. The owner stays named because the contract requires one for any > claim other than `settled`, not because there is a task. -Superseded by [0062](0062-service-is-the-release-unit.md): a Service is itself -the unit of atomic release, so a pair that must switch together is one Service +Superseded by [0062](0062-application-is-the-release-unit.md): an Application is itself +the unit of atomic release, so a pair that must switch together is one Application and the `releaseUnit` field this record introduces is deleted rather than specified. ## Rests on -An all-or-nothing multi-Service cutover can be expressed as layer-1 intent plus +An all-or-nothing multi-Application cutover can be expressed as layer-1 intent plus derived health gating, without naming a delivery mechanism. False if: expressing the gate requires delivery-specific vocabulary (a Flux kind, a workflow step, an applier identity) in any layer-1 or layer-2 field. Settled by: render a @@ -32,7 +37,7 @@ mechanisms (today's Flux (health checks on one Kustomization) and any future push applier) and observe both honour it unchanged. ## Why -Some Services are one product in two processes. An API and its frontend ship +Some Applications are one product in two processes. An API and its frontend ship together: a new frontend against an old API, or the reverse, is a broken product even though each pod individually reports healthy. The requirement, stated by the owner on 2026-09-07, is that the model support deploying several @@ -45,7 +50,7 @@ Until now that coupling lived only in delivery machinery, the deferred aggregator design carried a `deploys` list that happened to hold both members. Delivery is now defined separately from the model ([deferred/README.md](../deferred/README.md)), so the model must carry the -coupling itself or lose it. A **Release Unit** carries it: each member Service +coupling itself or lose it. A **Release Unit** carries it: each member Application declares its unit by name in layer 1; composition materialises the set; the derived gate is that **no member's new version receives traffic until every member's new version is healthy**, health meaning the member's own declared @@ -71,7 +76,7 @@ edge would turn the whole estate into one unit. ## Reversibility Undo cost today: delete the field; every member rolls independently again, an hour, no data movement. Becomes irreversible: never structurally, but once -service repositories declare units, removing the concept reintroduces the +project repositories declare units, removing the concept reintroduces the manual release coordination it replaced, one incident at a time. ## Consequences @@ -88,6 +93,6 @@ manual release coordination it replaced, one incident at a time. definition. - Rollback is unit-scoped: reverting one member means reverting the unit, paid by incident responders, in larger but consistent rollback scope. -- A Service belongs to at most one unit, and a unit spanning deployer +- An Application belongs to at most one unit, and a unit spanning deployer boundaries is invalid by construction once delivery is defined, paid by authors, in one more composition invariant. diff --git a/docs/adr/model/0061-placement-is-hard-dimensions.md b/docs/adr/model/0061-placement-is-hard-dimensions.md index 2fb2607..d935872 100644 --- a/docs/adr/model/0061-placement-is-hard-dimensions.md +++ b/docs/adr/model/0061-placement-is-hard-dimensions.md @@ -4,15 +4,20 @@ status: proposed claim: open owner: joris date: 2026-09-07 -normative: spec/v1/10-service-intent.md#placement +normative: spec/v1/10-project-intent.md#placement rests-on: ["0005"] --- # Placement is a set of hard dimensions matched against allocatable +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on The estate's placement needs are expressible as hard filters with no weighted -preference. False if: a real workload needs to prefer a node without requiring +preference. False if: a real process needs to prefer a node without requiring it, and cannot restate that as a set of acceptable values. Settled by: enumerate every soft term the live estate carries ( `kubectl get deploy,sts,ds -A -o json | jq '..|.preferredDuringSchedulingIgnoredDuringExecution? // empty'` @@ -24,7 +29,7 @@ half of [0016](0016-pod-hardening.md), which keeps hardening. `size` and `placement` were independent fields and nothing compared them. Node memory spans 4096Mi on `enschede-pi-2` and `enschede-pi-3` to 32768Mi on `frankfurt-contabo-1`, while the class table put `l` at 2Gi and `xl` at 4Gi with -memory request equal to limit. A `size: l` Workload could sit beside a placement +memory request equal to limit. A `size: l` Process could sit beside a placement admitting only the Pis (2Gi of a 4096Mi node before its reserve, `xl` not fitting at all) and the build passed, failing as `Pending` at apply. @@ -50,17 +55,17 @@ Matching is against **allocatable**: node total minus a declared reserve, published by the node contract ([0056](0056-node-facts-single-source.md)). Never a live read of free capacity, which would put an assignment outside the pinned input set ([0006](0006-pinned-inputs.md)). This is **eligibility, not -bin-packing**: three Workloads declaring `memory: 2Gi` all pass against a +bin-packing**: three Processes declaring `memory: 2Gi` all pass against a 4096Mi node, each compared against allocatable alone, and the scheduler refuses the third at apply. Memory and cpu are contended, so contention decides **who arbitrates**, not **who authors**: [0004](0004-contention-decides-authority.md) -is restated, not waived. The Service states its requirement; the platform +is restated, not waived. The Application states its requirement; the platform decides whether it fits and where. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Keep `size` beside `placement` | two fields answering one question, the class resolved through the Platform Intent and the node facts through the node contract: no build-time comparison without joining them | this is the gap being closed: a `size: l` Workload pinned to a 4096Mi Pi passes the build and goes `Pending` | +| Keep `size` beside `placement` | two fields answering one question, the class resolved through the Platform Intent and the node facts through the node contract: no build-time comparison without joining them | this is the gap being closed: a `size: l` Process pinned to a 4096Mi Pi passes the build and goes `Pending` | | Scored best-match: rank eligible nodes, place on the highest | a term matching nothing scores zero and the pod still places, so the failure mode is a worse node rather than a refusal | hands back the silence the `gtx960m` evidence bought: an unmet term producing a Running pod is precisely what nobody could see | | Named amounts instead of raw quantities | keeps [0004](0004-contention-decides-authority.md) intact as written, and a retune is one Platform Intent edit rather than an edit in every repository, the cost this decision accepts | a class name cannot be compared to a node's allocatable without the table, so the consistency check stays a join; raw was chosen because the comparison is then arithmetic | @@ -74,18 +79,18 @@ request equals limit (incompressible; OOM beats eviction roulette), cpu request and no cpu limit (throttling gets misdiagnosed as slow application code)) with an override and a reason as the escape ([0031](0031-derived-overrides-with-reason.md)). Becomes irreversible once about -thirty repositories carry raw numbers chosen per Workload: returning to classes +thirty repositories carry raw numbers chosen per Process: returning to classes means someone other than the author bucketing each. ## Consequences -- `memory` and `cpu` become required on every Workload (one edit per Service, +- `memory` and `cpu` become required on every Process (one edit per Application, ~30 of them, and BestEffort stops being the standing QoS class) paid by the one maintainer. -- Raising every JVM service from 768Mi to 1Gi is now an edit in every repository +- Raising every JVM application from 768Mi to 1Gi is now an edit in every repository holding one, not one row in the Platform Intent: paid by the one maintainer, on every retune. -- A Workload that could fall back to Frankfurt when the Pis are full must list - both arches or sit `Pending`: paid by service authors, who write the fallback +- A Process that could fall back to Frankfurt when the Pis are full must list + both arches or sit `Pending`: paid by application authors, who write the fallback down instead of weighting it. - `tailscale` leaves the capability vocabulary: on 7 of 7 nodes it excludes nothing, and a filter that never excludes teaches authors that filters do diff --git a/docs/adr/model/0062-application-is-the-release-unit.md b/docs/adr/model/0062-application-is-the-release-unit.md new file mode 100644 index 0000000..483354d --- /dev/null +++ b/docs/adr/model/0062-application-is-the-release-unit.md @@ -0,0 +1,93 @@ +--- +tier: decision +status: proposed +claim: settled +date: 2026-09-07 +normative: spec/v1/10-project-intent.md#application-identity +rests-on: ["0003"] +--- + +# An Application is the unit of atomic release + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +## Rests on +Every lockstep pair in this estate can be one Application without losing a +referencable name. False if: a pair must release together AND both names must be +referencable from outside their project. Settled by: grep every `dependsOn` target +across the composed union: today the complete set is `platform-postgres`, +`platform-rabbitmq`, `stalwart` and `platform-valkey`; neither `auth-api` nor +`auth-ui` nor `stalwart-provisioner` appears, so merging either pair into one +Application breaks no reference. + +## Why +This supersedes [0060](0060-release-unit.md). The requirement survives unchanged: +no member's new version receives traffic until every member is healthy, health +meaning that member's own declared readiness +([0014](0014-probes-are-siblings.md)), and one member failing its startup budget +holds the whole set on old versions. The mechanism changes. The guarantee is +carried by the Application boundary itself rather than by a `releaseUnit` name, which +is deleted. Things that must release together are Processes of one Application, and +there is no mechanism to couple two Applications. + +`releaseUnit` was a cross-repository coupling invisible in any single file: a +reader of `auth-api`'s document saw a name and had to search the composed union +to learn what else answered to it. Under one file per project +([0063](0063-intent-authored-per-project.md)) the coupled things are adjacent +(two entries under one Application's `processes`) and the Application boundary already +means "switches together". The field restated a fact the boundary carried, and +two records of one fact drift. + +The estate's clearest pair costs nothing to merge. `auth-api`'s estate-wide role +is the forward-auth middleware derived from every route's exposure audience +([0018](0018-exposure-by-audience.md)), **not** a `dependsOn` edge, so no +consumer names the id and folding it into Application `auth` breaks no reference. +`stalwart-provisioner` is the same case: nothing depends on it. + +An Application is not a Reconcile Unit ([0032](0032-reconcile-unit-derived.md)). The +Reconcile Unit is derived from the dependency graph and answers ordering: +`platform-postgres` before `knowledge`, where the later unit waits. The Application +is declared, by drawing a boundary, and answers atomicity: every Process +switches or none does, and a failure means nothing switches. Ordering is a graph +property; atomicity is a product judgement the graph cannot see, which is why one +is derived and the other is authored. + +## Alternatives +| option | cost if taken | why rejected | +|---|---|---| +| 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 | + +## Reversibility +Undo cost today: reintroduce an Application-level coupling field and split the merged +Applications back into separate ids, an afternoon of schema work plus one pull +request per project file, no data movement. The split is the expensive half. +Becomes irreversible once: an inbound reference exists to a merged Application's id +(a dependency edge, an exposure route, or a Vault path derived from it) because +splitting is then a rename, and [0010](0010-flat-application-identity.md) makes +renames permanent data rather than a free correction. + +## Consequences +- A Process is not independently referencable: `dependsOn` names + `{application, surface}`, so if anything ever needs an edge to + `stalwart-provisioner` the merge must be undone and the id restored: paid by + whoever adds that edge, in a rename with inbound references to fix. +- A surviving lockstep pair that cannot merge is not a missing feature; it is + evidence the Application boundary is drawn wrong, and the fix is redrawing it. That + judgement is joris's, one pair at a time, and it is the discipline this decision + buys: paid by the project owner, in boundary arguments the field used to defer. +- An Application switches at the speed of its slowest Process's health gate, and one + bad Process holds its neighbours on old versions visibly: paid by every + Process owner in that Application, in rollout latency. +- Rollback is Application-scoped: reverting one Process reverts all of them: paid + by incident responders, in larger but consistent rollback scope. +- `## Release units` leaves chapter 10 and the field's validation goes with it, + so the guarantee is now only as tested as the Application boundary is: paid by + whoever writes the test that proves a held member holds the set. +- Whatever delivery mechanism is eventually defined must implement + all-or-nothing switchover per Application: paid by the deferred delivery + definition ([deferred/README.md](../deferred/README.md)). diff --git a/docs/adr/model/0062-service-is-the-release-unit.md b/docs/adr/model/0062-service-is-the-release-unit.md deleted file mode 100644 index ebb757b..0000000 --- a/docs/adr/model/0062-service-is-the-release-unit.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -tier: decision -status: proposed -claim: settled -date: 2026-09-07 -normative: spec/v1/10-service-intent.md#service-identity -rests-on: ["0003"] ---- - -# A Service is the unit of atomic release - -## Rests on -Every lockstep pair in this estate can be one Service without losing a -referencable name. False if: a pair must release together AND both names must be -referencable from outside their domain. Settled by: grep every `dependsOn` target -across the composed union: today the complete set is `platform-postgres`, -`platform-rabbitmq`, `stalwart` and `platform-valkey`; neither `auth-api` nor -`auth-ui` nor `stalwart-provisioner` appears, so merging either pair into one -Service breaks no reference. - -## Why -This supersedes [0060](0060-release-unit.md). The requirement survives unchanged: -no member's new version receives traffic until every member is healthy, health -meaning that member's own declared readiness -([0014](0014-probes-are-siblings.md)), and one member failing its startup budget -holds the whole set on old versions. The mechanism changes. The guarantee is -carried by the Service boundary itself rather than by a `releaseUnit` name, which -is deleted. Things that must release together are Workloads of one Service, and -there is no mechanism to couple two Services. - -`releaseUnit` was a cross-repository coupling invisible in any single file: a -reader of `auth-api`'s document saw a name and had to search the composed union -to learn what else answered to it. Under one file per domain -([0063](0063-intent-authored-per-domain.md)) the coupled things are adjacent -(two entries under one Service's `workloads`) and the Service boundary already -means "switches together". The field restated a fact the boundary carried, and -two records of one fact drift. - -The estate's clearest pair costs nothing to merge. `auth-api`'s estate-wide role -is the forward-auth middleware derived from every route's exposure audience -([0018](0018-exposure-by-audience.md)), **not** a `dependsOn` edge, so no -consumer names the id and folding it into Service `auth` breaks no reference. -`stalwart-provisioner` is the same case: nothing depends on it. - -A Service is not a Reconcile Unit ([0032](0032-reconcile-unit-derived.md)). The -Reconcile Unit is derived from the dependency graph and answers ordering: -`platform-postgres` before `knowledge`, where the later unit waits. The Service -is declared, by drawing a boundary, and answers atomicity: every Workload -switches or none does, and a failure means nothing switches. Ordering is a graph -property; atomicity is a product judgement the graph cannot see, which is why one -is derived and the other is authored. - -## Alternatives -| option | cost if taken | why rejected | -|---|---|---| -| A `releaseWith` field, valid only within the domain 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 Services in one file get a second way to say "together" | The file-scope rule is the Service 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 Service in a domain file releases as one | Adding an unrelated Service 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 a Service its owner never coupled | -| A `components` level between Service and Workload | A third authoring level whose only job is grouping Workloads, and every field must then be assigned to Service, component or Workload | Fails [0003](0003-three-layer-meta-model.md)'s two deciding questions, the level records no decision, and Service is already that grouping | - -## Reversibility -Undo cost today: reintroduce a Service-level coupling field and split the merged -Services back into separate ids, an afternoon of schema work plus one pull -request per domain file, no data movement. The split is the expensive half. -Becomes irreversible once: an inbound reference exists to a merged Service's id -(a dependency edge, an exposure route, or a Vault path derived from it) because -splitting is then a rename, and [0010](0010-flat-service-identity.md) makes -renames permanent data rather than a free correction. - -## Consequences -- A Workload is not independently referencable: `dependsOn` names - `{service, surface}`, so if anything ever needs an edge to - `stalwart-provisioner` the merge must be undone and the id restored: paid by - whoever adds that edge, in a rename with inbound references to fix. -- A surviving lockstep pair that cannot merge is not a missing feature; it is - evidence the Service boundary is drawn wrong, and the fix is redrawing it. That - judgement is joris's, one pair at a time, and it is the discipline this decision - buys: paid by the domain owner, in boundary arguments the field used to defer. -- A Service switches at the speed of its slowest Workload's health gate, and one - bad Workload holds its neighbours on old versions visibly: paid by every - Workload owner in that Service, in rollout latency. -- Rollback is Service-scoped: reverting one Workload reverts all of them: paid - by incident responders, in larger but consistent rollback scope. -- `## Release units` leaves chapter 10 and the field's validation goes with it, - so the guarantee is now only as tested as the Service boundary is: paid by - whoever writes the test that proves a held member holds the set. -- Whatever delivery mechanism is eventually defined must implement - all-or-nothing switchover per Service: paid by the deferred delivery - definition ([deferred/README.md](../deferred/README.md)). diff --git a/docs/adr/model/0063-intent-authored-per-domain.md b/docs/adr/model/0063-intent-authored-per-domain.md deleted file mode 100644 index 06eeb10..0000000 --- a/docs/adr/model/0063-intent-authored-per-domain.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -tier: decision -status: proposed -claim: settled -date: 2026-09-07 -normative: spec/v1/40-composition.md#fragments -rests-on: ["0001"] ---- - -# Intent is authored one file per domain - -One file per domain, holding many Services, and one file is one Intent -Fragment. A repository may hold several domain files; a domain never spans -repositories. `owner` is the only field raised to the domain header, and -namespace derives from `domain` as `-system`. - -## Rests on - -The domain is the right authoring unit because it already is the deployment -unit: every live Service runs in the namespace named after its domain. False -if: a live Service namespace does not match its domain, one Service running -anywhere but `-system` breaks the derivation and forces the alias field -back. Settled by: `kubectl get ns -o name | grep -- '-system$'` compared -against the domain header of every Service document. Ten namespaces come back ( -auth-system, data-system, knowledge-system, app-system, agents-system, -mail-system, media-system, notes-system, automation-system, utility-system), -and every one equals `-system` today. - -## Why - -Chapter 20's authority table derives `namespace` "from `id`, or from a declared -`aliases.namespace` with a reason". That rule is wrong about this estate. -Namespaces here have never been per Service; they have always been per domain. -`home-portal` needed an alias only because the rule pointed at the wrong field: -the repository and the product are `home-portal`, so the id rule derives -`home-portal-system`, a namespace that does not exist and never has. The -Service runs in `app-system`, and its domain is `app`. Deriving from `domain` -yields `app-system` directly. Ten of ten live namespaces come out unchanged and -not one live object moves. - -`aliases` is deleted here. It covered three divergences and now expresses none: -namespace comes from `domain`, and the Workload name and the image are fields -the author already writes explicitly ([0010](0010-flat-service-identity.md)). -Nothing remains for it to say. The prohibition on aliasing a Service into -another deployer's namespace -([0047](../deferred/0047-namespace-per-deployer.md)) loses its subject matter with -it: no field can move a Service out of its domain's namespace. - -A namespace now holds several Services **by construction**. It is therefore not -a trust boundary. Chapter 20 already said so ("two Services may share one, so -a namespace is not a trust boundary") as a footnote to an exception. It is now -the normal case for every namespace in the estate, and it must be said loudly: -no isolation claim may rest on a namespace wall. Isolation is the derived -default-deny edge set ([0035](0035-network-policy-default-deny.md)), evaluated -per pod, plus per-Workload identity ([0024](0024-identity-per-workload.md)). - -Chapter 40 makes the unit of publication a repository declaring which domains -it contributes to ([0037](0037-composition-oci-fragments.md)). That list -collapses to one: a domain file is a fragment, so a fragment contributes to -exactly one domain, and `homelab-collections` publishes one fragment per domain -file rather than one fragment naming several. Because a domain never spans -repositories, composition unions fragments and never has to union a domain. - -## Alternatives - -| option | cost if taken | why rejected | -|---|---|---| -| One repository = one domain = one file | Splits `homelab-collections`, which holds three Services, into three repositories with three publish workflows and three sets of credentials | Buys nothing: chapter 40 already records that composition behaves identically split or not, so the split is a convenience, not a prerequisite | -| A domain spanning repositories, unioned by name | A domain's membership is knowable only after composition; no single file states who is in it | The cross-repository coupling problem wearing a hat, the same "members agreeing on a name is the mechanism" that deleted `releaseUnit` ([0062](0062-service-is-the-release-unit.md)) | -| Raise `alertClass` and `secrets` to the domain header alongside `owner` | A domain pages as loudly as its loudest member, and one grant hands every Service in the file a reader slot on the whole path ([0009](0009-vault-read-is-per-path.md)) | Both are per-Service facts; raising them widens blast radius to buy three saved lines | - -## Reversibility - -Undo cost today: splitting ten domain files back into per-Service documents and -restoring `aliases` to the schema and to chapter 20's authority table, hours, -against ten files and no composed artifact yet published against a domain -header. Becomes irreversible once: fragments publish domain-scoped and -consumers pin them by digest, and Vault roles and ServiceAccounts are named for -the Workload under the domain namespace, reverting then renames every identity -in the estate. - -## Consequences - -- The Service id is the repository/product name and Workload names are whatever - the processes are actually called: Service `home-portal` holding Workload - `app-ui` with image `app-ui` is the name, not a divergence, paid by authors, - who lose the alias field that used to record the difference as data. -- ServiceAccount and Vault role become the Workload name alone, unique within - the domain (`auth-system.auth-api`, not `auth-system.auth-auth-api`), paid - by the estate owner, who re-creates the roles once when chapter 20's - `-` rule is retired. -- A process name is used once per domain, not once per Service: two Services in - one file cannot both call a Workload `api`, paid by the domain's authors. -- Every namespace holds several Services, so no isolation review may cite a - namespace wall, paid by whoever reviews isolation, who reads the edge set - instead. -- A domain file grows with its domain: one review surface, one merge point, and - one `owner` for every Service in it; a Service needing a different owner needs - its own domain, paid by the domain's authors. diff --git a/docs/adr/model/0063-intent-authored-per-project.md b/docs/adr/model/0063-intent-authored-per-project.md new file mode 100644 index 0000000..eff2111 --- /dev/null +++ b/docs/adr/model/0063-intent-authored-per-project.md @@ -0,0 +1,104 @@ +--- +tier: decision +status: proposed +claim: settled +date: 2026-09-07 +normative: spec/v1/40-composition.md#fragments +rests-on: ["0001"] +--- + +# Intent is authored one file per project + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + +One file per project, holding many Applications, and one file is one Intent +Fragment. A repository may hold several project files; a project never spans +repositories. `owner` is the only field raised to the project header, and +namespace derives from `project` as `-system`. + +## Rests on + +The project is the right authoring unit because it already is the deployment +unit: every live Application runs in the namespace named after its project. False +if: a live Application namespace does not match its project, one Application running +anywhere but `-system` breaks the derivation and forces the alias field +back. Settled by: `kubectl get ns -o name | grep -- '-system$'` compared +against the project header of every Application document. Ten namespaces come back ( +auth-system, data-system, knowledge-system, app-system, agents-system, +mail-system, media-system, notes-system, automation-system, utility-system), +and every one equals `-system` today. + +## Why + +Chapter 20's authority table derives `namespace` "from `id`, or from a declared +`aliases.namespace` with a reason". That rule is wrong about this estate. +Namespaces here have never been per Application; they have always been per project. +`home-portal` needed an alias only because the rule pointed at the wrong field: +the repository and the product are `home-portal`, so the id rule derives +`home-portal-system`, a namespace that does not exist and never has. The +Application runs in `app-system`, and its project is `app`. Deriving from `project` +yields `app-system` directly. Ten of ten live namespaces come out unchanged and +not one live object moves. + +`aliases` is deleted here. It covered three divergences and now expresses none: +namespace comes from `project`, and the Process name and the image are fields +the author already writes explicitly ([0010](0010-flat-application-identity.md)). +Nothing remains for it to say. The prohibition on aliasing an Application into +another deployer's namespace +([0047](../deferred/0047-namespace-per-deployer.md)) loses its subject matter with +it: no field can move an Application out of its project's namespace. + +A namespace now holds several Applications **by construction**. It is therefore not +a trust boundary. Chapter 20 already said so ("two Applications may share one, so +a namespace is not a trust boundary") as a footnote to an exception. It is now +the normal case for every namespace in the estate, and it must be said loudly: +no isolation claim may rest on a namespace wall. Isolation is the derived +default-deny edge set ([0035](0035-network-policy-default-deny.md)), evaluated +per pod, plus per-Process identity ([0024](0024-identity-per-process.md)). + +Chapter 40 makes the unit of publication a repository declaring which projects +it contributes to ([0037](0037-composition-oci-fragments.md)). That list +collapses to one: a project file is a fragment, so a fragment contributes to +exactly one project, and `homelab-collections` publishes one fragment per project +file rather than one fragment naming several. Because a project never spans +repositories, composition unions fragments and never has to union a project. + +## Alternatives + +| option | cost if taken | why rejected | +|---|---|---| +| One repository = one project = one file | Splits `homelab-collections`, which holds three Applications, into three repositories with three publish workflows and three sets of credentials | Buys nothing: chapter 40 already records that composition behaves identically split or not, so the split is a convenience, not a prerequisite | +| A project spanning repositories, unioned by name | A project's membership is knowable only after composition; no single file states who is in it | The cross-repository coupling problem wearing a hat, the same "members agreeing on a name is the mechanism" that deleted `releaseUnit` ([0062](0062-application-is-the-release-unit.md)) | +| Raise `alertClass` and `secrets` to the project header alongside `owner` | A project pages as loudly as its loudest member, and one grant hands every Application in the file a reader slot on the whole path ([0009](0009-vault-read-is-per-path.md)) | Both are per-Application facts; raising them widens blast radius to buy three saved lines | + +## Reversibility + +Undo cost today: splitting ten project files back into per-Application documents and +restoring `aliases` to the schema and to chapter 20's authority table, hours, +against ten files and no composed artifact yet published against a project +header. Becomes irreversible once: fragments publish project-scoped and +consumers pin them by digest, and Vault roles and ServiceAccounts are named for +the Process under the project namespace, reverting then renames every identity +in the estate. + +## Consequences + +- The Application id is the repository/product name and Process names are whatever + the processes are actually called: Application `home-portal` holding Process + `app-ui` with image `app-ui` is the name, not a divergence, paid by authors, + who lose the alias field that used to record the difference as data. +- ServiceAccount and Vault role become the Process name alone, unique within + the project (`auth-system.auth-api`, not `auth-system.auth-auth-api`), paid + by the estate owner, who re-creates the roles once when chapter 20's + `-` rule is retired. +- A process name is used once per project, not once per Application: two Applications in + one file cannot both call a Process `api`, paid by the project's authors. +- Every namespace holds several Applications, so no isolation review may cite a + namespace wall, paid by whoever reviews isolation, who reads the edge set + instead. +- A project file grows with its project: one review surface, one merge point, and + one `owner` for every Application in it; an Application needing a different owner needs + its own project, paid by the project's authors. diff --git a/docs/adr/model/0064-sidecars-are-workload-vocabulary.md b/docs/adr/model/0064-sidecars-are-process-vocabulary.md similarity index 59% rename from docs/adr/model/0064-sidecars-are-workload-vocabulary.md rename to docs/adr/model/0064-sidecars-are-process-vocabulary.md index 2345427..c5fb205 100644 --- a/docs/adr/model/0064-sidecars-are-workload-vocabulary.md +++ b/docs/adr/model/0064-sidecars-are-process-vocabulary.md @@ -3,16 +3,21 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#sidecars +normative: spec/v1/10-project-intent.md#sidecars rests-on: ["0005"] --- -# A Workload may hold sidecars, and a sidecar carries what a container carries +# A Process may hold sidecars, and a sidecar carries what a container carries + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on -Every multi-container pod in this estate is expressible as one Workload plus +Every multi-container pod in this estate is expressible as one Process plus sidecars, and no sidecar needs a node dimension of its own. False if: a sidecar -must be placed differently from the Workload it runs beside (which a shared pod +must be placed differently from the Process it runs beside (which a shared pod makes impossible) or a live sidecar's resources or hardening cannot be stated at the sidecar. Settled by: the three live cases (`postgres` with `postgres-exporter` on 9187, `stalwart` with `stalwart-apply`, `agent-runner` @@ -20,10 +25,10 @@ carrying the `agent-gateway` jar) rendered from a declaration and diffed against the live pod specs. ## Why -A Workload holding more than one container is not an edge case here. It happens +A Process holding more than one container is not an edge case here. It happens three times, and the estate has already paid for having no vocabulary for it: `agent-gateway` sits in the accepted-drift ledger as *"a sidecar jar inside -agent-runner pods, not a workload of its own"*, a real container excused from +agent-runner pods, not a process of its own"*, a real container excused from attribution because the model could not name it. `postgres-exporter` is worse than unnamed: `platform-postgres` declares `provides: {metrics: 9187}` and the container that actually serves 9187 has no declaration at all. @@ -32,13 +37,13 @@ The open half was never whether sidecars exist but what a sidecar carries, and Kubernetes answers it cleanly because the split is already in the API. `nodeSelector` and affinity are **pod**-level; `resources` and `securityContext` are **container**-level. So the node dimensions (`arch`, `site`, `disk`, `gpu`, -`capabilities`) stay on the Workload and describe the pod, while `memory`, +`capabilities`) stay on the Process and describe the pod, while `memory`, `cpu` and `hardening` belong to each container and a sidecar declares its own. Splitting the field by where Kubernetes already puts it means the derivation needs no rule of its own: it follows the target. -Eligibility then sums. A node must fit the Workload's containers together, so -the placement check adds the sidecars' `memory` and `cpu` to the Workload's +Eligibility then sums. A node must fit the Process's containers together, so +the placement check adds the sidecars' `memory` and `cpu` to the Process's before matching against allocatable ([0061](0061-placement-is-hard-dimensions.md)). This is the one place the addition matters and it is easy to get wrong: `postgres` at 2Gi with a 64Mi @@ -46,41 +51,41 @@ exporter needs a node with 2112Mi free, not 2Gi, and on the 4096Mi Pis that difference is a fifth of what is left after the reserve. A sidecar's `image` is an alias resolved through the images lock, for the same -reason a Workload's is: a tag would put a mutable reference in a Deliverable +reason a Process's is: a tag would put a mutable reference in a Deliverable that `E_FLOATING_IMAGE` exists to refuse. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| A sidecar is a Workload of its own | Its own identity, ServiceAccount, Vault role, probes and release semantics, and it would then be independently referencable, which is false: `agent-gateway` cannot be deployed or scaled apart from `agent-runner` | The pod is the unit that shares a lifecycle, a network namespace and a node; modelling co-located containers as peers denies the one fact that makes them sidecars | -| Sidecars inherit the Workload's `hardening` and quantities | One number to write; no per-container vocabulary | `postgres-exporter` and `postgres` are different images with different needs, the exporter meets `restricted` while postgres does not, and inheriting would force the Workload's exception onto a container that never needed it, widening the estate's own inventory of what it cannot harden | +| A sidecar is a Process of its own | Its own identity, ServiceAccount, Vault role, probes and release semantics, and it would then be independently referencable, which is false: `agent-gateway` cannot be deployed or scaled apart from `agent-runner` | The pod is the unit that shares a lifecycle, a network namespace and a node; modelling co-located containers as peers denies the one fact that makes them sidecars | +| Sidecars inherit the Process's `hardening` and quantities | One number to write; no per-container vocabulary | `postgres-exporter` and `postgres` are different images with different needs, the exporter meets `restricted` while postgres does not, and inheriting would force the Process's exception onto a container that never needed it, widening the estate's own inventory of what it cannot harden | | Leave it ungraded and keep using the drift ledger | Nothing to write now | The ledger entry says the model cannot see the container; a ledger is for accepted holes, not for missing vocabulary, and [0055](0055-bidirectional-ledgers.md) requires every entry to be a deferred fix rather than a permanent exemption | ## Reversibility Undo cost today: three declarations across three repositories, plus the renderer branch that emits additional containers, under a day, no data movement, no live object renamed. Becomes irreversible once: a sidecar serves a surface another -Service depends on, because the consumer's edge then resolves to a port the -Workload's own container does not open, `platform-postgres`'s `metrics: 9187` +Application depends on, because the consumer's edge then resolves to a port the +Process's own container does not open, `platform-postgres`'s `metrics: 9187` is already exactly that shape. ## Consequences - The `agent-gateway` accepted-drift entry can close: the container becomes declarable and therefore attributable, so it stops being an object no adapter claims, paid by whoever writes the declaration, once. -- Placement eligibility sums across containers, so a Workload's node set narrows - when a sidecar is added and a previously-placeable Workload can become +- Placement eligibility sums across containers, so a Process's node set narrows + when a sidecar is added and a previously-placeable Process can become `E_PLACEMENT_UNSATISFIABLE` without its own quantities changing, paid by the author adding the sidecar, at build time rather than at apply time. -- A surface may be served by a sidecar rather than by the Workload's own +- A surface may be served by a sidecar rather than by the Process's own container, and `provides` does not say which. The port is on the pod, so nothing breaks; but a reader of `provides` alone cannot tell which process answers, paid by whoever debugs 9187, in one extra file to open. - Two more containers that must each meet `restricted` on their own, and a - sidecar that cannot is refused along with its Workload, paid in honesty: + sidecar that cannot is refused along with its Process, paid in honesty: those containers were always running and were invisible. -- `probes` stay on the Workload. A sidecar publishes no readiness signal of its - own, so a failing exporter cannot hold its Workload out of service, the +- `probes` stay on the Process. A sidecar publishes no readiness signal of its + own, so a failing exporter cannot hold its Process out of application, the right default for a metrics sidecar, and wrong for any sidecar that becomes load-bearing, which is a decision to revisit when one does, paid by the - Workload's owner, who must notice. + Process's owner, who must notice. 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 0f31035..86360a9 100644 --- a/docs/adr/model/0070-path-authority-is-layer-2.md +++ b/docs/adr/model/0070-path-authority-is-layer-2.md @@ -9,12 +9,17 @@ rests-on: ["0003"] # Layer 2 assigns every output path; layer 3 serialises what it is handed +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Where a Deliverable is written is a decision, not a formatting detail, and every path in the estate is assignable from the Resolved Deployment alone. False if: a path can only be known once an object is being serialised: a name derived from content the plan does not carry. Settled by: rendering the three worked -domains with `E_PATH_COLLISION` evaluated on the assembled plan, before any +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 @@ -26,13 +31,13 @@ adapter and the object it carries, reads like serialisation but is authority in disguise. Two live cases prove it cannot hold. `namespace.yaml` and the namespace-wide -default-deny are **one object per domain**, while an adapter keyed off the -Service emits one directory per Service: `auth` has one Service so nothing +default-deny are **one object per project**, while an adapter keyed off the +Application emits one directory per Application: `auth` has one Application so nothing collides, `data` has three and renders three identical Namespace objects at three paths, with nothing but write order deciding which survives. And the Gatus endpoints ConfigMap is estate-scoped: it lands in `utility-system`, a -namespace no participating Service owns, and `E_FOREIGN_NAMESPACE` is satisfied -only because the adapter owns the path rather than the Service. Under an +namespace no participating Application owns, and `E_FOREIGN_NAMESPACE` is satisfied +only because the adapter owns the path rather than the Application. Under an adapter-computed path both outcomes are accidents; under a plan both are assignments with an owner. @@ -53,7 +58,7 @@ not let one Adapter write into another's. ## Alternatives | 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-domain object and the estate-scoped Deliverable unresolvable, and keeps collision detection at the writer, where it has never existed | +| 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 | | 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 | diff --git a/docs/adr/model/0071-release-gate-inputs-are-layer-2.md b/docs/adr/model/0071-release-gate-inputs-are-layer-2.md index 4d2173d..3f89d46 100644 --- a/docs/adr/model/0071-release-gate-inputs-are-layer-2.md +++ b/docs/adr/model/0071-release-gate-inputs-are-layer-2.md @@ -9,8 +9,13 @@ rests-on: ["0005"] # The release gate is derived into layer 2, and nothing is rendered for it +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -Every mechanism that could perform a Service-scoped switchover needs the same +Every mechanism that could perform an Application-scoped switchover needs the same three inputs (the member list, each member's readiness reference, and one deadline), and all three are derivable from declared intent. False if: a candidate mechanism needs an input no declaration carries, or needs an applied @@ -27,25 +32,25 @@ nothing: `app.kubernetes.io/instance: auth` is on every object and no controller consumes it, the kustomize `Kustomization` groups a file set and has no notion of a gate, and the Flux `Kustomization`'s health checks are evaluated *after* apply, after traffic has already moved. No rendered object has the identity -"the Service". +"the Application". The three candidate mechanisms differ in machinery and agree on inputs. Holding -traffic at the edge needs to know which Workloads must be healthy and how long +traffic at the edge needs to know which Processes must be healthy and how long to wait. Paused ReplicaSets plus a selector flip needs the same. A gate outside Kubernetes needs the same. So the inputs are the model's obligation and the machinery is not, which is exactly where the 2026-09-07 boundary falls. -They belong in layer 2 because they are decisions. A member list is the Service +They belong in layer 2 because they are decisions. A member list is the Application boundary; a readiness reference is a projection of a declaration; a deadline is derived. Delivery must read a pinned lock anyway ([0006](0006-pinned-inputs.md)), so it reads them where every other decision -already is, and a Service owner sees the gate their Service will be held to in +already is, and an Application owner sees the gate their Application will be held to in the projection they already read back ([0033](0033-assignments-published-back.md)). Rendering an object instead would repeat the defect being fixed. An applied ConfigMap that no controller consumes is the same shape as the label nothing reads: it looks like a mechanism and is inert. Rendering a Flux `Kustomization` -per Service would be worse: it puts a delivery object inside the v1 render +per Application would be worse: it puts a delivery object inside the v1 render surface while providing no atomicity, since its health checks run after apply. The deadline is `max` over the members of `progressDeadlineSeconds`. This also @@ -59,10 +64,10 @@ gets one derivation; the class table goes. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Render a per-Service descriptor object | Visible to anyone reading the tree, and the substrate is retained as a declarative object store | Nothing consumes it, which is the exact shape of the label nothing reads; and a decision serialised into layer 3 is recorded in no lock | -| Render a Flux `Kustomization` per Service with health checks and a timeout | Uses the mechanism that delivers the estate today, and the fields already exist | Its checks evaluate after apply, so it holds the inputs without providing the property; and it puts a delivery object in v1's render surface, which 0059 scopes out | +| Render a per-Application descriptor object | Visible to anyone reading the tree, and the substrate is retained as a declarative object store | Nothing consumes it, which is the exact shape of the label nothing reads; and a decision serialised into layer 3 is recorded in no lock | +| Render a Flux `Kustomization` per Application with health checks and a timeout | Uses the mechanism that delivers the estate today, and the fields already exist | Its checks evaluate after apply, so it holds the inputs without providing the property; and it puts a delivery object in v1's render surface, which 0059 scopes out | | Let delivery derive the gate from Intent itself | Nothing to add to layer 2 | Puts a derivation outside layer 2, where the model forbids one, and two delivery implementations could compute different deadlines from one declaration | -| Keep the health timeout class beside the deadline | No spec deletion; today's renderer keeps working | Two derivations over one input, already disagreeing by 25 minutes on a live Workload, the next contradiction is only a matter of which number drifts | +| Keep the health timeout class beside the deadline | No spec deletion; today's renderer keeps working | Two derivations over one input, already disagreeing by 25 minutes on a live Process, the next contradiction is only a matter of which number drifts | ## Reversibility Undo cost today: the fields are three entries in a schema and one derivation; @@ -75,17 +80,17 @@ on. - R1 stops gating the render: the model's obligation is discharged in layer 2, and the mechanism remains open for the delivery definition to choose, paid by whoever writes that definition, who inherits inputs rather than a design. -- A Service with `probes: none` on every Workload cannot be gated, so +- An Application with `probes: none` on every Process cannot be gated, so `E_RELEASE_UNIT_NO_READINESS` fires at composition time rather than at apply time, paid by the author, at the earliest possible moment. -- The gate deadline is as long as the Service's slowest Workload, so a genuinely +- The gate deadline is as long as the Application's slowest Process, so a genuinely stuck fast member holds the unit for the slow member's budget (1800 seconds for `auth`, where `auth-ui` alone would have been 90), paid in time-to-detect, which is the cost of atomicity and not a defect in the derivation. - The health timeout class is deleted, so anything that read it (the Flux `Kustomization` timeout in the tree today) must read the gate deadline instead; G-27 closes with it, paid once, in the adapter that emits it. -- No object in the cluster names a Service, so a human debugging cannot find the +- No object in the cluster names an Application, so a human debugging cannot find the Release Unit by selector alone; `part-of` makes the members selectable and the gate itself is only visible in the projection, paid by whoever debugs, in one extra file to open. diff --git a/docs/adr/model/0072-the-label-set-is-fixed.md b/docs/adr/model/0072-the-label-set-is-fixed.md index 15521ec..b2971bf 100644 --- a/docs/adr/model/0072-the-label-set-is-fixed.md +++ b/docs/adr/model/0072-the-label-set-is-fixed.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#the-label-set +normative: spec/v1/10-project-intent.md#the-label-set rests-on: ["0005"] --- # The object label set is fixed, and two of its labels are immutable +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Five labels are enough for everything the estate selects on, and no authored field needs to reach the label set. False if: something must select objects by a @@ -22,18 +27,18 @@ The label set was never written down. It is emitted by a renderer, so it exists only as behaviour, and two of the labels it emits are a Deployment's `selector.matchLabels`. A selector is **immutable on a live object**: changing the convention later is not a re-render but delete-and-recreate on every -workload in the estate, with the downtime that implies for a stateful Workload +process in the estate, with the downtime that implies for a stateful Process on a `local-path` volume. A convention with that property has to be a decision. -`part-of` carrying the Service Id is the part with a consumer. A Service is the -Release Unit ([0062](0062-service-is-the-release-unit.md)) and whatever performs +`part-of` carrying the Application Id is the part with a consumer. An Application is the +Release Unit ([0062](0062-application-is-the-release-unit.md)) and whatever performs a switchover has to select its members; `part-of` is what makes them selectable. -It is deliberately **not** a selector, because a Service gaining or losing a -Workload must not require recreating the ones that stayed. +It is deliberately **not** a selector, because an Application gaining or losing a +Process must not require recreating the ones that stayed. -`name` and `instance` both carry the Workload name, which reads oddly and is +`name` and `instance` both carry the Process name, which reads oddly and is correct. The selector must match exactly one controller's pods, and a `name` -naming the Service would make every Workload of a multi-Workload Service +naming the Application would make every Process of a multi-Process Application ambiguous the moment anything selected on `name` alone: `auth` has two, so this is not hypothetical. @@ -42,15 +47,15 @@ change on every bump, for a value no selector may use and that the image digest already states on the object. An estate-scoped Deliverable carries `managed-by` only. It belongs to no -Workload and no Service, and `part-of` on the Gatus endpoints ConfigMap would -name a Service that does not own it. +Process and no Application, and `part-of` on the Gatus endpoints ConfigMap would +name an Application that does not own it. ## Alternatives | option | cost if taken | why rejected | |---|---|---| | Leave the set to the renderer | Nothing to specify; behaviour unchanged | Two of the labels are immutable selectors, so the convention is a one-way door being held open by nobody's decision | -| Let a Workload add labels | Authors can tag for their own tooling | A label is a selector surface; an authored label can collide with a derived one, and anything worth selecting on is a model concept that should be named | -| `name` = Service, `instance` = Workload | Reads the way the upstream convention intends | Makes every Workload of a two-Workload Service ambiguous under a `name`-only selector, and `auth` is exactly that case | +| Let a Process add labels | Authors can tag for their own tooling | A label is a selector surface; an authored label can collide with a derived one, and anything worth selecting on is a model concept that should be named | +| `name` = Application, `instance` = Process | Reads the way the upstream convention intends | Makes every Process of a two-Process Application ambiguous under a `name`-only selector, and `auth` is exactly that case | | Include `version` from the images lock | Fills in the conventional set | Churns on every image bump for a value no selector may use and the digest already carries | ## Reversibility @@ -58,7 +63,7 @@ Undo cost today: none of the five is live-critical *except* the two selectors, and those are already emitted with these values, so adopting the set costs nothing now. Becomes irreversible immediately for `name` and `instance`: every object applied under them carries an immutable selector, so a later change is -delete-and-recreate per workload, which is why the set is fixed before the +delete-and-recreate per process, which is why the set is fixed before the first render rather than after. ## Consequences @@ -67,9 +72,9 @@ first render rather than after. - Anything that wants to select on a model concept the labels do not carry has to read the Resolved Deployment instead, or make the case for a sixth label, paid by whoever wants it, deliberately. -- A Service rename changes `part-of` on every one of its objects; that is a +- An Application rename changes `part-of` on every one of its objects; that is a mutable label, so it is a patch rather than a recreate, paid at rename time, cheaply, which is the reason it is not a selector. -- `component` carries `runtime`, so a Workload changing runtime rewrites a +- `component` carries `runtime`, so a Process changing runtime rewrites a label; harmless, and it means the label tracks a declaration rather than a guess, paid by nobody. diff --git a/docs/adr/model/0073-vault-policy-is-a-deliverable.md b/docs/adr/model/0073-vault-policy-is-a-deliverable.md index cbbc666..3f4c4e9 100644 --- a/docs/adr/model/0073-vault-policy-is-a-deliverable.md +++ b/docs/adr/model/0073-vault-policy-is-a-deliverable.md @@ -9,13 +9,18 @@ rests-on: ["0005"] # The derived Vault policy and auth role are Deliverables of their own adapter +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -Every privilege a Workload holds in the Secret Store is derivable from its +Every privilege a Process holds in the Secret Store is derivable from its grants and their access tiers, and the resulting policy and auth role are serialisable documents like any other Deliverable. False if: a policy the estate needs cannot be expressed without a fact no pinned input carries: a capability that depends on the store's live state. Settled by: rendering the -three worked domains and diffing the emitted policies against the policies live +three worked projects and diffing the emitted policies against the policies live in Vault today, with the differences explained by a gap row rather than by a missing input. @@ -34,11 +39,11 @@ It is reached from both delivery modes, so it is not an edge case: paths, and `delivery: env` in `data` needs the operator's identity to hold it instead. Either way something must exist in Vault that the derivation describes. -The unit is the **Workload identity**, not the Service. -[0024](0024-identity-per-workload.md) makes the ServiceAccount and the Vault -role the Workload name alone, and the estate has already paid for getting this -wrong: `serviceAccountName()` returned the Service name, so two Workloads of one -Service authenticated as the same principal and received the union of both +The unit is the **Process identity**, not the Application. +[0024](0024-identity-per-process.md) makes the ServiceAccount and the Vault +role the Process name alone, and the estate has already paid for getting this +wrong: `applicationAccountName()` returned the Application name, so two Processes of one +Application authenticated as the same principal and received the union of both policies whatever level a grant was written at. One document per identity is what makes that impossible to reintroduce, and it makes a diff say which principal's privilege changed. @@ -59,8 +64,8 @@ would put a v1 dependency on CRDs nobody has installed. its JWT issuer and CA, and the KV mounts are estate-unique and draw on a shared resource, so [0004](0004-contention-decides-authority.md) makes them platform-assigned; since [0096](0096-the-foundation-is-declared.md) they are -Assets of the declared `vault` Service in the platform's secrets domain, and -before it they arrived through a blueprint pack. Rendering them per Service +Assets of the declared `vault` Application in the platform's secrets project, and +before it they arrived through a blueprint pack. Rendering them per Application would also need a bootstrap answer for the mount that authenticates the renderer itself. @@ -70,7 +75,7 @@ itself. | Emit CRs for a Vault-configuration operator | Everything becomes a Kubernetes object and one applier covers it | Adds an operator and CRDs the substrate does not run, making them a v1 dependency for a document that only needs to be written once per identity | | Register Vault policy as an unmanaged surface | Cheapest, and honest about who writes Vault config today | Leaves 0025 deriving a value nothing emits, so either it or 0005 has to be reopened; and an unmanaged surface is for what the model cannot see, not for output it declines to produce | | Extend `vso` to emit policies | No registry change and no amendment to 0052 | One adapter would own two artifact kinds with different appliers, and a path collision inside one adapter is invisible to the per-adapter rule | -| Aggregate one document per Service | Fewer files; a Service's whole posture in one place | Buries the identity boundary 0024 draws, and a two-Workload Service's diff stops saying which principal changed | +| Aggregate one document per Application | Fewer files; an Application's whole posture in one place | Buries the identity boundary 0024 draws, and a two-Process Application's diff stops saying which principal changed | | HCL rather than JSON | The format every Vault example and the estate's live policies use | Key order and formatting become the adapter's problem, which is the thing one serializer exists to prevent | ## Reversibility diff --git a/docs/adr/model/0074-networking-adapter-emits-policy.md b/docs/adr/model/0074-networking-adapter-emits-policy.md index e3b62bd..46b8cda 100644 --- a/docs/adr/model/0074-networking-adapter-emits-policy.md +++ b/docs/adr/model/0074-networking-adapter-emits-policy.md @@ -9,11 +9,16 @@ rests-on: ["0005"] # A `networking` adapter owns every NetworkPolicy in the estate +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every rendered NetworkPolicy is derivable from the composed edge set, the surfaces, the routes, the grants and the platform baseline, and one producer can own all of them. False if: a policy the estate needs cannot be derived from -those five inputs, or the per-domain default-deny and the per-Workload policies +those five inputs, or the per-project default-deny and the per-Process policies turn out to need different inputs rather than different paths. Settled by: the conftest assertion holding over a full estate render (every rendered policy carrying `Egress` in `policyTypes` also matches UDP/53), with `networking` the @@ -34,8 +39,8 @@ fails with a DNS timeout diagnosed as "Postgres is down". So the producer has to be written, and the question is which adapter owns the kind. It is its own adapter for two reasons that are about scope rather than -size. The namespace-wide default-deny is **one object per domain**, not per -Service, so it does not fit an adapter keyed off the Service; with path +size. The namespace-wide default-deny is **one object per project**, not per +Application, so it does not fit an adapter keyed off the Application; with path authority in layer 2 ([0070](0070-path-authority-is-layer-2.md)) that object now has one owner and one path, and the owner should be the adapter whose whole subject is policy. And the DNS assertion is a property of the policy set: with @@ -43,16 +48,16 @@ one producer it is a property of one adapter, checkable in one place, rather than a rule every adapter emitting a policy would have to be held to separately. -Splitting further (one adapter for per-Workload policies, another for the -per-domain baseline), would put the two baseline rules that must appear in +Splitting further (one adapter for per-Process policies, another for the +per-project baseline), would put the two baseline rules that must appear in *every* policy in a different producer from the policies they must appear in. The split that matters is by kind, and there is one kind. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Extend the `kubernetes` adapter | No registry change; every object a Service owns has one producer | The per-domain default-deny is not Service-scoped, so one adapter would own two path shapes, and a policy regression would be attributed identically to a Deployment regression | -| Two adapters, per-Workload and per-domain | Each adapter has exactly one path shape | The baseline rules belong in every policy, and this puts them in a different producer from most of the policies that need them | +| Extend the `kubernetes` adapter | No registry change; every object an Application owns has one producer | The per-project default-deny is not Application-scoped, so one adapter would own two path shapes, and a policy regression would be attributed identically to a Deployment regression | +| Two adapters, per-Process and per-project | Each adapter has exactly one path shape | The baseline rules belong in every policy, and this puts them in a different producer from most of the policies that need them | | Keep the deleted generation's renderer | It exists and produces objects today | It is unregistered, consumes `ProjectModel` rather than an `AdapterContext`, and omits both baseline rules, porting it costs what writing the adapter costs | ## Reversibility @@ -63,7 +68,7 @@ window's evidence is then keyed to what this adapter emitted. ## Consequences - 0052's set becomes eighteen, amended in place; `rbac` is not among them - ([0075](0075-no-workload-rbac-in-v1.md)), paid in one amendment, and the + ([0075](0075-no-process-rbac-in-v1.md)), paid in one amendment, and the count keeps meaning what it meant. - The DNS baseline becomes assertable against one producer, so the failure the deleted generation shipped is a test rather than a memory, paid by nobody. diff --git a/docs/adr/model/0075-no-workload-rbac-in-v1.md b/docs/adr/model/0075-no-process-rbac-in-v1.md similarity index 70% rename from docs/adr/model/0075-no-workload-rbac-in-v1.md rename to docs/adr/model/0075-no-process-rbac-in-v1.md index dfb4bf1..40e4626 100644 --- a/docs/adr/model/0075-no-workload-rbac-in-v1.md +++ b/docs/adr/model/0075-no-process-rbac-in-v1.md @@ -7,19 +7,24 @@ normative: spec/v1/16-dependencies.md#no-role-grants-what-an-absence-already-den rests-on: ["0001"] --- -# v1 renders no workload RBAC, and refuses any Deliverable that grants it +# v1 renders no process RBAC, and refuses any Deliverable that grants it + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on -No Workload in this estate needs the Kubernetes API to obtain the secrets or +No Process in this estate needs the Kubernetes API to obtain the secrets or configuration it declares, so the privilege a least-privilege Role would grant is -none. False if: a Workload's declared vocabulary (a grant, an asset, a probe) +none. False if: a Process's declared vocabulary (a grant, an asset, a probe) requires an API call the pod itself must make. Settled by: rendering the estate -with no RBAC object and no Workload losing a capability it declared; the one +with no RBAC object and no Process losing a capability it declared; the one known API consumer, `agents-api`, appears as a ledger entry rather than as a counter-example. ## Why -R3 records the shape of the problem exactly: three Services share `data-system`, +R3 records the shape of the problem exactly: three Applications share `data-system`, and the only thing stopping `platform-valkey`'s ServiceAccount from reading `platform-postgres`'s Secret is that no Role grants it, an absence, not a boundary. The instinct is to render RBAC so that the boundary is stated. That @@ -31,7 +36,7 @@ the pod. The pod makes no API call, so a Role granting `get` on that Secret grants a capability nothing exercises. Under `delivery: self` the pod authenticates to Vault, not to Kubernetes, and its privilege is the Vault policy ([0073](0073-vault-policy-is-a-deliverable.md)). Across all three modes, the -least-privilege Role for a Workload of this estate is the empty Role. +least-privilege Role for a Process of this estate is the empty Role. Rendering roughly sixty objects that grant nothing has three costs and no benefit. An empty Role reads as an oversight, so the next person adds a rule to @@ -42,7 +47,7 @@ content. The absence is worth keeping: it is worth **checking**. So the rule is stated as a refusal rather than as an emission: no rendered Deliverable may grant a -Workload access to `secrets`, `E_WORKLOAD_RBAC_GRANT`, evaluated over the +Process access to `secrets`, `E_PROCESS_RBAC_GRANT`, evaluated over the composed union at composition time. Isolation then rests on a checked property rather than on nobody having written a Role yet, which is the actual complaint R3 makes. @@ -52,9 +57,9 @@ earns its keep is the one that catches the maintainer's own future mistake. This is that shape: the mistake is not that valkey can read postgres' Secret today, it is that a broad Role added in a hurry next year would be invisible. -`agents-api` is the honest exception. It creates and deletes Services at +`agents-api` is the honest exception. It creates and deletes Applications at runtime, so it genuinely calls the API, and the model has no vocabulary for -"this Workload needs the API for this verb on this resource". Inventing that +"this Process needs the API for this verb on this resource". Inventing that vocabulary as an adapter default would be guessing; it belongs in a Bidirectional Ledger with an owner ([0055](0055-bidirectional-ledgers.md)) until a decision gives it a declaring site. @@ -62,9 +67,9 @@ gives it a declaring site. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Render an explicit least-privilege Role per Workload | A positive statement, so a future broad grant is a diff rather than an addition | About sixty objects that grant nothing, because the kubelet does the projecting; an empty Role invites a rule, and a standing RoleBinding is where a broad grant would hide | -| Defer workload RBAC beside deploy RBAC | One deferred boundary to remember | Deploy RBAC is about who applies; this is what a Service's own identity may do, which is model vocabulary, and deferring leaves the isolation claim resting on an unchecked absence | -| Give Workloads an `api:` declaration now, and render from it | Closes the `agents-api` case properly | Designing a Kubernetes-API vocabulary for one known consumer would be shaped entirely by that consumer, which is the mistake chapter 30 refuses for a neutral IR | +| Render an explicit least-privilege Role per Process | A positive statement, so a future broad grant is a diff rather than an addition | About sixty objects that grant nothing, because the kubelet does the projecting; an empty Role invites a rule, and a standing RoleBinding is where a broad grant would hide | +| Defer process RBAC beside deploy RBAC | One deferred boundary to remember | Deploy RBAC is about who applies; this is what an Application's own identity may do, which is model vocabulary, and deferring leaves the isolation claim resting on an unchecked absence | +| Give Processes an `api:` declaration now, and render from it | Closes the `agents-api` case properly | Designing a Kubernetes-API vocabulary for one known consumer would be shaped entirely by that consumer, which is the mistake chapter 30 refuses for a neutral IR | ## Reversibility Undo cost today: adding an `rbac` adapter later is adapter work of the usual @@ -76,7 +81,7 @@ a check rather than a shape other repositories pin. - Chapter 30's largest counted gap (16 RBAC objects) is not a gap: those objects will not be rendered, and the coverage ledger's RBAC entries close as decided rather than as done, paid in one edit to the arithmetic. -- A Workload that later needs the API cannot get it from an adapter default; it +- A Process that later needs the API cannot get it from an adapter default; it needs a ledger entry now and a declaring site eventually, paid by `agents-api`'s owner, visibly. - The invariant must see rendered Deliverables, so it runs where the Deliverable diff --git a/docs/adr/model/0076-middleware-has-one-producer.md b/docs/adr/model/0076-middleware-has-one-producer.md index cf3f475..f33af10 100644 --- a/docs/adr/model/0076-middleware-has-one-producer.md +++ b/docs/adr/model/0076-middleware-has-one-producer.md @@ -9,6 +9,11 @@ rests-on: ["0004"] # Every Middleware has one producer, and the tier names its forward-auth endpoint +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** Two things moved. A tier declares four edge facts in > the model's words (`audiences`, `listener`, `certificates`, `forwardAuth`) > and the Traefik spelling is the adapter's @@ -27,7 +32,7 @@ per content profile in use, one redirect per `redirectTo`) and the only fact that derivation lacks is an address the platform owns. False if: a route needs a middleware whose shape depends on something no declaration carries, or two routes on one tier and audience need different chains. Settled by: rendering the -three worked domains and finding every middleware reference in every emitted +three worked projects and finding every middleware reference in every emitted IngressRoute resolved by an object this adapter emitted. ## Why @@ -39,7 +44,7 @@ produced nowhere. The producer is its own adapter for the reason [0074](0074-networking-adapter-emits-policy.md) gives for policy: one producer -per kind. The Middlewares are estate-scoped, not per-Service, and both route +per kind. The Middlewares are estate-scoped, not per-Application, and both route adapters reference them, so making one route adapter the owner would mean a lan-only change can require editing the public adapter and the shared object's owner is decided by which adapter happened to receive it. A blueprint pack is @@ -50,8 +55,8 @@ drift the model exists to remove. The endpoint is the interesting half. A forward-auth Middleware must name the address that performs the check, and in this estate that address is `auth-api`. -Deriving it from `auth-api`'s own surface would write one Service's id into a -platform derivation and make the edge tree depend on resolving a Service, which +Deriving it from `auth-api`'s own surface would write one Application's id into a +platform derivation and make the edge tree depend on resolving an Application, which chapter 10 refuses in as many words: `auth-api`'s estate-wide role is this middleware, *never an edge*. @@ -74,8 +79,8 @@ class of defect before. |---|---|---| | `traefik-public` emits them, `traefik-lan` references | No registry change; middlewares live beside the routes using them | One adapter owns objects the other depends on, so a lan change edits the public adapter, and ownership is decided by accident of assignment | | A blueprint pack fixture | 0013 already delivers fixtures at a pinned ref, and the security baseline is platform policy | The needed set follows from declared audiences and content policies, so a fixture is a hand-maintained superset that drifts from the routes | -| Derive the endpoint from the auth Service's surface | Nothing authored twice | Hardcodes a Service id into a platform derivation and makes the edge depend on resolving a Service, which chapter 10 refuses | -| One Platform Intent field for the estate | Simplest at one cluster and one auth Service | Every tier carries a fact most of them must ignore, and a second endpoint becomes a schema change rather than a value | +| Derive the endpoint from the auth Application's surface | Nothing authored twice | Hardcodes an Application id into a platform derivation and makes the edge depend on resolving an Application, which chapter 10 refuses | +| One Platform Intent field for the estate | Simplest at one cluster and one auth Application | Every tier carries a fact most of them must ignore, and a second endpoint becomes a schema change rather than a value | ## Reversibility Undo cost today: one adapter, its registry entry, and one field on the tier diff --git a/docs/adr/model/0077-durability-derives-a-backup.md b/docs/adr/model/0077-durability-derives-a-backup.md index f2cbefb..f3903f3 100644 --- a/docs/adr/model/0077-durability-derives-a-backup.md +++ b/docs/adr/model/0077-durability-derives-a-backup.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#storage-and-durability +normative: spec/v1/10-project-intent.md#storage-and-durability rests-on: ["0004"] --- # A Durability Class derives a backup, from platform terms and a method keyed by engine +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Everything a backup needs beyond the class itself is either contended or a property of the engine, so nothing else has to be authored. False if: two @@ -36,17 +41,17 @@ why the missing pieces are a schedule and a command rather than a The terms are platform-assigned by the contention test ([0004](0004-contention-decides-authority.md)). A backup window is one node's IO -on a seven-node cluster where every stateful Workload is pinned to the machine +on a seven-node cluster where every stateful Process is pinned to the machine holding its PV; an off-cluster destination is one remote target with one credential. Both are shared finite resources, so the Platform Intent carries one policy per class and the volume declares only the class. Authoring the terms per -volume would put a mechanism in layer 1 and let two Services claim the same +volume would put a mechanism in layer 1 and let two Applications claim the same window with nothing arbitrating. A volume needing different terms restates one with a reason, which [0031](0031-derived-overrides-with-reason.md) already allows. -The method is keyed by the Workload's `engine` -([0078](0078-engine-is-workload-vocabulary.md)) and **is an image**: one +The method is keyed by the Process's `engine` +([0078](0078-engine-is-process-vocabulary.md)) and **is an image**: one purpose-built image per engine, named in the Platform document and resolved through the images lock ([0097](0097-authored-values-name-model-concepts.md)). What it does (`pg_dump` for `postgres`, a definitions export for `rabbitmq`, a @@ -57,7 +62,7 @@ an Asset is exactly the case that decision exists to refuse. Two smaller consequences follow from rules already made. The `CronJob` comes from the `kubernetes` adapter, because that kind is already its and the object is -Service-scoped; splitting one kind across two adapters is what made a path +Application-scoped; splitting one kind across two adapters is what made a path collision undetectable before. And the credential for the destination is a **derived** grant rather than an authored one: the platform chose the destination, so making a datastore owner author a grant against a platform path @@ -69,9 +74,9 @@ privilege nobody has to take on trust. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Author schedule, retention and destination per volume | The owner sees the terms beside the class | A schedule and a destination are mechanisms, which layer 1 excludes, and two Services could contend for one window with nothing arbitrating | +| Author schedule, retention and destination per volume | The owner sees the terms beside the class | A schedule and a destination are mechanisms, which layer 1 excludes, and two Applications could contend for one window with nothing arbitrating | | Author retention only, platform-assign the rest | Splits the tuple along the contention line exactly | A second authored field whose legal values are per-class anyway, and a 90-day claim on a snapshot-less cluster is what the deleted `rollbackTargetRetention` already asserted falsely | -| Let each Service declare a backup Workload of its own | Fully general, no new vocabulary, nothing derived | Every datastore owner reimplements retention and off-cluster copy, and the Durability Class derives nothing, 0015 reduced to a label, which is the state this decision ends | +| Let each Application declare a backup Process of its own | Fully general, no new vocabulary, nothing derived | Every datastore owner reimplements retention and off-cluster copy, and the Durability Class derives nothing, 0015 reduced to a label, which is the state this decision ends | | A hand-written backup stack, delivered as a fixture | Nothing to derive | Which objects are needed follows from which volumes declare which class, so the fixture is a superset that drifts, the objection that ruled out fixture Middlewares | ## Reversibility @@ -94,7 +99,7 @@ data-safety change rather than a refactor. platform, and visible in the derived policy rather than in someone's memory. - A volume whose engine has no method in the catalog cannot derive a backup, so adding a datastore engine to the estate is a platform change before it is a - Service change, paid by whoever adds the engine, at the moment they add it. + Application change, paid by whoever adds the engine, at the moment they add it. - `E_ENGINE_WITHOUT_DURABILITY` and `E_DURABILITY_WITHOUT_ENGINE` make the pair mandatory together, so a datastore that declares a class and forgets the engine fails the render rather than silently deriving nothing, paid by the author, diff --git a/docs/adr/model/0078-engine-is-workload-vocabulary.md b/docs/adr/model/0078-engine-is-process-vocabulary.md similarity index 73% rename from docs/adr/model/0078-engine-is-workload-vocabulary.md rename to docs/adr/model/0078-engine-is-process-vocabulary.md index 937b842..3f8b0ba 100644 --- a/docs/adr/model/0078-engine-is-workload-vocabulary.md +++ b/docs/adr/model/0078-engine-is-process-vocabulary.md @@ -3,14 +3,19 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#workload +normative: spec/v1/10-project-intent.md#process rests-on: ["0005"] --- # `engine` is layer-1 vocabulary: what the process is, not how it is instrumented +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -What a Workload's data *is* (a Postgres cluster, a RabbitMQ broker, a directory +What a Process's data *is* (a Postgres cluster, a RabbitMQ broker, a directory of files) is a fact only the owner can state and one the platform must key several derivations off. False if: every derivation that wants it can get it from something already declared without inferring from an image name. Settled by: @@ -21,7 +26,7 @@ them consulting `runtime` or an image alias. ## Why [0077](0077-durability-derives-a-backup.md) needs to know how to back a volume up, and the answer differs per datastore: `pg_dump`, a definitions export, a -file copy. Nothing in the model says which a Workload is. +file copy. Nothing in the model says which a Process is. The obvious candidates both fail. `runtime` selects the Runtime Profile (`jvm`, `python`, `node`, `static`, `none`) which is how a process is *instrumented*, @@ -31,12 +36,12 @@ overloading it further is a known mistake. Inferring from the image alias means backup method that changes silently when an image is swapped, and the images lock is a mapping to digests rather than a taxonomy. -Putting it in platform data fails differently: which Services are Postgres is a -fact the Service knows first, and a context republish before a new datastore can -be backed up puts a platform round-trip in front of a Service change. +Putting it in platform data fails differently: which Applications are Postgres is a +fact the Application knows first, and a context republish before a new datastore can +be backed up puts a platform round-trip in front of an Application change. So it is layer-1 vocabulary, and it stays inside the layer-1 rule because it -states a **fact about the Workload** rather than a mechanism. It names no +states a **fact about the Process** rather than a mechanism. It names no Kubernetes kind, no command and no schedule; the platform maps it to those. It pays for itself three times, which is the argument for a closed vocabulary @@ -57,8 +62,8 @@ it. |---|---|---| | Extend `runtime` to cover datastores | One field instead of two, and it already exists | `runtime` is instrumentation and `platform-postgres`'s is correctly `none`; R22 records this exact overload as a live defect | | Infer from the image alias | Nothing authored | An image swap silently changes what the platform thinks it is backing up, and the lock maps aliases to digests rather than to kinds | -| Platform-side mapping keyed by Service Id | Nothing new in layer 1 | Puts a fact the Service knows first into platform data, and a new datastore needs a context republish before it can be backed up | -| A field on the volume rather than the Workload | Scoped to where the backup happens | Two other derivations want it and neither is about a volume; and a Workload's engine is a property of the process, not of one of its mounts | +| Platform-side mapping keyed by Application Id | Nothing new in layer 1 | Puts a fact the Application knows first into platform data, and a new datastore needs a context republish before it can be backed up | +| A field on the volume rather than the Process | Scoped to where the backup happens | Two other derivations want it and neither is about a volume; and a Process's engine is a property of the process, not of one of its mounts | ## Reversibility Undo cost today: one optional field, refused where it means nothing, deletable @@ -67,7 +72,7 @@ the backup derivation reads it, because removing it then means re-deriving backup methods from something else for every datastore in the estate. ## Consequences -- Two error codes make the pairing explicit: a Workload holding a backed-up +- Two error codes make the pairing explicit: a Process holding a backed-up volume must declare an engine, and one declaring an engine with no such volume is refused, paid by the author, at build time, and it keeps the field from becoming decoration. @@ -76,5 +81,5 @@ backup methods from something else for every datastore in the estate. - R7 and R23 now have a field to key off, which does not decide them; it removes the reason they could not be decided, paid by whoever takes those rows. - One more layer-1 field to document, validate and complete in an editor, on a - Workload that already carries eight, paid in schema surface, and it is the + Process that already carries eight, paid in schema surface, and it is the first new authoring field this rebuild has added. diff --git a/docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md b/docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md index d00d005..8bf861b 100644 --- a/docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md +++ b/docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md @@ -3,42 +3,47 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#observability +normative: spec/v1/10-project-intent.md#observability rests-on: ["0004"] --- # An Alert Class derives rules from a platform catalog, and a class without a signal is refused +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-10.** The catalog, the severity mapping and the receiver > table are **deleted from this specification** rather than relocated. A first > attempt moved them into a versioned configuration owned by the observability -> Service; that reproduced the same fifteen lines in a forty-five line file with -> a schema envelope, changed nothing for a Service author, and shipped an +> Application; that reproduced the same fifteen lines in a forty-five line file with +> a schema envelope, changed nothing for an Application author, and shipped an > example of a document that belongs in another repository. The model publishes > `alertClass` as a resolved fact and a monitoring stack reads it > ([chapter 20](../../../spec/v1/20-resolved-deployment.md#publish-back)). > > What survives is one claim and one refusal. The claim: the rules worth -> alerting on are a property of what a Workload is, not of who owns it, which is -> why no domain file authors PromQL. The refusal: a class with no signal is +> alerting on are a property of what a Process is, not of who owns it, which is +> why no project file authors PromQL. The refusal: a class with no signal is > `E_ALERT_CLASS_WITHOUT_SIGNAL`. There is no longer a `none` member to be above: -> an omitted `observability` block is how a Service says it wants none -> ([chapter 10](../../../spec/v1/10-service-intent.md#observability), +> an omitted `observability` block is how an Application says it wants none +> ([chapter 10](../../../spec/v1/10-project-intent.md#observability), > [0021](0021-observability-scrape-and-alert-class.md)). ## Rests on -The rules worth alerting on are a property of what a Workload is and what it +The rules worth alerting on are a property of what a Process is and what it exposes, not of who owns it, so a platform catalog plus a declared urgency -derives every alert the estate needs, so no domain file ever authors an -expression. False if: a Service needs a rule whose expression only its owner +derives every alert the estate needs, so no project file ever authors an +expression. False if: an Application needs a rule whose expression only its owner could write, often enough that authored PromQL becomes the normal case. Settled -by: rendering the estate and finding no Service that needs an authored +by: rendering the estate and finding no Application that needs an authored expression to be adequately alerted. ## Why [0021](0021-observability-scrape-and-alert-class.md) says receivers, notifier routes, Gatus checks, ServiceMonitors and PrometheusRules all derive from two -declarations. Three Services declare three different Alert Classes and all three +declarations. Three Applications declare three different Alert Classes and all three produce zero objects. There is exactly one `PrometheusRule` in the estate, and Gatus monitors 41 endpoints while notifying nobody, its ConfigMap has `storage` and `ui` and no `alerting` section at all. The declaration has been inert in both @@ -47,20 +52,20 @@ directions. The rules come from a catalog because PromQL is a mechanism. A baseline set keyed off `scrape` (target absent, restart loop, probe failure) covers what "is it working" means for anything, and `engine` -([0078](0078-engine-is-workload-vocabulary.md)) covers what it means for a +([0078](0078-engine-is-process-vocabulary.md)) covers what it means for a Postgres or a RabbitMQ specifically. The class supplies severity and receiver, which is what it already claims to be: urgency, never routing. A receiver is a shared notification channel, so by [0004](0004-contention-decides-authority.md) the mapping is platform-assigned, -and it is one mapping feeding both producers, a Service declaring `page` means +and it is one mapping feeding both producers, an Application declaring `page` means the same thing whether the signal came from a scrape or from an endpoint check. **The refusal is the part that matters.** `platform-postgres` declares `page`, the loudest value in the vocabulary, and produces no monitoring object at all: Gatus derives from `exposure` and a datastore is correctly not exposed, and no -adapter reads the class. So the estate's most urgent Service is wired to nothing, +adapter reads the class. So the estate's most urgent Application is wired to nothing, and nothing says so. `E_ALERT_CLASS_WITHOUT_SIGNAL` makes a class above `none` -require a signal source: a `scrape` surface on some Workload, or an external +require a signal source: a `scrape` surface on some Process, or an external health surface. A warning would not do: in a one-maintainer estate a warning is a line in a log, which is how 41 endpoints came to notify nobody. @@ -68,13 +73,13 @@ a line in a log, which is how 41 endpoints came to notify nobody. stands: a baseline set keyed off the signal source covers "is it working" for anything, and `engine` covers what it means for a Postgres specifically. What changed is which document owns it. A rule expression is configuration of a -monitoring stack, so it belongs to the observability Service's versioned +monitoring stack, so it belongs to the observability Application's versioned configuration, consumed by its runner, not to the Platform Intent, which is a deployment model's authored document. The estate's urgency vocabulary and the signal facts stay in Intent; the PromQL, the cadence and the receiver table follow the stack that evaluates them. -The runner inherits the refusal. Where it cannot map a Service's signal and +The runner inherits the refusal. Where it cannot map an Application's signal and class to an active monitor and a receiver, **its build fails**. That is what preserves the property without the model owning PromQL: the same silence this decision exists to end, caught by the system that would have produced it. @@ -92,14 +97,14 @@ takes the metrics stack's global default, a value decided outside the model, and the fix is to state both in the observability configuration, where the stack that uses them is configured, rather than in a Platform document the metrics stack never reads. The ingest budget is shared, so the value is not a -Service's to set and not the deployment model's to carry. +Application's to set and not the deployment model's to carry. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Authored rules per Service | Highest fidelity to what each owner considers broken | PromQL in a domain file is a mechanism in layer 1, and every Service reimplements up-ness and restart detection | +| Authored rules per Application | Highest fidelity to what each owner considers broken | PromQL in a project file is a mechanism in layer 1, and every Application reimplements up-ness and restart detection | | Derive only an absent-target rule | Small and hard to get wrong | `platform-postgres` declaring `page` would get one rule that fires only when scraping breaks, which is not what `page` means | -| Derive a synthetic check for a Service with no signal | Nothing is ever silently unmonitored | Invents a signal the Service never declared, and reaching a readiness probe from outside the pod is a mechanism nobody asked for | +| Derive a synthetic check for an Application with no signal | Nothing is ever silently unmonitored | Invents a signal the Application never declared, and reaching a readiness probe from outside the pod is a mechanism nobody asked for | | Warn on a class with no signal | Nothing blocks on a monitoring gap | A warning is a log line here; the two live holes are both silences, which is the argument for a refusal | | `kubernetes` keeps all three monitoring kinds | No new adapter, no amendment | The `release: metrics-stack` label rule would live in the adapter that also emits Deployments and PVCs, and monitoring would have two owners once rules landed | @@ -119,12 +124,12 @@ changing it is an operational change rather than a refactor. for the estate's most important datastore, paid by its owner, once, and it is the exact defect the refusal exists to surface. - The catalog is observability data, so a rule that turns out to be wrong is - fixed once for the estate rather than per Service, which is the benefit, and + fixed once for the estate rather than per Application, which is the benefit, and also means one bad rule pages for everything at once. - Scrape timing is explicit in every monitor and stated in exactly one configuration, so changing the estate's interval is a config edit rather than an invisible chart default, paid in one more pinned value. - The model no longer renders `ServiceMonitor`, `PodMonitor` or `PrometheusRule`, so the `release: metrics-stack` label rule and every - monitoring kind belong to the observability Service, one producer, and the + monitoring kind belong to the observability Application, one producer, and the `kubernetes` adapter shrinks. diff --git a/docs/adr/model/0080-database-catalog-is-derived-data.md b/docs/adr/model/0080-database-catalog-is-derived-data.md index 205aee5..4b76490 100644 --- a/docs/adr/model/0080-database-catalog-is-derived-data.md +++ b/docs/adr/model/0080-database-catalog-is-derived-data.md @@ -9,20 +9,25 @@ rests-on: ["0005"] # The per-consumer database catalog is derived data, and Vault mints the credentials +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on The inbound edge set already carries everything a database catalog needs (which -Services consume this provider, and therefore which databases and owning users +Applications consume this provider, and therefore which databases and owning users must exist), and the credential can be issued rather than stored. False if: a consumer needs a database whose existence is not implied by an edge, or the estate's Vault has no database secrets engine and configuring one is refused. -Settled by: rendering the `data` domain and diffing the derived catalog against +Settled by: rendering the `data` project and diffing the derived catalog against `init-databases.sh`'s four databases, with every credential resolved through a `VaultDynamicSecret` and no password appearing in any rendered file. ## Why Chapter 16 lists "a database and owning user per consumer" as an inbound derivation and cites the evidence: 98 lines of `init-databases.sh` creating -`auth_db`, `agents_db`, `knowledge_db` and `n8n_db`, one per Service claiming a +`auth_db`, `agents_db`, `knowledge_db` and `n8n_db`, one per Application claiming a Postgres credential. The graph already knows all four. Nothing produces them, and chapter 10 refuses the obvious vehicle: an Asset may not be executable ([0012](0012-assets-not-code.md)), so the script has no legitimate home in the @@ -59,7 +64,7 @@ renderable and this decision settles. | Render the shell script from a platform template | Matches today's artifact exactly; one file, no second concept | A procedure in the render surface, reviewable only by execution, and it makes the model's ban on executable content a formality | | Render CRs for a database operator | Fully declarative and self-healing | Adds an operator and CRDs the substrate does not run, the same reason this lost for Vault policies | | A static credential per consumer at a KV path | Works with the mount that exists, no database engine to configure, and grants align trivially | A hand-rotated database password is the secret class the estate already has too much of, and it hides R20 rather than answering it | -| Let the consumer author its credential path | Grant and credential align by construction | Path layout is platform-assigned by [0023](0023-grant-unit-is-the-path.md), and this hands it back to the Service | +| Let the consumer author its credential path | Grant and credential align by construction | Path layout is platform-assigned by [0023](0023-grant-unit-is-the-path.md), and this hands it back to the Application | ## Reversibility Undo cost today: a derivation, a `ConfigMap`, and a role name: hours, since @@ -82,6 +87,6 @@ rather than editing a render. drop the database, because dropping data is destructive and gated by [0015](0015-durability-class-per-volume.md), paid as a stale database nobody deletes, which is the safe direction. -- The catalog is one object per provider Workload, so its diff shows the estate's +- The catalog is one object per provider Process, so its diff shows the estate's whole consumer set changing in one place, paid by nobody, and it is what makes an added consumer reviewable. diff --git a/docs/adr/model/0081-volume-size-is-a-hard-dimension.md b/docs/adr/model/0081-volume-size-is-a-hard-dimension.md index 296d8f1..6eb3ab2 100644 --- a/docs/adr/model/0081-volume-size-is-a-hard-dimension.md +++ b/docs/adr/model/0081-volume-size-is-a-hard-dimension.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#storage-and-durability +normative: spec/v1/10-project-intent.md#storage-and-durability rests-on: ["0004"] --- # A volume declares its size; the platform decides whether it fits +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on How much data a volume holds is knowable only to its owner, and whether that much fits is knowable only from the node contract, so the two halves belong to @@ -30,7 +35,7 @@ arbitrator with no request to arbitrate assigns nothing. The resolution is the shape [0061](0061-placement-is-hard-dimensions.md) already uses for the other contended quantities. `memory` and `cpu` are authored as hard -dimensions and matched against `allocatable`; the Service states the requirement +dimensions and matched against `allocatable`; the Application states the requirement and the platform decides eligibility. A volume's `size` is the same kind of fact: 20Gi of vault clone is a property of the data, and whether a node can hold it is a property of the estate. `E_STORAGE_UNSATISFIABLE` is the storage twin of @@ -38,15 +43,15 @@ a property of the estate. `E_STORAGE_UNSATISFIABLE` is the storage twin of A size class per engine or Durability Class was the alternative, and this estate has already priced that mistake twice. The health timeout class was a four-row -table that contradicted a declared budget inside one Service, and -`rollbackTargetRetention` was a value every Service declared identically and no +table that contradicted a declared budget inside one Application, and +`rollbackTargetRetention` was a value every Application declared identically and no renderer read. A table of guesses about how big a database is would be the third. -`placement.disk.size` becomes **derived** (the sum of the Workload's volume +`placement.disk.size` becomes **derived** (the sum of the Process's volume sizes), because the same quantity was otherwise authored twice, and chapter 16's single-authority property forbids exactly that. The two figures could disagree today with nothing detecting it. `disk.media` stays authored, because which media -a Workload needs is not implied by how much it needs: `platform-postgres` wants +a Process needs is not implied by how much it needs: `platform-postgres` wants NVMe for latency, not for room. `storageClassName` genuinely is assigned and stays absent: everything takes @@ -56,9 +61,9 @@ k3s's default `local-path`, and there is no second class to choose from. | option | cost if taken | why rejected | |---|---|---| | A size class per engine or Durability Class | Nothing new authored, and no author asks for 500Gi on a whim | A table of guesses about data size, which is what the deleted health-timeout class and `rollbackTargetRetention` both were | -| Reuse `placement.disk.size` as the capacity | No new field at all | A Workload with two volumes has one disk request and no way to say which volume gets what | +| Reuse `placement.disk.size` as the capacity | No new field at all | A Process with two volumes has one disk request and no way to say which volume gets what | | Keep both figures and check they agree | Nothing changes shape; the filter stays explicit | Two declaring sites for one quantity with a rule papering over it, which is the pattern single authority forbids | -| Drop `size` from `placement.disk` and check after binding | Smallest vocabulary | Placement could then put a Workload on a node that cannot hold its volumes, and the failure is a pending PVC rather than a build error | +| Drop `size` from `placement.disk` and check after binding | Smallest vocabulary | Placement could then put a Process on a node that cannot hold its volumes, and the failure is a pending PVC rather than a build error | ## Reversibility Undo cost today: one authored field and one derivation, hours. Becomes @@ -72,11 +77,11 @@ number later means a data move rather than a re-render. - A volume larger than any eligible node's `usable_gib` fails the render, so a 20Gi request on a cluster of 16GiB disks is a build error rather than a pod stuck `Pending`, paid at build time, deliberately. -- Capacity is now part of the eligible-node computation, so a Workload's node set +- Capacity is now part of the eligible-node computation, so a Process's node set can narrow when a volume grows, exactly as it does when memory grows, paid by whoever grows the volume, visibly. - `placement.disk.size` disappearing is a schema change to layer 1, so every - domain file declaring it must drop it; the sum is derived and cannot be + project file declaring it must drop it; the sum is derived and cannot be overridden without a reason, paid once, per repository. - A `local-path` PVC cannot be resized on this cluster, so the first number is load-bearing and growing a volume is a data move; the model states the size diff --git a/docs/adr/model/0082-images-lock-carries-uid-and-gid.md b/docs/adr/model/0082-images-lock-carries-uid-and-gid.md index a52e513..8997ce4 100644 --- a/docs/adr/model/0082-images-lock-carries-uid-and-gid.md +++ b/docs/adr/model/0082-images-lock-carries-uid-and-gid.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#the-uid-is-a-pinned-input-and-the-volume-needs-a-group +normative: spec/v1/10-project-intent.md#the-uid-is-a-pinned-input-and-the-volume-needs-a-group rests-on: ["0006"] --- # The images lock resolves each image's uid and gid, and fsGroup derives from the gid +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every image this estate runs declares a numeric user, or can be replaced by one that does, and that number plus its group is all a hardened pod and a writable @@ -34,7 +39,7 @@ no diagnostic pointing at the cause. A freshly provisioned `local-path` directory is **root-owned**. A non-root pod with no `fsGroup` cannot write it, so `platform-postgres` cannot `initdb` into -its own PV: the estate's most important stateful Workload, blocked by the +its own PV: the estate's most important stateful Process, blocked by the hardening class it is supposed to satisfy. And nothing in the model could state the UID, because a UID is a mechanism and @@ -54,17 +59,17 @@ the whole volume on every start, which on a large PV is minutes of startup the The alternative worth naming is the one most charts use: an init container that chowns the volume. It works, and it needs a root-capable container on every -stateful Workload, a `runAsRoot` exception in the inventory chapter 10 keeps +stateful Process, a `runAsRoot` exception in the inventory chapter 10 keeps precisely so that its length stays visible. Solving with an exception what `fsGroup` solves without one would make that inventory lie about the estate. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| The Workload authors `runAsUser` | Explicit and visible, no lock change | A UID is a mechanism, and it duplicates a fact the image carries: the two can disagree, and the image wins at runtime | -| The platform assigns a UID per Workload | Uniform, independent of what images declare | Requires every image to tolerate an arbitrary UID, and files already written to a PV by the image's own user stop being readable | -| Author `fsGroup` per Workload | Handles an image whose data group differs from its run group | A second mechanism in layer 1 restating what the image config already says | -| An init container that chowns the volume | Works regardless of the image, and is the common pattern | Needs a root-capable init container on every stateful Workload, which cannot meet `restricted` at all, for a problem `fsGroup` solves without one | +| The Process authors `runAsUser` | Explicit and visible, no lock change | A UID is a mechanism, and it duplicates a fact the image carries: the two can disagree, and the image wins at runtime | +| The platform assigns a UID per Process | Uniform, independent of what images declare | Requires every image to tolerate an arbitrary UID, and files already written to a PV by the image's own user stop being readable | +| Author `fsGroup` per Process | Handles an image whose data group differs from its run group | A second mechanism in layer 1 restating what the image config already says | +| An init container that chowns the volume | Works regardless of the image, and is the common pattern | Needs a root-capable init container on every stateful Process, which cannot meet `restricted` at all, for a problem `fsGroup` solves without one | ## Reversibility Undo cost today: two lock fields and one derivation, hours, and the lock is @@ -81,7 +86,7 @@ live data rather than re-rendering. - An image with a named `USER` cannot enter the estate until it is replaced or rebuilt, so `E_IMAGE_USER_NOT_NUMERIC` can block an image bump, paid by whoever bumps, with a diagnostic instead of a pod that will not start. -- `fsGroup` applies to every volume of a Workload, so a Workload holding two +- `fsGroup` applies to every volume of a Process, so a Process holding two volumes with different expected groups cannot be expressed; that is not a case the estate has, and it becomes an override with a reason if it appears, paid when it appears. diff --git a/docs/adr/model/0083-privileged-port-needs-the-capability.md b/docs/adr/model/0083-privileged-port-needs-the-capability.md index 39db7bb..806b1dc 100644 --- a/docs/adr/model/0083-privileged-port-needs-the-capability.md +++ b/docs/adr/model/0083-privileged-port-needs-the-capability.md @@ -3,29 +3,34 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#a-privileged-port-needs-the-capability-that-binds-it +normative: spec/v1/10-project-intent.md#a-privileged-port-needs-the-capability-that-binds-it rests-on: ["0005"] --- # A privileged port under non-root is refused +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on -Every Workload in this estate can listen above 1024, so no rendered pod needs a +Every Process in this estate can listen above 1024, so no rendered pod needs a silent capability. False if: an image the estate must run binds a privileged port and has no configuration for a different one, in which case it is refused and carried in a Bidirectional Ledger until it is replaced ([0055](0055-bidirectional-ledgers.md)). Settled by: rendering the estate with -no Workload declaring a port below 1024. +no Process declaring a port below 1024. ## Why `auth-ui` declared port 80 under the `restricted` class and therefore rendered a pod that cannot bind its own port: non-root plus `capabilities.drop: [ALL]` -removes `CAP_NET_BIND_SERVICE`. The render was internally consistent and the -workload could not start. The model said nothing either way. +removes `CAP_NET_BIND_APPLICATION`. The render was internally consistent and the +process could not start. The model said nothing either way. Refusing it is the reading that keeps the class honest. Deriving -`NET_BIND_SERVICE` wherever a low port appears would re-add a dropped capability -for every Workload that happens to declare one, silently, and the class would +`NET_BIND_APPLICATION` wherever a low port appears would re-add a dropped capability +for every Process that happens to declare one, silently, and the class would mean less than it says. There is no escape hatch, because [0016](0016-pod-hardening.md) deleted the @@ -40,7 +45,7 @@ number. The container port is invisible to consumers, to the rendered IngressRoute and to the Gatus check; only the Service object's `targetPort` moves. -Rewriting the port silently (deriving a high `targetPort` while the Service +Rewriting the port silently (deriving a high `targetPort` while the Application keeps 80) was the third option and it is the worst. The pod must actually listen where the platform decided, which no image obeys, so it would be a decision taken during serialisation that the process then contradicts. @@ -48,7 +53,7 @@ during serialisation that the process then contradicts. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Derive `NET_BIND_SERVICE` where a low port is declared | Nothing in any domain file changes | Silently re-adds a capability the class dropped, for every Workload that happens to declare a low port | +| Derive `NET_BIND_APPLICATION` where a low port is declared | Nothing in any project file changes | Silently re-adds a capability the class dropped, for every Process that happens to declare a low port | | Derive an unprivileged `targetPort` | Nothing authored changes and nothing is refused | The process still listens where its image says, so the rendered object and the running pod disagree | | Leave it to review | No new error code | The render is internally consistent, so review has nothing to notice; the failure appears as a crash-looping pod | diff --git a/docs/adr/model/0085-a-grant-is-a-union-on-engine.md b/docs/adr/model/0085-a-grant-is-a-union-on-engine.md index c33663f..d1c02f8 100644 --- a/docs/adr/model/0085-a-grant-is-a-union-on-engine.md +++ b/docs/adr/model/0085-a-grant-is-a-union-on-engine.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#secrets +normative: spec/v1/10-project-intent.md#secrets rests-on: ["0009"] --- # A grant is a discriminated union on engine, and every grant derives a read path +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on The estate uses three Secret Store engines, they authorise different operations, and each declaration derives exactly one set of read paths. False if: an engine @@ -67,7 +72,7 @@ the version that stays checkable. Undo cost today: the union collapses back to its `kv` arm by deleting two arms and the derived-path table: hours, and `engine` defaults to `kv` so no existing document changes either way. Becomes irreversible once: a derived policy grants -a live workload access through a non-KV arm, because collapsing the union would +a live process access through a non-KV arm, because collapsing the union would then revoke privilege something depends on. ## Consequences diff --git a/docs/adr/model/0086-kv-read-covers-its-metadata-sibling.md b/docs/adr/model/0086-kv-read-covers-its-metadata-sibling.md index 8cc25fd..607d327 100644 --- a/docs/adr/model/0086-kv-read-covers-its-metadata-sibling.md +++ b/docs/adr/model/0086-kv-read-covers-its-metadata-sibling.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#secrets +normative: spec/v1/10-project-intent.md#secrets rests-on: ["0009"] --- # A KV-v2 read grant covers the document's metadata sibling +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on KV-v2 splitting one document across `secret/data/` and `secret/metadata/` is an implementation detail of the engine, not two @@ -55,7 +60,7 @@ That is a worse trade than the one this makes. ## Reversibility Undo cost today: one stanza in the derivation. Becomes irreversible once: a -workload or a human process depends on listing versions, because removing the +process or a human process depends on listing versions, because removing the stanza then breaks an audit path rather than narrowing an unused one. ## Consequences diff --git a/docs/adr/model/0087-token-mounted-only-for-delivery-self.md b/docs/adr/model/0087-token-mounted-only-for-delivery-self.md index d8dae64..521c2d9 100644 --- a/docs/adr/model/0087-token-mounted-only-for-delivery-self.md +++ b/docs/adr/model/0087-token-mounted-only-for-delivery-self.md @@ -9,13 +9,18 @@ rests-on: ["0005"] # A ServiceAccount token is mounted only where the pod itself authenticates +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on A pod needs its ServiceAccount token exactly when it authenticates to something with it, and in this estate that is exactly `delivery: self`. False if: a -Workload needs the token for a reason no declaration implies and the case is +Process needs the token for a reason no declaration implies and the case is common enough that an override is the normal path rather than the exception. Settled by: rendering the estate and finding `automountServiceAccountToken: -false` on every Workload except those holding a `delivery: self` grant, with +false` on every Process except those holding a `delivery: self` grant, with `agents-api` the only override. ## Why @@ -32,8 +37,8 @@ anything to anyone. Only `delivery: self` means the pod authenticates with its own token (that is the whole content of the word `self`), so `delivery` is the field the derivation must read, and a grant's existence says nothing on its own. -This is the same shape as [0075](0075-no-workload-rbac-in-v1.md): the privilege -a Workload of this estate needs is smaller than the default, and refusing to +This is the same shape as [0075](0075-no-process-rbac-in-v1.md): the privilege +a Process of this estate needs is smaller than the default, and refusing to render the default is what makes that visible. There the object was a Role; here it is a token, and mounting one into a pod that never uses it is a credential sitting in a container filesystem for no reason: the thing an attacker reads @@ -73,7 +78,7 @@ live pod template, and widening it back is one derivation change. writes one it fails at runtime with a 403 from the API server rather than at build time, paid by its owner, and it is the one case the derivation deliberately does not guess at. -- A Workload switching a grant from `self` to `env` loses its token, which is +- A Process switching a grant from `self` to `env` loses its token, which is correct and is also a change nobody asked for when they changed the delivery mode, paid by whoever switches, visibly in the projection diff. - The override count becomes a number worth watching: every pod holding a token diff --git a/docs/adr/model/0088-startup-probe-targets-liveness.md b/docs/adr/model/0088-startup-probe-targets-liveness.md index 44c3977..6257828 100644 --- a/docs/adr/model/0088-startup-probe-targets-liveness.md +++ b/docs/adr/model/0088-startup-probe-targets-liveness.md @@ -3,16 +3,21 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#what-the-probe-derivation-completes +normative: spec/v1/10-project-intent.md#what-the-probe-derivation-completes rests-on: ["0005"] --- # The startup probe targets liveness, and probe cadence is platform policy +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on A startup probe and a liveness probe ask the same question of a process, so one declaration serves both, and the cadence at which any probe runs is an estate -concern rather than a per-Workload one. False if: a Workload's liveness endpoint +concern rather than a per-Process one. False if: a Process's liveness endpoint is unavailable during startup for a reason that is not a defect (a process that serves liveness only after warm-up), making the startup probe unable to use it. Settled by: rendering the estate's probes with every startup probe pointing at a @@ -44,7 +49,7 @@ That the wider convention points startup probes at readiness is not evidence against this. The convention exists because most charts declare one endpoint and call it both, which is the fallback 0014 already refused. -A Workload declaring readiness and no liveness derives **no startup probe**, +A Process declaring readiness and no liveness derives **no startup probe**, because there is nothing safe to poll, and its start is bounded by the progress deadline alone. That is a narrower guarantee, and it is honest: the alternative is to invent a target. @@ -62,7 +67,7 @@ them; a delay on top would be a second waiting period nobody declared. | option | cost if taken | why rejected | |---|---|---| | The startup probe targets readiness | Matches the common convention, and startup does ask "is it up" | A failing startup probe restarts the container, so this imports 0014's crash-loop-on-a-dependency-outage into the startup path | -| A third authored `probes.startup` block | Most explicit, and 0014's no-fallback principle taken to its end | A third block on every Workload for a value derivable from one already there, when chapter 10 says timings and thresholds stay derived | +| A third authored `probes.startup` block | Most explicit, and 0014's no-fallback principle taken to its end | A third block on every Process for a value derivable from one already there, when chapter 10 says timings and thresholds stay derived | | Fixed cadence constants in the spec | No context field, impossible to drift per cluster | Changing the estate's probe cadence becomes a spec amendment rather than a context republish with a lock | | Derive cadence from `startupBudget` | One authored number drives every timing | The relationship is invented; cold-start duration does not imply steady-state polling frequency | @@ -75,9 +80,9 @@ getting it undecided was not. ## Consequences - R14 closes, and no adapter chooses a probe target, which is what made the old behaviour a layer violation rather than merely a gap, paid by nobody. -- A Workload with readiness and no liveness gets no startup probe, so a slow +- A Process with readiness and no liveness gets no startup probe, so a slow starter without a liveness endpoint is bounded only by its progress deadline, - paid by that Workload's owner, who can declare liveness and get the budget. + paid by that Process's owner, who can declare liveness and get the budget. - Probe cadence becomes a pinned input, so retuning the estate's probes is a context republish and a new lock, and every rendered probe changes in one diff, paid in one more pinned value, and it replaces four hand-copied blocks. diff --git a/docs/adr/model/0089-replicas-derived-no-minavailable.md b/docs/adr/model/0089-replicas-derived-no-minavailable.md index cc3a3c8..f38b67c 100644 --- a/docs/adr/model/0089-replicas-derived-no-minavailable.md +++ b/docs/adr/model/0089-replicas-derived-no-minavailable.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#replicas-and-the-disruption-budget +normative: spec/v1/10-project-intent.md#replicas-and-the-disruption-budget rests-on: ["0002"] --- # `replicas` derives as one, `minAvailable` is deleted, and a budget over one replica is not emitted +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-10.** The generic override mechanism this decision relied on > is deleted ([0031](0031-derived-overrides-with-reason.md)). The second replica > is now the named `replicas: {count, reason}` field, the sole local exception @@ -16,14 +21,14 @@ rests-on: ["0002"] > substance is unchanged and is now stricter: `count` must exceed one and > `reason` is **required** whenever the block is present, so the field cannot > become a verbose spelling of the default -> ([chapter 10](../../../spec/v1/10-service-intent.md#capacity)). +> ([chapter 10](../../../spec/v1/10-project-intent.md#capacity)). ## Rests on -No Workload in this estate obtains availability from a replica count, so a +No Process in this estate obtains availability from a replica count, so a declared availability requirement could only ever be a request the substrate -cannot honour. False if: a stateless Workload's second replica measurably +cannot honour. False if: a stateless Process's second replica measurably survives an event that takes the first one down: which requires two nodes, a -shared-nothing workload and a load balancer that notices. Settled by: rendering +shared-nothing process and a load balancer that notices. Settled by: rendering the estate with `replicas: 1` everywhere except the declared capacity exceptions, and `kubectl drain` on the control-plane node completing rather than blocking. @@ -40,7 +45,7 @@ node is also the control plane. `minAvailable` was never graded, and grading it against [0002](0002-kubernetes-as-substrate.md) is what deletes it. Storage is `local-path`, all fourteen PVCs are `ReadWriteOnce`, and a `local-path` volume -does not survive its node, so every stateful Workload is pinned to one machine +does not survive its node, so every stateful Process is pinned to one machine by construction. Rescheduling does not exist. Control-plane HA does not exist. Two replicas on one node are two processes on one kernel. A field whose meaning is "how many pods must stay up" cannot be honoured by a substrate where the @@ -57,11 +62,11 @@ The budget is the operational half. Emitting a PDB only where `replicas` exceeds one removes the deadlock class entirely, and expressing it as `maxUnavailable: 1` rather than `minAvailable: replicas - 1` means a drain can always make progress and the guarantee does not have to be recomputed when a count changes. A -single-replica Workload gets no budget, because a budget that forbids its only +single-replica Process gets no budget, because a budget that forbids its only eviction is not protection. Emitting no PDB at all was tempting and slightly wrong: a two-replica stateless -Workload spread over seven nodes does benefit from not losing both at once, and +Process spread over seven nodes does benefit from not losing both at once, and that is the one case a budget earns its place here. ## Alternatives @@ -69,8 +74,8 @@ that is the one case a budget earns its place here. |---|---|---| | Keep `minAvailable`, graded as an availability requirement | The six live PDBs stay derivable from a declaration | Grades a field whose meaning 0002 denies: no rescheduling, no HA control plane, one node per stateful volume | | Replace it with an availability class (`single`, `multi`) | Reads as intent, hides the arithmetic | A two-value class table over a property the substrate cannot deliver, and two such tables were deleted this week already | -| `minAvailable: replicas - 1` | Stays in the vocabulary the live objects use | The arithmetic is per Workload, so a count change silently changes the budget's meaning, and `replicas: 1` still deadlocks unless special-cased | -| No PDB at all | Nothing to derive; every stateful Workload is pinned anyway | Loses the one case a budget earns here: a multi-replica stateless Workload losing both pods to one drain | +| `minAvailable: replicas - 1` | Stays in the vocabulary the live objects use | The arithmetic is per Process, so a count change silently changes the budget's meaning, and `replicas: 1` still deadlocks unless special-cased | +| No PDB at all | Nothing to derive; every stateful Process is pinned anyway | Loses the one case a budget earns here: a multi-replica stateless Process losing both pods to one drain | ## Reversibility Undo cost today: `replicas` and the PDB are both mutable on live objects, and @@ -80,13 +85,13 @@ was survivable and the undecidedness was not. ## Consequences - R16 closes, and the drain deadlock closes with it: no rendered PDB can forbid - the eviction of a Workload's only pod, paid by nobody, and it was reachable + the eviction of a Process's only pod, paid by nobody, and it was reachable on the control-plane node. - `auth-api` declares `replicas: {count: 2, reason: …}` to keep its second replica, so the capacity decision becomes a recorded reason instead of a number nobody can source, paid by its owner, once. - The estate's six live PDBs are not all re-derivable: any over a single-replica - Workload will not be rendered, so adoption drops them, which is the intended + Process will not be rendered, so adoption drops them, which is the intended correction and will look like a removal in the first diff, paid at adoption, deliberately. - The last ungraded field in chapter 10 is gone, so "still to be graded" shrinks diff --git a/docs/adr/model/0090-edges-resolve-against-the-register.md b/docs/adr/model/0090-edges-resolve-against-the-register.md index 054cfae..f5b2f2b 100644 --- a/docs/adr/model/0090-edges-resolve-against-the-register.md +++ b/docs/adr/model/0090-edges-resolve-against-the-register.md @@ -9,6 +9,11 @@ rests-on: ["0005"] # An edge resolves against the union or the unmanaged register, and the register carries coordinates +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + > **Amended 2026-09-08.** The register this ADR extended is split > ([0095](0095-platform-intent-is-the-second-authored-document.md)). A target the > estate *depends on* (`stalwart` with an address and surfaces) is a @@ -22,19 +27,19 @@ rests-on: ["0005"] > edge resolves against facts and never against exemptions. ## Rests on -Every provider a Workload depends on is either deployed by this model or +Every provider a Process depends on is either deployed by this model or registered as something the estate runs and does not deploy, so an edge has exactly two namespaces to resolve against and no third case. False if: a -Workload legitimately depends on something in neither (an internet endpoint +Process legitimately depends on something in neither (an internet endpoint that belongs in no register) often enough that registering it is the wrong shape. Settled by: rendering the estate with `auth`'s SMTP edge deriving an egress rule to `stalwart`'s registered address, and no rendered policy missing a rule for any declared edge. ## Why -`E_UNRESOLVED_SERVICE` already refuses an edge naming a Service that is not in +`E_UNRESOLVED_APPLICATION` already refuses an edge naming an Application that is not in the union, so the silent case is subtler and worse: an edge naming something the -estate **has** but the model does not deploy. `{service: stalwart, surface: +estate **has** but the model does not deploy. `{application: stalwart, surface: smtp}` is the live example. Nothing resolved, so nothing derived (no coordinates, therefore no egress rule) and the result is a *valid* policy that is short one rule. The on-call sees a connection timeout; no gate goes red. The @@ -71,7 +76,7 @@ nowhere is the model that lost the rule in the first place. ## Alternatives | option | cost if taken | why rejected | |---|---|---| -| Union Services only; an external provider is not an edge | Keeps the edge set purely internal | The dependency exists in fact and would exist nowhere in the model, the condition that produced R18 | +| Union Applications only; an external provider is not an edge | Keeps the edge set purely internal | The dependency exists in fact and would exist nowhere in the model, the condition that produced R18 | | A raw host or CIDR in the edge | Derivable with no lookup | An address is a mechanism, and the same endpoint gets written into every consumer that needs it | | No coordinates: widen egress to the node network for such edges | Always works, nothing to declare | A blanket allow to the node network is the east-west openness default-deny exists to close | | Warn on an unresolved target and continue | Nothing blocks | A warning is a log line here, and the failure it describes is already invisible on-call | diff --git a/docs/adr/model/0091-identity-placeholders-not-framework-wiring.md b/docs/adr/model/0091-identity-placeholders-not-framework-wiring.md index 2ab2e41..44c8ce9 100644 --- a/docs/adr/model/0091-identity-placeholders-not-framework-wiring.md +++ b/docs/adr/model/0091-identity-placeholders-not-framework-wiring.md @@ -3,16 +3,21 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#configuration +normative: spec/v1/10-project-intent.md#configuration rests-on: ["0005"] --- -# The model derives no framework wiring; it exposes the Workload's own identity as placeholders +# The model derives no framework wiring; it exposes the Process's own identity as placeholders + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on -A self-delivering Workload's Vault client configuration is its framework's +A self-delivering Process's Vault client configuration is its framework's concern, and the only part the model must supply is the derived values that -configuration references. False if: two Workloads of the estate wire the same +configuration references. False if: two Processes of the estate wire the same framework and their boilerplate drifts apart in a way that breaks one of them, which would make the duplication, not the taxonomy, the real cost. Settled by: `auth-api` rendering with its four spring-cloud-vault lines in its own env file, @@ -31,31 +36,31 @@ the estate ever adopts, and R22 records the pressure to do exactly that. A `secretClient` field with a platform catalog was the other real option, and it loses on the same ground as a neutral IR: each catalog entry is the model guessing at somebody else's configuration surface, and the taxonomy grows -whenever a repository changes library. [0078](0078-engine-is-workload-vocabulary.md) +whenever a repository changes library. [0078](0078-engine-is-process-vocabulary.md) added a field for what a process **is**, which the platform must act on to back it up. A framework is what a process is *built with*, and nothing the platform does depends on it. -So the wiring stays where the framework knowledge is: the Workload's own env +So the wiring stays where the framework knowledge is: the Process's own env file, in its own repository. That leaves one real problem, which is the reason this is a decision rather than a shrug. One of those four lines is `VAULT_KUBERNETES_ROLE: auth-api`, and the role name is **derived** -([0024](0024-identity-per-workload.md)). Written as a literal it is precisely -the staleness that produced the `serviceAccountName()` defect, where a +([0024](0024-identity-per-process.md)). Written as a literal it is precisely +the staleness that produced the `applicationAccountName()` defect, where a hand-maintained name and a derived one disagreed and nothing noticed until two -Workloads shared a principal. +Processes shared a principal. Hence `${identity:…}`, a fourth named source beside `${dependency:…}`, `${secret:…}` and `${exposure:…}`. The first three name something else; this one -names what the platform decided about **this** Workload (`vaultRole`, -`serviceAccount`, `namespace`), as a closed key set, with no template language, +names what the platform decided about **this** Process (`vaultRole`, +`applicationAccount`, `namespace`), as a closed key set, with no template language, exactly like the others. A literal where a placeholder belongs is already a build error, so the drift closes. Restricting it to `vaultRole` alone was tempting and leaves the next self-delivering -Workload writing its namespace as a literal, which is the same defect one field +Process writing its namespace as a literal, which is the same defect one field later. Reusing `${dependency:…}` for self-reference would muddle a source that -means "an edge to another Service" and force the edge invariants to special-case +means "an edge to another Application" and force the edge invariants to special-case it. ## Alternatives @@ -64,8 +69,8 @@ it. | A `secretClient` field with a platform wiring catalog | Removes the boilerplate; derived once for every consumer | The model would carry a framework taxonomy that grows with every library adopted, each entry a guess at someone else's configuration surface | | Extend `runtime` into a framework taxonomy | One field, no new vocabulary | The overload R22 names: every runtime crossed with every framework, with observability wiring hanging off the same value | | Keep "derives the client wiring" and special-case Spring | Matches what the estate needs today, exactly | One framework hardcoded into a platform derivation, and the second framework rewrites it | -| Only `${identity:vaultRole}` | Smallest surface, nothing speculative | The next Workload writes its namespace as a literal, reintroducing the drift one field over | -| Reuse `${dependency:…}` for self-reference | No new source | A dependency is an edge to another Service; self-reference muddles the source and the invariants that check edges | +| Only `${identity:vaultRole}` | Smallest surface, nothing speculative | The next Process writes its namespace as a literal, reintroducing the drift one field over | +| Reuse `${dependency:…}` for self-reference | No new source | A dependency is an edge to another Application; self-reference muddles the source and the invariants that check edges | ## Reversibility Undo cost today: one placeholder source with three keys, and the sentence about @@ -76,9 +81,9 @@ though that is a rename, not a redesign. ## Consequences - R22 closes without the model learning what a framework is, which is the outcome worth having, paid in four boilerplate lines per self-delivering - Workload, in the repository that owns the framework. + Process, in the repository that owns the framework. - `VAULT_KUBERNETES_ROLE` can no longer disagree with the derived role, so the - `serviceAccountName()` class of defect is closed on the authoring side too, + `applicationAccountName()` class of defect is closed on the authoring side too, paid by nobody. - A fourth placeholder source is a fourth thing to validate, complete in an editor and resolve; the key set is closed so the validation is a lookup, paid diff --git a/docs/adr/model/0092-writable-paths-are-declared.md b/docs/adr/model/0092-writable-paths-are-declared.md index c8b30eb..0308581 100644 --- a/docs/adr/model/0092-writable-paths-are-declared.md +++ b/docs/adr/model/0092-writable-paths-are-declared.md @@ -3,18 +3,23 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#writable-paths-are-declared-not-exempted +normative: spec/v1/10-project-intent.md#writable-paths-are-declared-not-exempted rests-on: ["0005"] --- -# A Workload declares the paths it writes, and that is not a hardening exception +# A Process declares the paths it writes, and that is not a hardening exception + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on A process under a read-only root filesystem writes to a small, knowable set of paths, and one ephemeral size covers every such path in this estate. False if: a -Workload's writable set cannot be enumerated ahead of time, or one default size +Process's writable set cannot be enumerated ahead of time, or one default size is wrong often enough that the override is the normal case. Settled by: -rendering the estate with every non-static Workload declaring its writable paths, +rendering the estate with every non-static Process declaring its writable paths, `auth-ui`'s `writableRootFilesystem` exception deleted, and no override on the size. @@ -24,7 +29,7 @@ does not mean nothing writes. A JVM needs `/tmp`. nginx needs `/var/cache/nginx` and `/var/run` before it can serve a request. The model had no way to say so, which produced two defects at once. -The first is undeclared behaviour. `auth.domain.yml` states that "the JVM writes +The first is undeclared behaviour. `auth.project.yml` states that "the JVM writes only to `/tmp`, which the render supplies as an `emptyDir`": a mount no chapter specifies, from a derivation that exists nowhere. Either every pod gets a `/tmp` nobody asked for, or `auth-api` does not start, and which one happens is a @@ -35,7 +40,7 @@ The second is worse, because it corrupts a control the estate depends on. writable directories, and its own recorded reason predicts the fix: *"the exception retires when a rebuilt image relocates both paths onto a mounted emptyDir."* Under this decision no rebuild is needed, the paths are mounted by -declaration, and the exception retires now. That matters beyond one Workload: +declaration, and the exception retires now. That matters beyond one Process: chapter 10 refuses an image that cannot meet the class rather than relaxing a control for it, so a mounted tmpfs must not be confused with disabling `readOnlyRootFilesystem`. Only one of the two is expressible, and it is this @@ -53,7 +58,7 @@ Platform Intent carries one default, the same shape as probe cadence and scrape timing. Authoring a size per path was the alternative and it is 0081's shape, which is right for a persistent volume whose size is a property of the data and wrong here: a temp directory's size is a property of the node's tolerance, not of -the Service. +the Application. Nothing is implicit. `/tmp` is not supplied unless declared, because a mount nobody asked for appears in every static image that never writes, and an implicit @@ -76,13 +81,13 @@ re-adding a blanket relaxation the inventory has stopped carrying. ## Consequences - R15 closes, and one entry leaves the exception inventory, the first time that list has shrunk for a reason other than a rebuilt image, paid by nobody. -- Every non-static Workload now declares its writable set, so an image whose +- Every non-static Process now declares its writable set, so an image whose write behaviour nobody knows has to be examined before it renders, paid by its owner, once, and it is information the estate did not have. - A wrong or missing path is a runtime failure, not a build error: the model cannot know what a process writes, so a forgotten `/var/run` surfaces as a crash, paid at first render, which is the earliest anything could know. -- One ephemeral default governs the estate, so a Workload that needs a large +- One ephemeral default governs the estate, so a Process that needs a large temp area carries an override with a reason and shows up in review, paid in one line, deliberately visible. - `emptyDir` is node-local and lost on restart, which is correct for every path diff --git a/docs/adr/model/0093-route-precedence-is-derived.md b/docs/adr/model/0093-route-precedence-is-derived.md index ccd90d7..9ccf23c 100644 --- a/docs/adr/model/0093-route-precedence-is-derived.md +++ b/docs/adr/model/0093-route-precedence-is-derived.md @@ -3,12 +3,17 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#exposure +normative: spec/v1/10-project-intent.md#exposure rests-on: ["0005"] --- # Route precedence is derived and rendered explicitly, and a duplicate route is refused +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Which route serves a request is decided by the declaration, and path specificity is enough to decide it for every exposure in this estate. False if: two routes on @@ -76,5 +81,5 @@ the field then reorders live traffic. currently resolves silently; adoption may surface one, paid at adoption, which is the point of a build error over a coin flip. - The rendered IngressRoute grows a field, so the golden trees change for every - routed Service, paid once, in the comment-strip pass that is already rewriting + routed Application, paid once, in the comment-strip pass that is already rewriting them. diff --git a/docs/adr/model/0094-asset-change-restarts-unconditionally.md b/docs/adr/model/0094-asset-change-restarts-unconditionally.md index f8239a8..7260a31 100644 --- a/docs/adr/model/0094-asset-change-restarts-unconditionally.md +++ b/docs/adr/model/0094-asset-change-restarts-unconditionally.md @@ -3,11 +3,16 @@ tier: decision status: proposed claim: settled date: 2026-09-07 -normative: spec/v1/10-service-intent.md#assets +normative: spec/v1/10-project-intent.md#assets rests-on: ["0005"] --- -# An Asset change is content-hashed and restarts the Workload; there is no onChange field +# An Asset change is content-hashed and restarts the Process; there is no onChange field + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on No image in this estate reloads its own configuration file without being told, @@ -25,7 +30,7 @@ estate's 18 ConfigMaps are plain today, so an edit applies successfully and neve reaches the pod. `reload` had no mechanism at all: Kubernetes has no primitive that reloads a process, no image here watches its own config file, and reaching into a running container to signal it would need Kubernetes API access that -[0075](0075-no-workload-rbac-in-v1.md) refuses. +[0075](0075-no-process-rbac-in-v1.md) refuses. Three ways to give `reload` a meaning were considered and all three cost more than the value returns. A hash-named Job invoking the engine's reload command @@ -36,12 +41,12 @@ A reloader operator adds an operator the substrate does not run and triggers restarts anyway. Watching the file is the image's job and no image does it. So `reload` is deleted, and with it the field: an enum with one legal value is a -label an author has to type, which is [0078](0078-engine-is-workload-vocabulary.md)'s +label an author has to type, which is [0078](0078-engine-is-process-vocabulary.md)'s own rule turned on this vocabulary. Propagation becomes an unconditional property of every Asset rather than a promise kept by the Assets that remembered to ask. The cost is stated rather than mitigated, because hiding it would be worse. -Under [0089](0089-replicas-derived-no-minavailable.md) a stateful Workload runs +Under [0089](0089-replicas-derived-no-minavailable.md) a stateful Process runs one replica with `Recreate`, so editing one line of `postgresql.conf` takes `platform-postgres` down for a restart. That is what an Asset edit costs on this substrate. An author who needs it cheaper needs a mechanism (an image that @@ -71,7 +76,7 @@ schema addition with a default, which every consumer tolerates. - R23 closes, and the propagation guarantee now holds for every Asset rather than for those that declared it, paid by nobody, and it fixes the 16 plain ConfigMaps by construction. -- Editing an Asset on a stateful Workload is an outage, stated in the chapter, so +- Editing an Asset on a stateful Process is an outage, stated in the chapter, so a one-line `postgresql.conf` change is planned rather than discovered, paid by whoever edits, knowingly. - One authored field leaves layer 1, which is the second field this pass has 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 05254fb..872229b 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 @@ -9,13 +9,18 @@ rests-on: ["0004"] # Platform Intent is the second authored document, published as an Intent Fragment +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every platform-side value the render reads is either a fact about the estate or a policy the estate applies uniformly, and the contention test sorts every value -between the Service's document and the platform's with no residue. False if: a -value turns up that neither a Service could state for itself nor the platform can -state once for the estate: something per-Service that only the platform knows. -Settled by: composing the worked domains with the Platform document as a +between the Application's document and the platform's with no residue. False if: a +value turns up that neither an Application could state for itself nor the platform can +state once for the estate: something per-Application that only the platform knows. +Settled by: composing the worked projects with the Platform document as a participant and finding every field of the former Cluster Context assigned to exactly one of the two authored kinds, with no third document. @@ -40,7 +45,7 @@ value that must be unique across the estate or draws on a shared finite resource is the platform's to declare. And it has a **schema to derive from**, which is what an editor, a validator and a lock all need. -Publication follows from the same move. A domain enters composition as an +Publication follows from the same move. A project enters composition as an Intent Fragment by OCI digest ([0037](0037-composition-oci-fragments.md)); the Cluster Context entered by a separate side channel, also by digest, for no reason other than history. One mechanism, one lock, and the platform becomes a @@ -48,9 +53,9 @@ reason other than history. One mechanism, one lock, and the platform becomes a render against a stale platform document is `E_PARTICIPANT_STALE`, where before it was a digest nobody compared to a clock. -Two things deliberately stay out. Foundation components are Services in domain +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 a Service is the duplicate vocabulary +second way to declare an Application is the duplicate vocabulary [0003](0003-three-layer-meta-model.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. @@ -65,7 +70,7 @@ both, and an edge should resolve against facts, never against exemptions. | option | cost if taken | why rejected | |---|---|---| | Give the Cluster Context a chapter and leave it a separate pinned input | Preserves today's diagram; smallest edit | Two publication paths for two documents obeying one rule, and a participant no participants list can declare missing | -| One Platform document holding facts and foundation components | One file to find | Invents a second way to declare a Service: the platform's Vault written differently from a tenant's Postgres | +| One Platform document holding facts and foundation components | One file to find | Invents a second way to declare an Application: the platform's Vault written differently from a tenant's Postgres | | Fold the node contract in | Every estate fact in one document | nix imports a deployment-model document, or the facts exist twice; 0056 was decided to prevent the second | | Keep providers in the unmanaged register with optional coordinates | One list, as 0090 left it | A dependency the estate relies on filed as an accepted hole, with a review date threatening the build for something not going away | diff --git a/docs/adr/model/0096-the-foundation-is-declared.md b/docs/adr/model/0096-the-foundation-is-declared.md index a7d7489..124f6cd 100644 --- a/docs/adr/model/0096-the-foundation-is-declared.md +++ b/docs/adr/model/0096-the-foundation-is-declared.md @@ -7,16 +7,21 @@ normative: spec/v1/14-platform-intent.md#the-foundation-is-declared rests-on: ["0003"] --- -# The foundation is declared as Services; nothing hand-written enters the render +# The foundation is declared as Applications; nothing hand-written enters the render + +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. ## Rests on -Every foundation component the estate runs can be stated in the Service Intent -vocabulary (an image, Workloads, grants, exposure, volumes, a class) and what +Every foundation component the estate runs can be stated in the Project Intent +vocabulary (an image, Processes, grants, exposure, volumes, a class) and what a Helm chart adds beyond that is either a default a declaration replaces or a CRD the bootstrap set pins. False if: a component the estate needs has configuration no field of chapter 10 or 14 can express and no derivation can produce. Settled by: rendering Vault, VSO, Traefik, Prometheus and Gatus from platform-owned -domain files and diffing the result against what the packs deliver today, with +project files and diffing the result against what the packs deliver today, with every difference explained by a decision rather than a missing word. ## Why @@ -31,10 +36,10 @@ that the foundation is *"the part carrying the CVEs and the CRD upgrades"*, precisely the objects that most need a diff a reviewer reads. The alternative to a pack is not a bigger pack; it is a declaration. Vault is a -Workload with a volume of class `irreplaceable` and an `engine`. Traefik is two -Services placed by capability (one on the `public-ingress` node, one on a LAN -node), each the proxy for one tier. VSO is a Workload with grants. Prometheus and -Gatus are Workloads whose configuration is an **inbound derivation**: what every +Process with a volume of class `irreplaceable` and an `engine`. Traefik is two +Applications placed by capability (one on the `public-ingress` node, one on a LAN +node), each the proxy for one tier. VSO is a Process with grants. Prometheus and +Gatus are Processes whose configuration is an **inbound derivation**: what every scrape surface and every exposure in the union implies for them, which is the shape [0080](0080-database-catalog-is-derived-data.md) already gave the database catalog. Every word needed exists in chapter 10 today. @@ -43,7 +48,7 @@ Helm is the objection, and it dissolves into two parts. A chart brings **defaults**, and a declaration is what replaces a default. A chart brings **CRDs**, which are cluster-scoped schema that must exist before any object of that kind can apply, so they join the bootstrap set as pinned facts, beside k3s -itself, the Flux source and Vault's unseal. Adding a chart-shaped Workload source +itself, the Flux source and Vault's unseal. Adding a chart-shaped Process source instead would reintroduce a hand-maintained values surface, which is a chart's whole configuration, and every invariant that reads a Deployment would see nothing. @@ -51,10 +56,10 @@ nothing. The bootstrap set is deliberately minimal and deliberately a table: k3s because it is what applies, the Flux source because it pulls the tree everything else is in, Vault's unseal because the model must never hold that secret, and the -CRDs. Growing it is a decision. Everything else is a Service. +CRDs. Growing it is a decision. Everything else is an Application. Deleting the pass-through is the same decision applied to the escape hatch. -[0012](0012-assets-not-code.md) refuses executable content for a Service; a raw +[0012](0012-assets-not-code.md) refuses executable content for an Application; a raw manifest is content the model cannot see, for the platform. What cannot yet be declared is a Bidirectional Ledger entry with a review date ([0055](0055-bidirectional-ledgers.md)), a deferred fix, never a permanent @@ -64,12 +69,12 @@ exemption. | option | cost if taken | why rejected | |---|---|---| | Keep packs at a pinned checkout, ledgered | Cheapest today; 0013 already decided how they arrive | Leaves 41 hand-written objects outside every invariant, carrying the estate's CVEs and CRD upgrades | -| A chart-shaped Workload source rendering `HelmRelease` | Fast path for chart-only upstreams | Reintroduces a values surface no invariant reads, and the Deployment-shaped checks see nothing | +| A chart-shaped Process source rendering `HelmRelease` | Fast path for chart-only upstreams | Reintroduces a values surface no invariant reads, and the Deployment-shaped checks see nothing | | Declare everything, including Flux and Vault's unseal | Purest | A render cannot apply itself; the description would be aspirational rather than checkable | | Keep the raw-manifest pass-through, guarded | Handles the case nobody anticipated | An unbounded shape the model cannot see; the ledger already exists for exactly the case nobody anticipated | ## Reversibility -Undo cost today: the platform domain files are deletable and the packs still +Undo cost today: the platform project files are deletable and the packs still exist in `flux-modules`. Becomes irreversible once: the packs are deleted from their repository and the rendered foundation is what runs, because reverting then means reconstructing hand-written objects from a rendered tree. @@ -79,7 +84,7 @@ then means reconstructing hand-written objects from a rendered tree. paid in one supersession. - `flux-packs` and `flux-source` are deleted, and `HelmRelease` and `HelmRepository` stop being rendered kinds, paid by whoever declares the five - foundation Services, once each. + foundation Applications, once each. - The foundation gets default-deny, the secrets gate, the label set, attribution and a byte diff on every change, which it has never had, paid in the first diff, which will be large and is the point. @@ -87,5 +92,5 @@ then means reconstructing hand-written objects from a rendered tree. 0059's stopping clause is amended to say what is actually held constant (the Flux installation, applying a tree the model renders), paid in scope, and chapter 00 already said the foundation is where the risk is. -- Adoption order in chapter 60 puts the platform domain first, because tenants +- Adoption order in chapter 60 puts the platform project first, because tenants depend on it, paid in sequencing, which the Reconcile Unit already computes. diff --git a/docs/adr/model/0097-authored-values-name-model-concepts.md b/docs/adr/model/0097-authored-values-name-model-concepts.md index b448ec4..d398398 100644 --- a/docs/adr/model/0097-authored-values-name-model-concepts.md +++ b/docs/adr/model/0097-authored-values-name-model-concepts.md @@ -9,12 +9,17 @@ rests-on: ["0005"] # An authored value names a model concept; the target's spelling is a derivation +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every value a human writes in either authored document can be named for what it means in the model, and mapping that name to the substrate's field is a derivation with one declaring site. False if: an authored value exists whose only faithful name is the target's, a knob with no model-level meaning that a -Service nevertheless needs to turn. Settled by: a grep of both authored kinds +Application nevertheless needs to turn. Settled by: a grep of both authored kinds for Kubernetes field paths, Traefik keys, Linux shell and k3s flags returning nothing, with every rendered object still byte-identical to before. @@ -45,7 +50,7 @@ field silently set. > **unchanged and now applies to the sole survivor**: `replicas` names a model > concept (local capacity) and never `spec.replicas`, and its `reason` is > required by the field rather than by a convention -> ([chapter 10](../../../spec/v1/10-service-intent.md#capacity)). The general +> ([chapter 10](../../../spec/v1/10-project-intent.md#capacity)). The general > argument is also unchanged: an authored value names what it means, and the > target's spelling is a derivation. diff --git a/docs/adr/model/0098-one-publication-path.md b/docs/adr/model/0098-one-publication-path.md index a8f4a3c..f642be2 100644 --- a/docs/adr/model/0098-one-publication-path.md +++ b/docs/adr/model/0098-one-publication-path.md @@ -9,12 +9,17 @@ rests-on: ["0003"] # A repository publishes its Intent Fragment and nothing else; every derivation runs once, centrally +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Every kind the five publish-time producers emit is derivable centrally from the same declaration over the composed union, so the producers add no information a -consumer can observe. False if: a fragment kind carries something the domain file -does not, a fact only knowable in the Service repository at publish time. -Settled by: composing the worked domains from their Intent Fragments alone and +consumer can observe. False if: a fragment kind carries something the project file +does not, a fact only knowable in the Project repository at publish time. +Settled by: composing the worked projects from their Intent Fragments alone and diffing the rendered tree against a render fed by the five producer documents, byte for byte. @@ -23,7 +28,7 @@ byte for byte. publication one mechanism: everything authored enters composition as an Intent Fragment by digest. That leaves the five `*-fragment` adapters as the one remaining second path into the render, and the deletion test decides them. -Remove `traefik-route-fragment`, `kubernetes-workload-fragment`, +Remove `traefik-route-fragment`, `kubernetes-process-fragment`, `gatus-endpoint-fragment`, `edge-catalog-fragment` and `image-metadata-fragment` together with `adapter-compat.ts`, and nothing a consumer observes changes: the central run already derives every one of those kinds from the composed intent. @@ -35,22 +40,22 @@ how its output reached the estate; with a composed union ([0037](0037-composition-oci-fragments.md)) the repository's job is to publish what it declared, and the render is one run over everything declared. One runtime, one use-case, and the publish-time step becomes what 0037 already says -it is: push a domain file by digest. A Service owner wanting to see their own +it is: push a project file by digest. An Application owner wanting to see their own render runs the same core locally with the same pinned inputs, a use-case, not a second adapter set. Three more shallow modules fall to the same test. The **estate-scoped Deliverables** (the Gatus endpoints, the two edge catalogs) had adapters of their own and landed in a namespace no tenant owns. With Gatus and Traefik as -declared Services ([0096](0096-the-foundation-is-declared.md)) they are -**inbound derivations** of the Service that consumes them, exactly as the +declared Applications ([0096](0096-the-foundation-is-declared.md)) they are +**inbound derivations** of the Application that consumes them, exactly as the database catalog is for `postgres` ([0080](0080-database-catalog-is-derived-data.md)): what every exposure in the union implies for `gatus`, rendered as its own Asset by the `kubernetes` adapter. Three adapters exist to render three ConfigMaps whose content is an inbound derivation the model already has a word for. **Image metadata** was a document "not a Kubernetes object", consumed by tooling. -It is a projection of the images lock (which Workload runs which alias at which +It is a projection of the images lock (which Process runs which alias at which digest), and that is a layer-2 fact [0033](0033-assignments-published-back.md) already publishes back. It joins the Resolved Deployment artifact set and its adapter goes. @@ -73,7 +78,7 @@ owned by the adapter that emits both. The guarantee that matters (LAN traffic never proxied through Frankfurt, which the estate is not permitted to do for Jellyfin's volume) rests on facts an author can see, not on which adapter ran: two tiers with disjoint audiences, a route's audience as the only way it reaches -a tier, and two Traefik Services placed on different nodes. Adding a tier is a +a tier, and two Traefik Applications placed on different nodes. Adding a tier is a Platform document edit. What remains is six adapters, one per subsystem: `kubernetes`, `networking`, @@ -118,7 +123,7 @@ own lock semantics. delivery's to define; until it is, the bootstrap Flux source applies the tree the kustomize groupings describe, paid by the delivery definition, which inherits an ordering rather than an object. -- A Service repository's publish workflow shrinks to validate-and-push, paid by +- A Project repository's publish workflow shrinks to validate-and-push, paid by nobody, and ten repositories lose a render step. - Jellyfin's LAN-only path is now a property of the Platform document rather than of the adapter registry, so a reviewer reading `tiers` can see it, paid in diff --git a/docs/adr/model/0099-bootstrap-set-is-recorded.md b/docs/adr/model/0099-bootstrap-set-is-recorded.md index 9a2d6be..950ab06 100644 --- a/docs/adr/model/0099-bootstrap-set-is-recorded.md +++ b/docs/adr/model/0099-bootstrap-set-is-recorded.md @@ -10,9 +10,14 @@ rests-on: ["0002"] # The bootstrap set is a recorded, enumerated table: k3s, the Flux source, Vault's unseal, and the CRDs +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Four things must exist before the first rendered object can apply, and nothing -else does. False if: a declared Service turns out to need something applied before +else does. False if: a declared Application turns out to need something applied before it that is neither in this table nor derivable from the Reconcile Unit ordering. Settled by: standing up a fresh cluster from the bootstrap set alone and applying the full rendered tree in Reconcile Unit order, with no object failing for want of @@ -32,7 +37,7 @@ define ([0098](0098-one-publication-path.md)). Vault's unseal is in it because the model must never hold that secret. The CRDs are in it because they are cluster-scoped schema that must exist before any object of their kind can apply; the components that *use* them (VSO, Traefik, Prometheus) are declared -Services, and the CRDs are pinned by version the way an image is pinned by +Applications, and the CRDs are pinned by version the way an image is pinned by digest. The table is a Bidirectional Ledger in shape diff --git a/docs/adr/model/0116-project-application-process.md b/docs/adr/model/0116-project-application-process.md new file mode 100644 index 0000000..1a5b298 --- /dev/null +++ b/docs/adr/model/0116-project-application-process.md @@ -0,0 +1,80 @@ +--- +tier: decision +status: proposed +claim: settled +date: 2026-09-14 +normative: spec/v1/10-project-intent.md#application-identity +rests-on: ["0003"] +--- + +# The authored hierarchy is Project, Application and Process, named for a reader who does not work with deployments + +## 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 +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 +for a different thing, so a reader of one chapter meets one word with two +meanings. Settled by: `CONTEXT.md` listing Service, Workload and Domain as +retired model words, and no chapter using them for a model level. + +## Why +The levels were Domain, Service and Workload. Each word was already taken. + +**Service** carried five incompatible meanings across the tools this estate +could deploy with: a network endpoint in Kubernetes, one independently released +unit in Docker Compose, ECS, Railway and Cloud Run, a microservice in Dapr, an +add-on in Heroku, a port section in Score. The model's Service was none of +those: it was the set of runnable parts released atomically. Worse, the +Deliverable Set renders Kubernetes `Service` objects, so a chapter about one +Service's rendered tree named two different things with one word. + +**Domain** reads as a DNS name to most readers, and this model authors +hostnames. **Workload** is jargon, and it means opposite things in Kubernetes +(one runnable part) and Score (the whole deployable bundle). + +The replacements come from where the concept already has a common name. +Platforms that release several processes as one unit call that unit an +**application** (Heroku, Fly.io, Cloud Foundry, DigitalOcean App Platform, the +Open Application Model, Radius, the Kubernetes SIG Application resource). +Heroku, Cloud Foundry and Fly.io call one runnable part a **process**, and a +newcomer reads that as a running program without a glossary. Docker Compose +and Railway call one file holding several deployable apps a **project**. +Because the estate may move off Kubernetes, the names are chosen for clarity, +not for alignment with any one tool. + +The layer-1 document follows the file it lives in: **Project Intent**, beside +Platform Intent. Layer 3 keeps the target's spellings, as +[0097](0097-authored-values-name-model-concepts.md) already requires, so a +rendered file is still `workload.yaml` and a Kubernetes `Service` is still a +`Service`. + +## Alternatives +| option | cost if taken | why rejected | +|---|---|---| +| Keep Domain, Service and Workload | No rename | Service collides with the rendered Kubernetes object and four other meanings; Domain collides with hostnames | +| ReleaseGroup containing Services | Names the release guarantee directly | "Group" suggests independently releasable members, which is the superseded Release Unit ([0060](0060-release-unit.md)); "service" then sits at a level that is not independently released | +| Application containing Components | Matches OAM and Backstage | "Component" does not say the thing runs, and Backstage uses it for libraries too | +| Application containing Workloads | Keeps half the vocabulary | Workload stays jargon with opposite meanings in Kubernetes and Score | + +## Reversibility +Undo cost today: a mechanical rename across the specification, the decision +record and the examples, a day. Becomes irreversible once: either +implementation names its types, grammar keywords or diagnostic codes after the +levels, because the words then sit in two codebases and every authored file. + +## Consequences +- Every authored key moves with the level: `project`, `applications`, + `processes`, and the file suffix `.project.yml`. Paid once, now, while no + parser exists. +- Five diagnostic codes are renamed, among them `E_DUPLICATE_APPLICATION_ID` + and `E_DUPLICATE_PROCESS_NAME`. Paid once, in the constraint ledger's first + rows. +- "Application" and "process" are also ordinary English words, and the + hexagon's `application/` ring names the use-cases. The capitalised term is the + model level; lower case in a chapter is the ordinary word. Paid by readers of + `docs/architecture.md`, where both appear. +- ADRs written before this one keep their decisions and gain an amendment note; + their file names and titles use the new words, so a citation by number still + resolves. diff --git a/docs/architecture.md b/docs/architecture.md index 5d795ba..6df72fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ the outermost ring; a use-case that reached them could not be called by a test. ## Ports Two use-cases, one core. `publish` runs in any repository that authors intent ( -a domain, or the platform) validates the Intent Fragment and pushes it by +a project, or the platform) validates the Intent Fragment and pushes it by digest, and renders nothing; `compose` runs centrally over the composed union and is where every adapter runs ([0098](adr/model/0098-one-publication-path.md)). They share the domain, and @@ -60,7 +60,7 @@ as a port the domain declares and the CLI supplies. | port | what it hides | production implementation | |---|---|---| -| `PinnedInputSet` | resolving every Intent Fragment (the domain files and the Platform document) the node contract, the locks and the ClusterState snapshot into parsed, validated, digested documents | filesystem plus OCI | +| `PinnedInputSet` | resolving every Intent Fragment (the project files and the Platform document) the node contract, the locks and the ClusterState snapshot into parsed, validated, digested documents | filesystem plus OCI | | `FragmentSource` / `FragmentPublisher` | reading and publishing Intent Fragments by digest | `oras push` then `oras resolve`, and a filesystem implementation for tests | | `Hasher` | the hash primitive | `node:crypto`, so the domain imports no crypto | | `DeliverableWriter` | putting bytes on disk | staging directory plus atomic rename | @@ -203,7 +203,7 @@ agreement between the two follows from both agreeing with the oracle. | oracle | where | compared as | |---|---|---| -| the parsed Service Intent | `spec/v1/examples//expected/intent.json` | canonical JSON, byte for byte | +| the parsed Project Intent | `spec/v1/examples//expected/intent.json` | canonical JSON, byte for byte | | the Resolved Deployment | `spec/v1/examples//expected/resolved.json` | canonical JSON, byte for byte | | the Deliverable Set | `spec/v1/examples//rendered/` | the existing golden tree, byte for byte | | the diagnostics of a refused case | `.diagnostics.json` beside the refused input in `refusals/` or `negative/` | a set of `(code, path)` pairs | @@ -214,7 +214,7 @@ numbers in their shortest form, no insignificant whitespace. An absent optional field is absent, never `null`. **A path** is an RFC 6901 JSON Pointer into the canonical intent document, -`/services/0/observability/alertClass`. A diagnostic about a derived value +`/applications/0/observability/alertClass`. A diagnostic about a derived value points at the authored value it derives from. Messages and hints are free per implementation; the code and the path are the contract. diff --git a/emf/docs/adr/emf/0109-ecore-metamodels-are-hand-written-per-document.md b/emf/docs/adr/emf/0109-ecore-metamodels-are-hand-written-per-document.md index f316b0f..548fa45 100644 --- a/emf/docs/adr/emf/0109-ecore-metamodels-are-hand-written-per-document.md +++ b/emf/docs/adr/emf/0109-ecore-metamodels-are-hand-written-per-document.md @@ -10,9 +10,14 @@ rests-on: ["0106"] # Each model document has a hand-written Ecore metamodel, and its Java is generated at build time +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../../../../docs/adr/model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Resting on [0106](0106-the-model-is-expressible-in-the-emf-toolchain.md), the -claim is that Service Intent, Platform Intent, the Resolved Deployment and the +claim is that Project Intent, Platform Intent, the Resolved Deployment and the Deliverable Set each fit one Ecore package whose structure, exported as the parity descriptor, equals the structure the Zod schemas in `src/` declare. False if: the descriptor exported from Ecore cannot equal the committed diff --git a/emf/docs/adr/emf/0111-xtext-parses-the-authored-yaml-into-the-metamodel.md b/emf/docs/adr/emf/0111-xtext-parses-the-authored-yaml-into-the-metamodel.md index 2a8ba01..309c6ba 100644 --- a/emf/docs/adr/emf/0111-xtext-parses-the-authored-yaml-into-the-metamodel.md +++ b/emf/docs/adr/emf/0111-xtext-parses-the-authored-yaml-into-the-metamodel.md @@ -10,11 +10,16 @@ rests-on: ["0106"] # The Xtext grammar parses the authored YAML files themselves, into the imported metamodel +> **Amended 2026-09-14.** Vocabulary renamed by +> [0116](../../../../docs/adr/model/0116-project-application-process.md): Domain is now Project, +> Service is Application, Workload is Process, and Service Intent is Project +> Intent. The decision is unchanged. + ## Rests on Resting on [0106](0106-the-model-is-expressible-in-the-emf-toolchain.md), the claim is that the YAML subset every authored example uses can be parsed by an Xtext grammar with synthetic indentation tokens, producing instances of the -hand-written Service Intent and Platform Intent metamodels. False if: an +hand-written Project Intent and Platform Intent metamodels. False if: an authored example needs YAML the grammar cannot parse without ambiguity, or the parsed model serialises to an `intent.json` different from the committed one. Settled by: the parsed-intent parity suite green for every authored file under diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index 7d5ac34..89183de 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -57,7 +57,7 @@ Tycho configuration and the target platform. |---|---|---| | `metamodel/` | `.ecore` and `.genmodel` per layer, Complete OCL `.ocl` per layer, the descriptor exporter | Task 1 | | `syntax/` | the Xtext grammar for the authored YAML subset | Task 1 | -| `resolve/` | the QVTo transformation from Service Intent and Platform Intent to the Resolved Deployment | Task 2 | +| `resolve/` | the QVTo transformation from Project Intent and Platform Intent to the Resolved Deployment | Task 2 | | `render/` | the Acceleo 4 templates from the Resolved Deployment to the Deliverable Set | Task 3 | | `cli/` | the pipeline entry point: files in, canonical JSON, diagnostics and rendered files out | Task 1 onward | | `parity/` | JUnit suites asserting each stage against the committed oracles, and the witness ledger check | Task 1 onward | @@ -67,7 +67,7 @@ below. ## Metamodels -One Ecore metamodel per document the model defines: Service Intent, Platform +One Ecore metamodel per document the model defines: Project Intent, Platform Intent, Resolved Deployment and Deliverable Set. Each is hand-written `.ecore` XMI, committed, with names taken unchanged from [`CONTEXT.md`](../../CONTEXT.md). Cross-document references are Ecore @@ -99,12 +99,12 @@ carries. ## Concrete syntax -The Xtext grammar parses the same authored `.domain.yml` and +The Xtext grammar parses the same authored `.project.yml` and `platform.intent.yml` files the TypeScript compiler reads. It covers the YAML subset those files use, with indentation handled by synthetic block tokens, and refuses anything outside the subset with a diagnostic rather than a guess. -The grammar imports the hand-written Service Intent and Platform Intent +The grammar imports the hand-written Project Intent and Platform Intent metamodels, so the parser produces instances of the graded metamodel directly. There is no inferred syntax metamodel and no mapping step between parsing and validation. @@ -112,7 +112,7 @@ validation. ## Transformation A QVT-Operational transformation derives the Resolved Deployment from the -parsed Service Intent and Platform Intent, run through the standalone +parsed Project Intent and Platform Intent, run through the standalone transformation executor. Every derivation `spec/v1/20-resolved-deployment.md` names is a mapping or a helper in it; a derived value with no mapping is a gap the parity case for it exposes. diff --git a/scripts/diagrams/class-diagram.py b/scripts/diagrams/class-diagram.py index 2908ae4..ee53726 100644 --- a/scripts/diagrams/class-diagram.py +++ b/scripts/diagrams/class-diagram.py @@ -2,7 +2,7 @@ """Render chapter 10's class diagram from the mermaid block that mirrors it. The drawing and the mermaid cannot disagree, because the mermaid IS the source: -this reads the `classDiagram` block out of spec/v1/10-service-intent.md and +this reads the `classDiagram` block out of spec/v1/10-project-intent.md and emits a .drawio file, which draw.io then exports to the committed SVG. Layout rules, in the order they matter: @@ -35,7 +35,7 @@ Usage: python3 scripts/diagrams/class-diagram.py out.drawio /Applications/draw.io.app/Contents/MacOS/draw.io -x -f svg -e -b 10 \ - -o spec/v1/diagrams/10-service-intent-model.drawio.svg out.drawio + -o spec/v1/diagrams/10-project-intent-model.drawio.svg out.drawio CHILDREN below is the only hand-maintained part: it fixes the tree parent of each node and the sibling order. Sibling order is chosen so that cross-links @@ -44,7 +44,7 @@ """ import re, sys, xml.etree.ElementTree as ET -MD = "spec/v1/10-service-intent.md" +MD = "spec/v1/10-project-intent.md" src = open(MD).read() body = re.search(r"```mermaid\nclassDiagram\n(.*?)\n```", src, re.S).group(1) @@ -68,16 +68,16 @@ dep.append((m.group(1), m.group(2), m.group(3).strip())) # The tree. Sibling order puts cross-link partners next to each other: -# Surface is Workload's last child and Route is Exposure's first, so the two +# Surface is Process's last child and Route is Exposure's first, so the two # `resolves by name` edges and `Route -> Audience` all land between neighbours. CHILDREN = { - "Domain": ["Service"], - "Service": ["Observability", "Grant", "Workload", "Exposure"], + "Project": ["Application"], + "Application": ["Observability", "Grant", "Process", "Exposure"], "Observability": ["Scrape"], "Grant": ["Rotation"], # Surface stays last, and Route stays Exposure's first, so the two # `resolves by name` cross-links land between neighbours. - "Workload": [ + "Process": [ "Capacity", "Sidecar", "Probe", "Asset", "Volume", "Placement", "EnvFile", "DependencyEdge", "Surface", ], @@ -85,7 +85,7 @@ "EnvFile": ["Placeholder"], "Exposure": ["Route"], } -placed = {"Domain"} +placed = {"Project"} for p, ks in CHILDREN.items(): for k in ks: assert k in nodes, f"unknown node {k}" @@ -101,7 +101,7 @@ def setdepth(n, d): depth[n] = d for c in CHILDREN.get(n, []): setdepth(c, d + 1) -setdepth("Domain", 0) +setdepth("Project", 0) maxd = max(depth.values()) def height(n): @@ -118,7 +118,7 @@ def layout(n): for c in ks: layout(c) x[n] = (x[ks[0]] + x[ks[-1]]) / 2.0 -layout("Domain") +layout("Project") tree = {(p, k) for p, ks in CHILDREN.items() for k in ks} cx = lambda n: x[n] + W / 2.0 @@ -297,7 +297,7 @@ def neighbours(a, b): open(sys.argv[1], "w").write( '' - f'' + f'' f'{ET.tostring(model, encoding="unicode")}') print(f"nodes={len(nodes)} depth={maxd} size={int(cursor[0])}x{int(FLOOR)} " f"lanes/row={stack_at} cross={len(cross)} notes=" diff --git a/spec/v1/00-overview.md b/spec/v1/00-overview.md index d193ab5..8e36a38 100644 --- a/spec/v1/00-overview.md +++ b/spec/v1/00-overview.md @@ -25,7 +25,7 @@ exists, not at the scale of an imagined organisation |---|---|---| | regular human maintainers | 1, across three git identities | `git shortlog -sn --all`: 35 commits by the one human, 99 by five bot accounts | | production clusters | 1, seven nodes | clusters serving production traffic | -| Services | about 30 | in about 10 repositories (reviewers who wrote "~30 repositories" were counting Services) | +| Applications | about 30 | in about 10 repositories (reviewers who wrote "~30 repositories" were counting Applications) | | horizon | 2028-08-31 | re-run the counts at that date, or earlier on a falsifying observation | Two consequences are normative for the rest of the specification. First, @@ -41,12 +41,12 @@ premise is re-opened against the new scale rather than re-worded. Kubernetes stays, and not for the reasons it is usually bought ([0002](../../docs/adr/model/0002-kubernetes-as-substrate.md)). Rescheduling does not exist here: storage is `local-path`, all fourteen PVCs are `ReadWriteOnce`, and -a `local-path` volume does not survive its node, so every stateful Workload is +a `local-path` volume does not survive its node, so every stateful Process is pinned to one machine by construction. Control-plane HA does not exist: every platform fixture carries exactly one `k3s-control-plane` host. Horizontal scale is not exercised: `auth-api`'s two replicas were a capacity decision on freed budget, and on one node two replicas are two processes on one kernel. The -overhead is counted: 405 rendered objects for about 30 Services, 41 of them +overhead is counted: 405 rendered objects for about 30 Applications, 41 of them foundation, and the foundation is the part carrying the CVEs and the CRD upgrades. @@ -65,7 +65,7 @@ obligation is narrower and is discharged in these chapters: every Deliverable is a serialized object attributed to exactly one adapter, so there is always a single answer to "what should own this field". -The layer-1 documents (Service Intent and Platform Intent alike) survive a +The layer-1 documents (Project Intent and Platform Intent alike) survive a substrate swap: neither names a Kubernetes kind, a Traefik field or a k3s flag ([0097](../../docs/adr/model/0097-authored-values-name-model-concepts.md)). The registered adapters do not: five of the six emit Kubernetes kinds and the sixth @@ -79,7 +79,7 @@ contract ([0003](../../docs/adr/model/0003-three-layer-meta-model.md)). | Layer | Name | Authored | Owns | |---|---|---|---| -| 1 | Service Intent, and Platform Intent | by hand, a Service's in its owning repo, the estate's in the platform's | requirements and facts, never mechanisms | +| 1 | Project Intent, and Platform Intent | by hand, an Application's in its owning repo, the estate's in the platform's | requirements and facts, never mechanisms | | 2 | Resolved Deployment | never, derived | every platform decision | | 3 | Deliverable Set | never, serialized | files, no decisions | @@ -91,8 +91,8 @@ contention test: a value is platform-assigned if and only if it must be unique across the estate or draws on a shared finite resource ([0004](../../docs/adr/model/0004-contention-decides-authority.md), normative in [chapter 20](20-resolved-deployment.md#authority)). The same test decides which -of the two authored documents a value is written in: a Service's own in -[chapter 10](10-service-intent.md), the estate's in +of the two authored documents a value is written in: an Application's own in +[chapter 10](10-project-intent.md), the estate's in [chapter 14](14-platform-intent.md) ([0095](../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)). @@ -103,7 +103,7 @@ the resolved shape) and the estate documented the consequence as a trap rather than fixing it, because a two-layer vocabulary could not say which document was wrong. -Layer 2 is derived from a **closed set of pinned, digested inputs**: Service +Layer 2 is derived from a **closed set of pinned, digested inputs**: Project Intent, the Platform Intent, the locks, and a ClusterState snapshot carrying its own digest ([0006](../../docs/adr/model/0006-pinned-inputs.md), [0034](../../docs/adr/model/0034-cluster-state-pinned-input.md)). Nothing at render @@ -131,11 +131,11 @@ at all. The first draft of this rebuild defined a "core" and a "group G" that failed to partition the decision set (six ADRs landed on neither side), which is why membership of a directory, not a list, is the line. -A third domain, `docs/adr/architecture/`, carries decisions about the +A third project, `docs/adr/architecture/`, carries decisions about the compiler's own structure. It is neither model nor delivery: its `normative:` pointers name sections of [`docs/architecture.md`](../../docs/architecture.md) rather than of these chapters, and nothing in it can change what the model -means. `scripts/lint-adrs.ts` holds each domain to its own normative root. +means. `scripts/lint-adrs.ts` holds each project to its own normative root. The model makes exactly three demands on whatever delivery is eventually defined. They are model decisions, not delivery ones, and together they are the @@ -198,13 +198,13 @@ repository already uses between a chapter and an ADR. | Chapter | Covers | Diagram | |---|---|---| -| [`10-service-intent.md`](10-service-intent.md) | Service, Workload, and every layer-1 field by concern: identity, configuration, assets, probes, storage and durability, hardening and size, placement, exposure, observability, secrets and grants, release units | drawn | -| [`14-platform-intent.md`](14-platform-intent.md) | the second authored document: substrate facts, the bootstrap set, the declared foundation, tiers as edge facts, durability policy, engines as images, providers, and no observability policy, which the observability service owns | none | -| [`16-dependencies.md`](16-dependencies.md) | dependency edges, per-Workload identity, derived network policy, the derivation map | drawn | +| [`10-project-intent.md`](10-project-intent.md) | Application, Process, and every layer-1 field by concern: identity, configuration, assets, probes, storage and durability, hardening and size, placement, exposure, observability, secrets and grants, release units | drawn | +| [`14-platform-intent.md`](14-platform-intent.md) | the second authored document: substrate facts, the bootstrap set, the declared foundation, tiers as edge facts, durability policy, engines as images, providers, and no observability policy, which the observability application owns | none | +| [`16-dependencies.md`](16-dependencies.md) | dependency edges, per-Process identity, derived network policy, the derivation map | drawn | | [`20-resolved-deployment.md`](20-resolved-deployment.md) | the Resolved Deployment, the authority table in one place, the pinned input set including ClusterState, derived mechanics, the one capacity exception, the Reconcile Unit, publish-back | drawn | | [`30-deliverables.md`](30-deliverables.md) | adapters, the adapter port, attribution, ledgers, coverage re-derived from the registry | drawn | | [`40-composition.md`](40-composition.md) | Intent Fragments, participants and the staleness bound, schema versioning and rollout, unmanaged surfaces | drawn | -| [`50-lifecycle.md`](50-lifecycle.md) | model-level lifecycle: Release Unit switchover, expand/contract for cross-Service contract changes, lock lifecycle, and the statement that delivery mechanics and co-testing are defined separately | drawn | +| [`50-lifecycle.md`](50-lifecycle.md) | model-level lifecycle: Release Unit switchover, expand/contract for cross-Application contract changes, lock lifecycle, and the statement that delivery mechanics and co-testing are defined separately | drawn | | [`60-setup.md`](60-setup.md) | bootstrap order, secrets at rest, CNI selection, node facts, restore, onboarding and adoption | drawn | **Chapter 16's derivation map is the load-bearing artefact**, and its value is @@ -235,11 +235,11 @@ parse-checked in CI. | path | what it shows | |---|---| -| `examples/domains/{auth,knowledge,data}.yml` | Service Intent, one file per domain: two-level secret grants, `probes: none` stated explicitly, TCP probes, `placement` dimensions, declared `writablePaths`, `durability` per volume, and the `auth` pair as two Workloads of one Service | -| `examples/{knowledge-api,knowledge-ingest-worker,auth-api,platform-postgres}.base.env` | env files, one set **per Workload**, threaded with `${dependency:…}` and `${secret:#}` placeholders whose paths byte-match a granted path | -| `examples/workflows/service-publish-fragment.yml` | publish on merge, `oras push` then `oras resolve`, read back | +| `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/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-service-id/` | a negative fixture, so an invariant that stops running is detectable | +| `examples/negative/duplicate-application-id/` | a negative fixture, so an invariant that stops running is detectable | | [`examples/refusals/`](examples/refusals/README.md) | the refusal fixtures: an alert class with no signal, a class outside the vocabulary, and the `rolling`/`recreate` pair over RWO storage | Delivery examples are no longer part of this specification. `aggregator.yml`, @@ -263,17 +263,17 @@ through, with the deciding ADR named. 1. ~~**`exposure[].name` and apex hosts.**~~ Decided by [0018](../../docs/adr/model/0018-exposure-by-audience.md) as amended: the hostname - is **authored on the Service**, never assigned. An `exposure` entry carries - `host` as the full FQDN, so no zone rule and no `.` + is **authored on the Application**, never assigned. An `exposure` entry carries + `host` as the full FQDN, so no zone rule and no `.` derivation exists anywhere, and with none, there is no apex flag left to grade, because an apex host is written `host: jorisjonkers.dev` exactly like - every other host. `name` is required and unique **within the Service**, + every other host. `name` is required and unique **within the Application**, which is what `E_DUPLICATE_EXPOSURE_NAME` had always checked and nothing had - defined, and what `${exposure:.#url}` addresses. Estate-wide + defined, and what `${exposure:.#url}` addresses. Estate-wide uniqueness of `host` becomes a composition check, `E_DUPLICATE_HOST`, evaluated over the composed union together with the Registered Unmanaged Surfaces ([chapter 40](40-composition.md#identity)): - the Service authors the value and composition arbitrates the collision, + the Application authors the value and composition arbitrates the collision, which is [0004](../../docs/adr/model/0004-contention-decides-authority.md) restated as contention deciding who arbitrates rather than who authors. @@ -298,7 +298,7 @@ through, with the deciding ADR named. contract ([0056](../../docs/adr/model/0056-node-facts-single-source.md)), and placement is declared as capabilities rather than labels ([0017](../../docs/adr/model/0017-placement-by-capability.md)), so retiring a - prefix costs no edit in any service repository. The live nodes still carry + prefix costs no edit in any project repository. The live nodes still carry two: 110 labels across 7 nodes, 55 under `platform.jorisjonkers.dev/*` and the same 55 under `personal-stack/*`, named after an archived repository that rejects pushes. A hand-applied `kubectl label` drifts back on the next @@ -318,7 +318,7 @@ through, with the deciding ADR named. `E_PATH_COLLISION` is a check on the path plan ([0070](../../docs/adr/model/0070-path-authority-is-layer-2.md)) awaiting a compiler, and Grafana's 45 authored objects become Assets of the declared - observability Services ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)). + observability Applications ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)). What remains is the number, which is [chapter 30](30-deliverables.md#open-in-this-chapter)'s one open item and is owned there. - **Owner:** joris. @@ -329,7 +329,7 @@ through, with the deciding ADR named. 5. ~~**`minAvailable`.**~~ Chapter 10 proposed three fields. One became `placement` ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)), `sidecars` is graded by - [0064](../../docs/adr/model/0064-sidecars-are-workload-vocabulary.md), and this + [0064](../../docs/adr/model/0064-sidecars-are-process-vocabulary.md), and this one is **graded by deletion** ([0089](../../docs/adr/model/0089-replicas-derived-no-minavailable.md)). The six live PodDisruptionBudgets are re-homed rather than derived from a declaration: @@ -343,7 +343,7 @@ through, with the deciding ADR named. - **Blocks:** approving chapter 10; the availability half of item 4. 6. **Whether the composed union may span clusters** (chapter 40). The lock is - keyed by cluster while Service Id uniqueness is estate-wide. With one cluster + keyed by cluster while Application Id uniqueness is estate-wide. With one cluster ([0001](../../docs/adr/model/0001-estate-scale-and-ownership.md)) the question is invisible, and neither [0037](../../docs/adr/model/0037-composition-oci-fragments.md) nor @@ -381,10 +381,10 @@ through, with the deciding ADR named. [0036](../../docs/adr/model/0036-cni-selection.md), which supplies the non-enforcing policy stage the old audit-mode precondition assumed and `networking.k8s.io/v1` does not have. -- ~~**Where third-party Service Intent lives.**~~ Decided by +- ~~**Where third-party Project Intent lives.**~~ Decided by [0037](../../docs/adr/model/0037-composition-oci-fragments.md): publication is - repository-scoped and a fragment declares the domains it contributes to, so - splitting a multi-domain repository is a convenience, never a prerequisite. + repository-scoped and a fragment declares the projects it contributes to, so + splitting a multi-project repository is a convenience, never a prerequisite. - ~~**Fragment publication trigger**~~ and ~~**who runs composition.**~~ Decided by [0037](../../docs/adr/model/0037-composition-oci-fragments.md): fragments publish on merge, independently of any image release; composition runs on any publish @@ -417,9 +417,9 @@ an ADR. ```mermaid flowchart TB - subgraph AUTH["layer 1, hand-authored: Service Intent in each owning repository, Platform Intent in the platform's"] - a1["domains/<domain>.yml
services, workloads, placement, hardening,
durability, probes, exposure, secrets"] - a2["env/<workload>/*.env
one set per Workload"] + 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"] + 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"] end @@ -430,7 +430,7 @@ flowchart TB a5 --> FR FR --> CO["composition
union + estate-wide invariants
merges nothing, runs on any publish"] - PAR["participants.yml
expected domains, maxAge 7d"] --> CO + PAR["participants.yml
expected projects, maxAge 7d"] --> CO CO --> CI["ComposedIntent
+ CompositionLock"] CI --> RES["layer 2, Resolved Deployment
every platform assignment,
a function of the pinned inputs alone"] @@ -438,7 +438,7 @@ flowchart TB CS["ClusterState snapshot
clusterStateDigest"] --> RES IL["images lock
digests, uid, gid, never tags"] --> RES - RES --> RS["resolved.yml
published back per Service"] + RES --> RS["resolved.yml
published back per Application"] RES --> DS["layer 3, Deliverable Set
six registered adapters, run once centrally,
one attributed adapter per file"] DS --> DEL["delivery, DEFINED SEPARATELY
docs/adr/deferred/
must honour Release Unit atomicity,
Durability Class gates,
pinned inputs only"] diff --git a/spec/v1/10-service-intent.md b/spec/v1/10-project-intent.md similarity index 77% rename from spec/v1/10-service-intent.md rename to spec/v1/10-project-intent.md index d59d0a0..1bc1c77 100644 --- a/spec/v1/10-service-intent.md +++ b/spec/v1/10-project-intent.md @@ -1,32 +1,32 @@ -# Chapter 10: Service Intent +# Chapter 10: Project Intent Layer 1. The only layer a human authors, and the only layer that lives in the -domain's own repository. +project's own repository. Two rules govern everything below, and every field is justified against one of them: -1. **Service Intent contains no mechanisms.** A field belongs here only if it +1. **Project Intent contains no mechanisms.** A field belongs here only if it states a requirement. `RollingUpdate`, `nodeSelector`, `IngressRoute`, `VaultStaticSecret`, `securityContext` and `statefulset` are mechanisms and appear nowhere. What they should be is derived from what is declared ([0005](../../docs/adr/model/0005-derivation-is-total.md), [0030](../../docs/adr/model/0030-runtime-mechanics-derived.md)). -2. **Service Intent never gets the last word on a contended value.** A value +2. **Project Intent never gets the last word on a contended value.** A value that must be unique across the estate, or that draws on a shared finite resource, is **arbitrated** by layer 2 ([0004](../../docs/adr/model/0004-contention-decides-authority.md)). Contention - decides who **arbitrates**, not who **authors**: the Service states its - requirement, the platform decides whether it fits and where, and the Service + decides who **arbitrates**, not who **authors**: the Application states its + requirement, the platform decides whether it fits and where, and the Application reads the assignment back from its generated `resolved.yml` ([0033](../../docs/adr/model/0033-assignments-published-back.md)). The second rule reads as it does because placement forced it. `memory` and `cpu` are contended (they draw on a finite pool of node capacity) and they are -nevertheless authored here, as raw quantities per Workload +nevertheless authored here, as raw quantities per Process ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). An authors-only reading of contention would forbid the field and leave the estate exactly where -it is, because a number no Service may write is a number nobody writes, and what +it is, because a number no Application may write is a number nobody writes, and what that produced is BestEffort on every pod. Arbitration is real and it is the platform's: eligibility is checked against node allocatable at build time, and the scheduler places at apply. @@ -37,33 +37,33 @@ Layer 1 is authored as two kinds of file: | file | owns | |---|---| -| `platform/.yml` | one domain: its `owner`, and every Service in it: workloads, surfaces, dependencies, exposure, probes, volumes, placement, hardening, and secret **access** | -| `platform/env//base.env` + `platform/env//.env` | every environment variable that **that Workload** receives | +| `platform/.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 | -One file is one domain and one Intent Fragment -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). A repository may -hold several domain files (which is what lets `homelab-collections` stay one -repository holding three Services rather than three repositories with three -publish workflows) and a domain never spans repositories, so composition unions -fragments and never has to union a domain (chapter 40). +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 +repository holding three Applications rather than three repositories with three +publish workflows) and a project never spans repositories, so composition unions +fragments and never has to union a project (chapter 40). The split that matters is not file-level but concern-level. A secret's **access** -is declared in the domain file, beside the `dependsOn` edge that motivates it; the +is declared in the project file, beside the `dependsOn` edge that motivates it; the **environment variable** that carries it is a placeholder in the env file. Each file therefore checks the other: an env file referencing a secret with no grant is an unauthorised reference, and a grant with no reference is a dead grant. ```yaml apiVersion: intent.jorisjonkers.dev/v1 -kind: Domain +kind: Project 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 -gets its own namespace. `kind` names the authored document (one domain holding -many Services) while chapter 40's `IntentFragment` is the envelope that +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 toolkit package's version, and composition accepts a range rather than an equality ([0039](../../docs/adr/model/0039-artifact-schema-versioning.md)); chapter 40 @@ -71,7 +71,7 @@ defines the range and what the lock records. ## The model -![The layer-1 model](diagrams/10-service-intent-model.drawio.svg) +![The layer-1 model](diagrams/10-project-intent-model.drawio.svg) [Diagram source](#the-layer-1-model) · generated by [`scripts/diagrams/class-diagram.py`](../../scripts/diagrams/class-diagram.py) @@ -86,7 +86,7 @@ vocabularies an attribute's type names are tabulated under they added a line each and told a reader nothing the type name had not. The two relations that reach across more than one layer are not drawn either. A `Placeholder` byte-matches a granted path and an exposure placeholder addresses -`service.name`; both are stated where they are enforced, under +`application.name`; both are stated where they are enforced, under [Validation](#validation). Nothing in it is ungraded. `minAvailable` was the last such field and it is @@ -95,74 +95,74 @@ Nothing in it is ungraded. `minAvailable` was the last such field and it is availability by replica count does not exist on this substrate, so the field could only ever have been a request the platform could not honour. `sidecars` is graded by -[0064](../../docs/adr/model/0064-sidecars-are-workload-vocabulary.md). `placement` is not among them: it is graded by +[0064](../../docs/adr/model/0064-sidecars-are-process-vocabulary.md). `placement` is not among them: it is graded by [0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md) and specified in -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 +full below, and it is the only composite on the Process that is **required**. +Env files hang off the **Process**, not the Application +([0011](../../docs/adr/model/0011-configuration-env-files-per-process.md)), and so does `provides`: a port is a property of a process. `exposure` hangs off the -**Service**, because a hostname is a property of the product rather than of any +**Application**, because a hostname is a property of the product rather than of any one process, and one hostname routes into two of them. -## Service identity +## Application identity -Intent is authored one file per domain. The file states the domain, raises -exactly one field to it, and lists the Services it holds -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)): +Intent is authored one file per project. The file states the project, raises +exactly one field to it, and lists the Applications it holds +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)): ```yaml -domain: auth # the file header; one domain per file -owner: joris # the only field raised to the domain -services: +project: auth # the file header; one project per file +owner: joris # the only field raised to the project +applications: - id: auth # the referencable identity; namespace auth-system - workloads: + processes: - name: auth-api # the process's own name, and its identity image: auth-api - name: auth-ui image: auth-ui ``` -A Service is identified by one short string, unique across the estate, and that -string is the only identity another Service may reference -([0010](../../docs/adr/model/0010-flat-service-identity.md)). The id is the repository -or product name. Workload names are whatever the processes are actually called, +An Application is identified by one short string, unique across the estate, and that +string is the only identity another Application may reference +([0010](../../docs/adr/model/0010-flat-application-identity.md)). The id is the repository +or product name. Process names are whatever the processes are actually called, and so are their images: neither is a derivative of the id. -**The namespace derives from the domain**, as `-system`, and from nothing -else. That reproduces all ten live Service namespaces (`auth-system`, +**The namespace derives from the project**, as `-system`, and from nothing +else. That reproduces all ten live Application namespaces (`auth-system`, `data-system`, `knowledge-system`, `app-system`, `agents-system`, `mail-system`, `media-system`, `notes-system`, `automation-system`, `utility-system`) with zero renames and not one live object moved. Which is why nothing remains for an alias field to express, and why there is none. `fleet-infra/docs/live-divergence.md` records the case one was invented -for (*"the service repository is home-portal; live called the image app-ui. A +for (*"the project repository is home-portal; live called the image app-ui. A rename, not a different image"*) and under these rules the row describes a divergence that no longer exists. The id is the repository name, `home-portal`. -The Workload is called what the process is called, `app-ui`, and so is its -image. The domain is `app`, so the namespace is `app-system`, which is where the -Service already runs. The three things an alias used to carry are the namespace -(now derived from the domain), the Workload name and the image (both authored +The Process is called what the process is called, `app-ui`, and so is its +image. The project is `app`, so the namespace is `app-system`, which is where the +Application already runs. The three things an alias used to carry are the namespace +(now derived from the project), the Process name and the image (both authored explicitly), and the one divergence it still expressed (a namespace of the -Service's own choosing) is exactly the move that let a Service claim another -domain's namespace. Deleting the field deletes that move with it. +Application's own choosing) is exactly the move that let an Application claim another +project's namespace. Deleting the field deletes that move with it. -**A Service is the unit of atomic release.** Some products are one thing in two +**An Application is the unit of atomic release.** Some products are one thing in two processes: a new frontend against an old API is a broken product even though each -pod individually reports healthy. That coupling is carried by the Service -boundary itself ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)). -The Workloads of one Service switch together or none switches. No Workload's new -version receives traffic until **every** Workload's new version is healthy, where -healthy means that Workload's own declared readiness +pod individually reports healthy. That coupling is carried by the Application +boundary itself ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)). +The Processes of one Application switch together or none switches. No Process's new +version receives traffic until **every** Process's new version is healthy, where +healthy means that Process's own declared readiness ([0014](../../docs/adr/model/0014-probes-are-siblings.md)). If any member fails its `startupBudget`, **no** member switches and the old versions keep serving. -Rollback is Service-scoped: reverting one Workload reverts all of them. +Rollback is Application-scoped: reverting one Process reverts all of them. -There is no mechanism to couple two Services, and no field naming a set. A pair -that must release together is **one Service** (`auth-api` and `auth-ui` are -Workloads of Service `auth`, `stalwart` and `stalwart-provisioner` Workloads of -Service `stalwart`) and a surviving pair that cannot merge is evidence the -Service boundary is drawn wrong, not a missing field. Merging costs nothing in +There is no mechanism to couple two Applications, and no field naming a set. A pair +that must release together is **one Application** (`auth-api` and `auth-ui` are +Processes of Application `auth`, `stalwart` and `stalwart-provisioner` Processes of +Application `stalwart`) and a surviving pair that cannot merge is evidence the +Application boundary is drawn wrong, not a missing field. Merging costs nothing in this estate because nothing references the folded names: the complete set of `dependsOn` targets across the composed union is `platform-postgres`, `platform-rabbitmq`, `stalwart` and `platform-valkey`, and `auth-api`'s @@ -172,51 +172,51 @@ audience ([0018](../../docs/adr/model/0018-exposure-by-audience.md)), never an e Atomicity is declared rather than derived, because lockstep release is a product choice the graph cannot see: a frontend depends on its API, but a dependency edge does not mean the two must cut over together, and deriving atomicity from every -edge would make the whole estate one unit. A Service is therefore not a Reconcile +edge would make the whole estate one unit. An Application is therefore not a Reconcile Unit: -| | Reconcile Unit | Service | +| | Reconcile Unit | Application | |---|---|---| | answers | in what order | all at once, or not at all | | origin | derived from the dependency graph ([0032](../../docs/adr/model/0032-reconcile-unit-derived.md)) | declared, by drawing a boundary | -| example | `platform-postgres` before `knowledge` | `auth-api` and `auth-ui`, in Service `auth` | +| example | `platform-postgres` before `knowledge` | `auth-api` and `auth-ui`, in Application `auth` | | failure | the later unit waits | nothing switches | -The Service says **what** must hold, never **how** it is achieved. The mechanism ( +The Application says **what** must hold, never **how** it is achieved. The mechanism ( what applies the change, in what order, behind what gate) is defined separately. -**A namespace holds several Services by construction, so it is not a trust +**A namespace holds several Applications by construction, so it is not a trust boundary.** This was once a footnote to an exception; it is now the normal case for every namespace in the estate, and it must be read as normal rather than as an edge case. No isolation claim may rest on a namespace wall. Isolation is the derived default-deny edge set ([0035](../../docs/adr/model/0035-network-policy-default-deny.md)), evaluated per pod, -plus per-Workload identity ([0024](../../docs/adr/model/0024-identity-per-workload.md)). +plus per-Process identity ([0024](../../docs/adr/model/0024-identity-per-process.md)). | field | level | required | notes | |---|---|---|---| -| `domain` | file header | yes | One domain per file. The namespace is `-system`; the domain also owns the Secret Subtree and is the unit of Intent Fragment publication ([0037](../../docs/adr/model/0037-composition-oci-fragments.md), [0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). | -| `owner` | file header | yes | Who is notified. The **only** field raised to the domain; a Service needing a different owner needs its own domain. | -| `id` | Service | yes | The one referencable identity, estate-unique. The repository or product name. | -| `observability` | Service | no | `{alertClass, scrape {workload, surface, path}}`, whole or absent. Absent means no monitoring is rendered. Urgency, never routing ([0021](../../docs/adr/model/0021-observability-scrape-and-alert-class.md)). Never raised to the domain: a domain would then page as loudly as its loudest member. See [Observability](#observability). | -| `workloads` | Service | yes | One or more. They switch together. | -| `exposure` | Service | no | The hostnames this Service serves and how each routes into its Workloads. On the Service, not the Workload: one hostname fronts two processes in the live `auth` case. A Service nothing reaches from outside declares none. See [Exposure](#exposure). | +| `project` | file header | yes | One project per file. The namespace is `-system`; the project also owns the Secret Subtree and is the unit of Intent Fragment publication ([0037](../../docs/adr/model/0037-composition-oci-fragments.md), [0063](../../docs/adr/model/0063-intent-authored-per-project.md)). | +| `owner` | file header | yes | Who is notified. The **only** field raised to the project; an Application needing a different owner needs its own project. | +| `id` | Application | yes | The one referencable identity, estate-unique. The repository or product name. | +| `observability` | Application | no | `{alertClass, scrape {process, surface, path}}`, whole or absent. Absent means no monitoring is rendered. Urgency, never routing ([0021](../../docs/adr/model/0021-observability-scrape-and-alert-class.md)). Never raised to the project: a project would then page as loudly as its loudest member. See [Observability](#observability). | +| `processes` | Application | yes | One or more. They switch together. | +| `exposure` | Application | no | The hostnames this Application serves and how each routes into its Processes. On the Application, not the Process: one hostname fronts two processes in the live `auth` case. An Application nothing reaches from outside declares none. See [Exposure](#exposure). | Uniqueness cannot be had by construction, only by check: the id encodes neither -domain nor repository, so nothing structural stops two repositories claiming one -string. `E_DUPLICATE_SERVICE_ID` fires at composition (chapter 40), and the +project nor repository, so nothing structural stops two repositories claiming one +string. `E_DUPLICATE_APPLICATION_ID` fires at composition (chapter 40), and the window in which two repositories both claim an id is an accepted cost. -Workload names carry a second uniqueness rule, and it is scoped to the **domain -file** rather than to the Service, because the ServiceAccount and the Vault role -are the Workload name alone: `auth-system.auth-api`, never -`auth-system.auth-auth-api` ([0024](../../docs/adr/model/0024-identity-per-workload.md), -derived in chapter 16). Two Services in one file therefore cannot both call a -Workload `api`: that is `E_DUPLICATE_WORKLOAD_NAME` at composition (chapter 40), +Process names carry a second uniqueness rule, and it is scoped to the **project +file** rather than to the Application, because the ServiceAccount and the Vault role +are the Process name alone: `auth-system.auth-api`, never +`auth-system.auth-auth-api` ([0024](../../docs/adr/model/0024-identity-per-process.md), +derived in chapter 16). Two Applications in one file therefore cannot both call a +Process `api`: that is `E_DUPLICATE_PROCESS_NAME` at composition (chapter 40), raised where a reader can see both declarations at once. -No field can move a Service out of its domain's namespace, so the old question of -whether a Service may name a namespace some other applier owns has lost its +No field can move an Application out of its project's namespace, so the old question of +whether an Application may name a namespace some other applier owns has lost its subject matter: see [Delivery and co-testing are defined separately](#delivery-and-co-testing-are-defined-separately). @@ -226,27 +226,27 @@ Labels are **derived and fixed**, and no authored field contributes to them ([0072](../../docs/adr/model/0072-the-label-set-is-fixed.md)). The set is stated here rather than left to a renderer because two of these labels are a Deployment's `selector.matchLabels` and are therefore **immutable on a live -object**: changing the convention later is delete-and-recreate on every workload +object**: changing the convention later is delete-and-recreate on every process in the estate. | label | value | mutable | |---|---|---| -| `app.kubernetes.io/name` | the Workload `name` | **no** (selector) | -| `app.kubernetes.io/instance` | the Workload `name` | **no** (selector) | -| `app.kubernetes.io/part-of` | the Service Id | yes | +| `app.kubernetes.io/name` | the Process `name` | **no** (selector) | +| `app.kubernetes.io/instance` | the Process `name` | **no** (selector) | +| `app.kubernetes.io/part-of` | the Application Id | yes | | `app.kubernetes.io/managed-by` | `deploy-kit` | yes | -| `app.kubernetes.io/component` | the Workload `runtime` | yes | +| `app.kubernetes.io/component` | the Process `runtime` | yes | -`part-of` carries the Service, which is what makes the Release Unit selectable +`part-of` carries the Application, which is what makes the Release Unit selectable by whatever performs a switchover ([chapter 20](20-resolved-deployment.md#the-release-gate)). It is deliberately not a -selector: a Service gaining or losing a Workload must not require recreating +selector: an Application gaining or losing a Process must not require recreating the others. -`name` and `instance` are both the Workload name rather than one naming the -Service, because the selector must match exactly one controller's pods. A -`name` of the Service and an `instance` of the Workload would read better and -would make every Workload of a multi-Workload Service selector-ambiguous the +`name` and `instance` are both the Process name rather than one naming the +Application, because the selector must match exactly one controller's pods. A +`name` of the Application and an `instance` of the Process would read better and +would make every Process of a multi-Process Service selector-ambiguous the moment anything selected on `name` alone. No `app.kubernetes.io/version`. A version label would have to come from the @@ -255,43 +255,43 @@ may use and that the image digest already states exactly, on the object, where a reader looks anyway. An estate-scoped Deliverable carries `managed-by` and nothing else: it belongs -to no Workload and to no Service, and `part-of` on such an object would name a -Service that does not own it. +to no Process and to no Application, and `part-of` on such an object would name a +Application that does not own it. ## Ports and surfaces There is no `ports` list. A port is an **integer**, written where it is used, and -`provides` is a flat map of surface name to port declared **on the Workload**, +`provides` is a flat map of surface name to port declared **on the Process**, because a port is a property of a process: ```yaml -# in the data domain file, under Service platform-postgres -workloads: +# in the data project file, under Application platform-postgres +processes: - name: platform-postgres provides: db: 5432 # the process itself metrics: 9187 # its exporter sidecar, in the same pod ``` -A Workload with no listener declares no `provides` at all: the ingest worker of +A Process with no listener declares no `provides` at all: the ingest worker of `knowledge` has none, and the map is absent rather than empty. ```yaml -probes: # on the Workload: the integer, at its point of use +probes: # on the Process: the integer, at its point of use readiness: path: /api/actuator/health/readiness port: 8080 ``` An [exposure](#exposure) route and an [observability](#observability) scrape are -the two places a port is *not* written: both sit on the Service and name -`{workload, surface}`, so the integer stays declared once, by the process that +the two places a port is *not* written: both sit on the Application and name +`{process, surface}`, so the integer stays declared once, by the process that listens on it. -Surface **names** are unique within a Service, not within a Workload, because a -dependency edge names `{service, surface}` and never a Workload -([0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md)). A Service's -surface set is the union of its Workloads' `provides` maps, and one name declared +Surface **names** are unique within an Application, not within a Process, because a +dependency edge names `{application, surface}` and never a Process +([0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md)). An Application's +surface set is the union of its Processes' `provides` maps, and one name declared twice inside that union is a build error: the edge would otherwise be ambiguous about which process it means. @@ -312,16 +312,16 @@ named `db`, or the rename is accepted as a parity entry. `submissions` and `submission` both appear live, which is a separate inconsistency this rule happens to expose. -## Workload +## Process -`name` is the process's own name, unique within the domain file. It is not a -derivative of the Service id, and it is what the Workload's ServiceAccount and +`name` is the process's own name, unique within the project file. It is not a +derivative of the Application id, and it is what the Process's ServiceAccount and Vault role are called (chapter 16). `image` is an alias resolved to a digest through the images lock, never a tag, never a digest here. -`lifecycle` is `service` or `job`. Not `deployment` / `statefulset` / `job`, +`lifecycle` is `application` or `job`. Not `deployment` / `statefulset` / `job`, because those are mechanisms; the object kind derives from `lifecycle`, `stateful` and `volumes`. @@ -330,10 +330,10 @@ and `volumes`. `engine` names **what the process is**, where that is something the platform has to treat specially: `postgres`, `rabbitmq`, `valkey`, `files`, or absent. -It is a fact about the Workload rather than a mechanism, which is why it belongs -here ([0078](../../docs/adr/model/0078-engine-is-workload-vocabulary.md)), and +It is a fact about the Process rather than a mechanism, which is why it belongs +here ([0078](../../docs/adr/model/0078-engine-is-process-vocabulary.md)), and it is what the platform keys its backup method off -([Storage and durability](#storage-and-durability)). It is required on a Workload +([Storage and durability](#storage-and-durability)). It is required on a Process holding a volume of a class that derives a backup, and refused on one that derives none: `E_ENGINE_WITHOUT_DURABILITY` and `E_DURABILITY_WITHOUT_ENGINE`. @@ -342,20 +342,20 @@ derives none: `E_ENGINE_WITHOUT_DURABILITY` and `E_DURABILITY_WITHOUT_ENGINE`. runs a third-party image, so its `runtime` is `none` and its `engine` is `postgres`. -`provides` and `placement` are Workload fields, specified in +`provides` and `placement` are Process fields, specified in [Ports and surfaces](#ports-and-surfaces) and [Placement](#placement). Every -Workload declares a `placement` block, because two of its dimensions are +Process declares a `placement` block, because two of its dimensions are required. -`exposure` is **not** a Workload field. A Workload states which ports it listens -on; which hostname reaches it, and on what path, is stated once on the Service +`exposure` is **not** a Process field. A Process states which ports it listens +on; which hostname reaches it, and on what path, is stated once on the Application ([Exposure](#exposure)). ### Sidecars -A Workload is one pod, and a pod holds more than one container three times in +A Process is one pod, and a pod holds more than one container three times in this estate. `sidecars` names the others -([0064](../../docs/adr/model/0064-sidecars-are-workload-vocabulary.md)): +([0064](../../docs/adr/model/0064-sidecars-are-process-vocabulary.md)): ```yaml - name: postgres @@ -373,24 +373,24 @@ this estate. `sidecars` names the others | field | required | shape | notes | |---|---|---|---| -| `name` | yes | one value | The container's own name, unique among the Workload's containers. The Workload is one of them, so a sidecar may not take its name. A collision is refused at composition (chapter 40). | -| `image` | yes | an alias | Resolved to a digest through the images lock, exactly as a Workload's is. A tag would put a mutable reference in a Deliverable, which `E_FLOATING_IMAGE` (chapter 30) refuses. | -| `memory` | yes | one quantity | This container's request. Shape rules are the Workload's ([Placement](#placement)). | +| `name` | yes | one value | The container's own name, unique among the Process's containers. The Process is one of them, so a sidecar may not take its name. A collision is refused at composition (chapter 40). | +| `image` | yes | an alias | Resolved to a digest through the images lock, exactly as a Process's is. A tag would put a mutable reference in a Deliverable, which `E_FLOATING_IMAGE` (chapter 30) refuses. | +| `memory` | yes | one quantity | This container's request. Shape rules are the Process's ([Placement](#placement)). | | `cpu` | yes | one quantity | The same. | The split follows Kubernetes rather than a rule of the model's own: `nodeSelector` and affinity are **pod**-level, `resources` and `securityContext` are **container**-level. So the node dimensions (`arch`, `site`, `disk`, `gpu`, -`capabilities`) stay on the Workload and describe the pod, and a sidecar +`capabilities`) stay on the Process and describe the pod, and a sidecar declares neither them nor a `placement` block. `memory` and `cpu` are per container, and a sidecar declares its own. Nothing is inherited, and nothing needs to be: every container in the pod meets -`restricted` or the Workload is refused, so there is no per-container relaxation +`restricted` or the Process is refused, so there is no per-container relaxation to push onto a sidecar in the first place. **Eligibility sums.** A node must fit the pod's containers together, so the -placement check adds every sidecar's `memory` and `cpu` to the Workload's before +placement check adds every sidecar's `memory` and `cpu` to the Process's before matching against allocatable ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). `postgres` at 2Gi with a 64Mi exporter needs a node with 2112Mi free, not 2Gi. This is the one @@ -398,7 +398,7 @@ place the addition matters and the one place it is easy to miss. A sidecar has no identity, no probes, no exposure and no release semantics of its own: it is not independently deployable, which is what makes it a sidecar -rather than a Workload. `provides` therefore stays on the **Workload** even when +rather than a Process. `provides` therefore stays on the **Process** even when the listener is a sidecar: `platform-postgres` declares `metrics: 9187` and the exporter is the container that serves it, which is exactly the attribution the model could not state before this field existed. @@ -407,26 +407,26 @@ model could not state before this field existed. ```yaml dependsOn: - - {service: platform-postgres, surface: postgres} - - {service: auth-api, surface: http, required: false} + - {application: platform-postgres, surface: postgres} + - {application: auth-api, surface: http, required: false} ``` -Declared per Workload, so network policy is precise: within `knowledge`, the API +Declared per Process, so network policy is precise: within `knowledge`, the API reaches Postgres while the ingest worker reaches RabbitMQ, and neither inherits the other's egress ([0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md), -[0035](../../docs/adr/model/0035-network-policy-default-deny.md)). The Service's edge +[0035](../../docs/adr/model/0035-network-policy-default-deny.md)). The Application's edge set is the union, and that union drives the Reconcile Unit DAG ([0032](../../docs/adr/model/0032-reconcile-unit-derived.md)). Chapter 16 covers what an edge derives, inbound as well as outbound. -An edge says one Workload needs another to run. It says nothing about which +An edge says one Process needs another to run. It says nothing about which suites must pass before either may ship: that is defined separately. ## Configuration -Configuration is authored as dotenv, **per Workload** -([0011](../../docs/adr/model/0011-configuration-env-files-per-workload.md)), because -Workloads of one Service do not share an environment: `knowledge-api` and +Configuration is authored as dotenv, **per Process** +([0011](../../docs/adr/model/0011-configuration-env-files-per-process.md)), because +Processes of one Application do not share an environment: `knowledge-api` and `knowledge-ingest-worker` overlap on the RabbitMQ coordinates and on nothing else. ``` @@ -438,7 +438,7 @@ DB_USER=${secret:secret/data/platform/postgres/kb#user} ``` `base.env` carries everything that does not vary; one overlay per Cluster Target -(`platform/env//.env`) carries only what differs, overlay +(`platform/env//.env`) carries only what differs, overlay winning key by key. With one cluster the overlay is usually empty, which is already what `stalwart-provisioner` half-invented, its `production.env` and `staging.env` being byte-identical. @@ -446,26 +446,26 @@ already what `stalwart-provisioner` half-invented, its `production.env` and A literal is written literally. A derived value is a **named placeholder**: `${dependency:…}` for a coordinate, `${secret:…}` for a secret, `${exposure:…}` for a hostname the estate serves, and `${identity:…}` for what the platform -derived about **this** Workload +derived about **this** Process ([0091](../../docs/adr/model/0091-identity-placeholders-not-framework-wiring.md)): | key | value | |---|---| -| `${identity:vaultRole}` | the Workload's Vault role, its own name ([0024](../../docs/adr/model/0024-identity-per-workload.md)) | -| `${identity:serviceAccount}` | the Workload's ServiceAccount name | -| `${identity:namespace}` | `-system` | +| `${identity:vaultRole}` | the Process's Vault role, its own name ([0024](../../docs/adr/model/0024-identity-per-process.md)) | +| `${identity:applicationAccount}` | the Process's ServiceAccount name | +| `${identity:namespace}` | `-system` | -The key set is closed. It exists because a self-delivering Workload has to wire +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 +`applicationAccountName()` 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 `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`, -`agents-api` and `knowledge-api` except `OTEL_SERVICE_NAME`, sixty duplicated -lines that leave the service repositories under this rule. +`agents-api` and `knowledge-api` except `OTEL_APPLICATION_NAME`, sixty duplicated +lines that leave the project repositories under this rule. Placeholders are named-source references and never a template language: no conditionals, no arithmetic. The placeholder names the source; the key names the @@ -494,7 +494,7 @@ assets: Every Asset renders a **content-hashed object name**, so an edit reaches the pod (16 of the estate's 18 ConfigMaps are plain today, meaning an edit applies successfully and has no effect) and the resulting pod-template change restarts -the Workload. +the Process. There is no `reload`. Nothing in Kubernetes reloads a process, no image in this estate watches its own config file, and a reload would need an actor the model @@ -515,12 +515,12 @@ Assets. **Six fixed files** with no derived values (`postgresql.conf`, `sources.conf`, `stalwart` `config.json`) and **seven mixed files**: a large static body threaded with a few derived values, `rabbitmq.conf` most starkly with one derived line in twenty-four (`auth_oauth2.issuer = https://auth.jorisjonkers.dev`, -a hostname belonging to another Service). Those thirteen are Assets. The other +a hostname belonging to another Application). Those thirteen are Assets. The other **five are derived catalogs** (`gatus-endpoints` (41 derived references in 288 lines), `platform-edge-route-catalog` (30/163), `platform-edge-catalog` (28/146), `grafana-datasources` (6/104), `postgres-init-script` (18/98)) and they leave *authored* configuration entirely: each is an **inbound derivation** for the -platform Service that consumes it, rendered as that Service's own Asset +platform Application that consumes it, rendered as that Application's own Asset ([chapter 16](16-dependencies.md#what-an-edge-derives-read-inbound), [0098](../../docs/adr/model/0098-one-publication-path.md)). @@ -547,11 +547,11 @@ fallback** ([0014](../../docs/adr/model/0014-probes-are-siblings.md)). Readiness *can I serve traffic*; liveness means *is my process wedged*. A liveness probe pointed at a readiness endpoint turns a dependency outage into a crash-loop, and the v2 model made that the default for anyone declaring one path: -`src/adapters/kubernetes-workload-fragment.ts:166` renders +`src/adapters/kubernetes-process-fragment.ts:166` renders `livenessProbe: probe(health.livenessPath ?? health.path)`, and `app-ui` and `agents-login` both rely on it today. -A service with no HTTP surface uses `tcp`, which is not decoration: `postgres` +An application with no HTTP surface uses `tcp`, which is not decoration: `postgres` probes with `tcpSocket` on port `db` for both, and the rest of the data tier does the same. @@ -561,14 +561,14 @@ probes: liveness: {tcp: 5432} ``` -A Workload with no listener declares the absence, so a forgotten probe block is +A Process with no listener declares the absence, so a forgotten probe block is never mistaken for a deliberate one: ```yaml probes: none # knowledge-ingest-worker: no ports, nothing to probe ``` -Timings, thresholds and deadlines stay derived. A Workload that declares ports +Timings, thresholds and deadlines stay derived. A Process that declares ports but no probe declaration is refused. ### What the probe derivation completes @@ -590,21 +590,21 @@ probe does, so pointing it at a readiness endpoint reproduces the defect dependency outage makes readiness fail, startup never succeeds, and the pod crash-loops on somebody else's outage. -A Workload declaring readiness and no liveness therefore derives **no startup +A Process declaring readiness and no liveness therefore derives **no startup probe** (there is nothing safe to poll) and its start is bounded by the progress deadline alone. -Readiness is also what the Service's atomic switchover waits on: healthy means -*this* Workload's declared readiness, so a Service with a Workload that never +Readiness is also what the Application's atomic switchover waits on: healthy means +*this* Process's declared readiness, so an Application with a Process that never reports ready never switches any of them. ### Replicas, and the disruption budget `replicas` derives as **1** ([0089](../../docs/adr/model/0089-replicas-derived-no-minavailable.md)). Storage -is `local-path` and every claim is `ReadWriteOnce`, so a stateful Workload is +is `local-path` and every claim is `ReadWriteOnce`, so a stateful Process is pinned to one machine by construction; on one node, two replicas are two -processes on one kernel. A Workload that wants more states it as +processes on one kernel. A Process that wants more states it as [Capacity](#capacity) (a `count` above one with a reason) which is what `auth-api`'s two replicas always were: a capacity decision on freed budget, recorded now instead of inferred. @@ -635,7 +635,7 @@ volumes: Every volume declares a **Durability Class** ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)), what the data is -worth, which only the owning Service knows: +worth, which only the owning Application knows: | class | means | derives | live example | |---|---|---|---| @@ -652,8 +652,8 @@ Intent carries one policy per class and the volume declares only what the data is worth. A volume that genuinely needs different terms is a platform policy change, not a per-volume restatement. -**The method is platform-assigned too**, keyed by the Workload's -[`engine`](#workload): the method **is an image**: one purpose-built image per +**The method is platform-assigned too**, keyed by the Process's +[`engine`](#process): the method **is an image**: one purpose-built image per engine whose entrypoint performs the backup, named in the Platform document and resolved through the images lock ([chapter 14](14-platform-intent.md#engines), [0097](../../docs/adr/model/0097-authored-values-name-model-concepts.md)). @@ -675,15 +675,15 @@ Longhorn: all fourteen PVCs are `ReadWriteOnce`, and [workspace ADR-0011](https://github.com/JorisJonkers-dev/workspace/blob/main/docs/decisions/ADR-0011-backup-coverage-gaps.md) records that *"PVC-level snapshots are impossible here: no VolumeSnapshot CRDs, and `local-path` has no CSI snapshot support."* Two consequences follow: a volume -pins its Workload to the node holding the PV, and retention can only be an +pins its Process to the node holding the PV, and retention can only be an application-level backup job. The first of those is why `disk` in [Placement](#placement) filters only the -**first** placement of a Workload. Once a claim is bound, the binding recorded in +**first** placement of a Process. Once a claim is bound, the binding recorded in the pinned `ClusterState` outranks the declared media, and a `disk` value that contradicts it is `E_DISK_BINDING_CONFLICT` rather than a term quietly ignored. -The class replaces `rollbackTargetRetention`, which every Service declared +The class replaces `rollbackTargetRetention`, which every Application declared identically as `{minimumDays: 90, acknowledged: true}`, which no renderer read, and which asserted a ninety-day rollback a snapshot-less cluster cannot perform. **A volume declares its `size`; the platform decides whether it fits** @@ -698,12 +698,12 @@ authored beside `claim` and `mountAt`, exactly the shape `storageClassName` still does not appear, and is still assigned: everything takes k3s's default `local-path`. -`placement.disk.size` is **derived** (the sum of the Workload's volume sizes) +`placement.disk.size` is **derived** (the sum of the Process's volume sizes) so the quantity has one declaring site. Authoring it in both places let the same number be stated twice and disagree, which is what chapter 16's single-authority -property forbids. `placement.disk.media` stays authored: which media a Workload +property forbids. `placement.disk.media` stays authored: which media a Process needs is not implied by how much it needs. `volumeClaimTemplate` is forbidden: a template ties the volume to -the Workload's name, so a rename orphans the claim. +the Process's name, so a rename orphans the claim. Durability is also the model's gate on destruction: a claim backing non-`reconstructible` data may not be removed as a side effect of a render. What @@ -714,7 +714,7 @@ chapter 60. ## Pod hardening -One required-by-default field per Workload, added before the first production +One required-by-default field per Process, added before the first production apply because the retrofit gets strictly more expensive every week ([0016](../../docs/adr/model/0016-pod-hardening.md)). @@ -724,7 +724,7 @@ writablePaths: [/var/cache/nginx, /var/run] The field does not exist today, in either renderer generation: `grep -rniE 'securityContext|runAsNonRoot|readOnlyRootFilesystem|seccompProfile' src/ schemas/` -returns **0 hits**, and `src/deployment/render/workloads.ts:130` builds a +returns **0 hits**, and `src/deployment/render/processes.ts:130` builds a container from name, image, pullPolicy, ports, command, args, env, envFrom, volumeMounts, probes and resources, and stops. Rendered pods run as their image's UID, with a writable root and default capabilities, and the standing QoS class for @@ -738,12 +738,12 @@ symptom. ### Hardening -The posture itself is **not authored per Workload**. It is one estate-wide value, +The posture itself is **not authored per Process**. It is one estate-wide value, `restricted`, declared once in the Platform document -([chapter 14](14-platform-intent.md#hardening-policy)), a Workload that repeated +([chapter 14](14-platform-intent.md#hardening-policy)), a Process that repeated it thirty times would be restating the only value there is, and a field with one legal value carries no information ([0089](../../docs/adr/model/0089-replicas-derived-no-minavailable.md) -deleted `minAvailable` for the same reason). What a Workload authors is the +deleted `minAvailable` for the same reason). What a Process authors is the paths it must write, and nothing else. `restricted` is four controls, applied together: @@ -757,7 +757,7 @@ together: ### Writable paths are declared, not exempted A read-only root filesystem is not a filesystem nothing writes. A JVM needs -`/tmp`; nginx needs `/var/cache/nginx` and `/var/run`. A Workload therefore +`/tmp`; nginx needs `/var/cache/nginx` and `/var/run`. A Process therefore lists the paths it must write ([0092](../../docs/adr/model/0092-writable-paths-are-declared.md)): ```yaml @@ -779,7 +779,7 @@ site. Nothing is implicit. `/tmp` is not supplied unless it is declared (a mount nobody asked for would appear in every static image that never writes) and the -worked `auth` domain claiming that "the render supplies `/tmp` as an `emptyDir`" +worked `auth` project claiming that "the render supplies `/tmp` as an `emptyDir`" described behaviour no chapter specified. This is what retired the estate's last two exceptions. nginx declaring @@ -787,7 +787,7 @@ This is what retired the estate's last two exceptions. nginx declaring without relaxing anything, which is what `auth-ui`'s own recorded reason predicted. -**A Workload that cannot meet the class is refused.** There is no exception +**A Process that cannot meet the class is refused.** There is no exception vocabulary, no `allow` list and no `hardening: privileged` shorthand: an image that needs root, a writable root filesystem, a dropped capability back or a relaxed syscall filter is `E_HARDENING_UNMET` at composition. The fix is the @@ -800,18 +800,18 @@ relaxation carried with a reason is an override under another name, and it outlives the image that justified it: the estate's own inventory of "what we cannot harden" was written once and never shortened. Refusing instead puts the cost where the defect is. The worked estate proves the point: after -`writablePaths` and the images lock, **no Workload in the example set declares an +`writablePaths` and the images lock, **no Process in the example set declares an exception at all**, and the two that used to are `auth-ui`, which lists the paths nginx writes, and `platform-postgres`, whose UID comes from the lock. ### A privileged port needs the capability that binds it A `provides` port below 1024 cannot be bound by a non-root process without -`CAP_NET_BIND_SERVICE`, and the `restricted` class drops all capabilities. A -Workload declaring one is `E_PRIVILEGED_PORT_UNDER_NONROOT` +`CAP_NET_BIND_APPLICATION`, and the `restricted` class drops all capabilities. A +Process declaring one is `E_PRIVILEGED_PORT_UNDER_NONROOT` ([0083](../../docs/adr/model/0083-privileged-port-needs-the-capability.md)), and the answer is a port above 1024. Deriving the capability silently would -re-add what the class dropped for every Workload that happens to declare a low +re-add what the class dropped for every Process that happens to declare a low port. `auth-ui` is the live case and its answer is to listen on 8080. A route names a @@ -838,16 +838,16 @@ lock-time error with a name or a runtime error without one. **A volume gets `fsGroup`.** A freshly provisioned `local-path` directory is root-owned, so without a group a non-root pod cannot write its own PV: -`platform-postgres` cannot `initdb`. Any Workload holding a volume therefore +`platform-postgres` cannot `initdb`. Any Process holding a volume therefore derives `fsGroup` from the resolved `gid`, with `fsGroupChangePolicy: OnRootMismatch` so the kubelet does not re-chown a large volume on every start. No authored field, and no root-capable init container, -which every stateful Workload would then need, to solve a problem `fsGroup` +which every stateful Process would then need, to solve a problem `fsGroup` solves. Enforcement from the platform side was rejected rather than overlooked. Pod Security Admission can reject but never fill in, so a non-conforming pod fails at -apply with no exception path a Service can author; a mutating admission default is +apply with no exception path an Application can author; a mutating admission default is a value the render cannot see, which contradicts [0005](../../docs/adr/model/0005-derivation-is-total.md). @@ -866,7 +866,7 @@ placement: Six dimensions and a flat capability set, all of them **hard** ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). `memory` and `cpu` -are required on every Workload; every other term defaults to *any node*. +are required on every Process; every other term defaults to *any node*. | dimension | required | shape | matched against, in the pinned node contract | |---|---|---|---| @@ -874,7 +874,7 @@ are required on every Workload; every other term defaults to *any node*. | `cpu` | yes | one quantity | the node's allocatable cpu | | `arch` | no | a set of values | the node's architecture | | `site` | no | one value | the node's site | -| `disk` | no | `{media: [...]}` | the media of the node's disks; the capacity term is derived from the Workload's volume sizes ([Storage and durability](#storage-and-durability)) | +| `disk` | no | `{media: [...]}` | the media of the node's disks; the capacity term is derived from the Process's volume sizes ([Storage and durability](#storage-and-durability)) | | `gpu` | no | `{class: , memory: }` | `gpus[].class` and `gpus[].memory_mib` | | `capabilities` | no | a set of flat strings | the capabilities the node advertises | @@ -905,16 +905,16 @@ a reserve declared in the node file, published by the node contract a live read of free capacity, which would put an assignment outside the pinned input set ([0006](../../docs/adr/model/0006-pinned-inputs.md)). -A Workload is eligible on a node when every declared term matches that node -**alone**. The check never sums Workloads. Three Workloads each declaring +A Process is eligible on a node when every declared term matches that node +**alone**. The check never sums Processes. Three Processes each declaring `memory: 2Gi` therefore **all pass** against a 4096Mi node (each is compared against allocatable on its own) and the scheduler refuses the third at apply. State that plainly to anyone reading this gate as a capacity plan: it proves a -home exists for each Workload, not that every Workload fits at once. +home exists for each Process, not that every Process fits at once. `memory` and `cpu` are contended, and they are authored here anyway. That is not a hole in [0004](../../docs/adr/model/0004-contention-decides-authority.md): contention -decides who **arbitrates**, not who **authors**. The Service states its +decides who **arbitrates**, not who **authors**. The Application states its requirement, the platform decides whether it fits, refuses what no node can hold, and the scheduler decides where. The accepted cost is stated rather than hidden: nothing stops an author writing `memory: 8Gi`, and no arbitration exists @@ -942,7 +942,7 @@ than defaulted. Capabilities advertised, with node counts: `adguard` (5), `lan-ingress` (3), `nvidia` (2), `samba` (1), `public-ingress` (1), `llm-host` (1), `backup-store` (1), `amd-gpu` (1). No node carries a taint, so a capability set is the only -thing keeping a Workload off a node it should not be on. +thing keeping a Process off a node it should not be on. `tailscale` is absent from that list deliberately. It was advertised on 7 of 7 nodes, where it excluded nothing, and a filter that never excludes teaches @@ -970,17 +970,17 @@ a second node gains samba. ineligible on a 2048MiB card by arithmetic rather than by luck. `gpu-nvidia` is not vocabulary, and neither is any other flat string standing in for a device. -### Labels are not the Service's to name +### Labels are not the Application's to name `nix-config/generated/node-contract.yml` emits 110 labels for 7 nodes: 55 under `platform.jorisjonkers.dev/*` and the same 55 under `personal-stack/*`, named after an archived repository that rejects pushes. Authored as selectors, retiring -that prefix is an edit in every service repository; authored as placement +that prefix is an edit in every project repository; authored as placement dimensions it touches none ([0056](../../docs/adr/model/0056-node-facts-single-source.md)). Placement already implied is not declared either: a `local-path` volume pins its -Workload to the node holding the PV, and the resolver states that, reading the +Process to the node holding the PV, and the resolver states that, reading the binding from the pinned `ClusterState` snapshot, never from a live cluster ([0034](../../docs/adr/model/0034-cluster-state-pinned-input.md)). A PV that rebinds after a node failure therefore surfaces as a new lock, not as drift, and a `disk` @@ -1006,50 +1006,50 @@ Both shape rules are derivations and neither is authorable: there is no second field inside `placement`, and no hatch to reach one ([No overrides](20-resolved-deployment.md#no-overrides)). -What the numbers look like against real Workloads, with the evidence that fixed +What the numbers look like against real Processes, with the evidence that fixed them: -| workload | `memory` | `cpu` | why | +| process | `memory` | `cpu` | why | |---|---|---|---| | `app-ui` | `64Mi` | `10m` | nginx serving static files, measured at *"~10–20Mi RAM each"*; `postgres-exporter` sits in the same band | | `knowledge-ingest-worker` | `256Mi` | `50m` | an interpreted single-consumer queue worker, not a server | -| `knowledge-api` | `768Mi` | `250m` | a JVM service at its default heap; `knowledge/knowledge.domain.yml` measures its cold start at *"~250-300s"* | -| `platform-postgres` | `2Gi` | `500m` | the datastore with pgvector that eight Services queue behind | +| `knowledge-api` | `768Mi` | `250m` | a JVM application at its default heap; `knowledge/knowledge.project.yml` measures its cold start at *"~250-300s"* | +| `platform-postgres` | `2Gi` | `500m` | the datastore with pgvector that eight Applications queue behind | -A wrong number now mis-sizes one Workload rather than every member of a class, -and correcting it is an edit in that Workload's own file. The cost is the mirror -image: raising every JVM service from 768Mi to 1Gi is an edit in every repository +A wrong number now mis-sizes one Process rather than every member of a class, +and correcting it is an edit in that Process's own file. The cost is the mirror +image: raising every JVM application from 768Mi to 1Gi is an edit in every repository holding one, on every retune. ## Exposure -An exposure entry says *this hostname routes here*. It sits on the **Service**, -beside its Workloads, and it carries its own routing: +An exposure entry says *this hostname routes here*. It sits on the **Application**, +beside its Processes, and it carries its own routing: ```yaml -services: +applications: - id: auth exposure: - - name: public # unique within the Service + - name: public # unique within the Application host: auth.jorisjonkers.dev # the full FQDN, authored audience: anonymous contentPolicy: strict # optional: strict | admin | workflow routes: - - {path: /api, match: prefix, workload: auth-api, surface: http} - - {path: /, match: prefix, workload: auth-ui, surface: http} + - {path: /api, match: prefix, process: auth-api, surface: http} + - {path: /, match: prefix, process: auth-ui, surface: http} ``` | field | level | required | notes | |---|---|---|---| -| `name` | exposure | yes | Unique within the Service. It is what `E_DUPLICATE_EXPOSURE_NAME` checks and what a `${exposure:…}` placeholder addresses. A Service serving two hostnames (`jellyfin` public and lan) needs it to tell them apart. | +| `name` | exposure | yes | Unique within the Application. It is what `E_DUPLICATE_EXPOSURE_NAME` checks and what a `${exposure:…}` placeholder addresses. An Application serving two hostnames (`jellyfin` public and lan) needs it to tell them apart. | | `host` | exposure | yes | The full FQDN, written out. Unique across the estate. | | `audience` | exposure | yes | `anonymous` \| `authenticated` \| `internal` \| `lan`. The default for every route beneath it. | | `contentPolicy` | exposure | no | `strict` \| `admin` \| `workflow`. The Content-Security-Policy profile: the one header choice an author makes, from a closed list. | | `routes` | exposure | yes | One or more. | | `path` | route | yes | The path this rule matches. | | `match` | route | yes | `prefix` \| `exact`. | -| `workload` | route | yes | A Workload of **this** Service. | -| `surface` | route | yes | A surface that Workload declares in `provides`: a name, never a port integer. | +| `process` | route | yes | A Process of **this** Application. | +| `surface` | route | yes | A surface that Process declares in `provides`: a name, never a port integer. | | `audience` | route | no | Overrides the exposure's audience, for this path alone. | | `redirectTo` | route | no | A path this route redirects to. A path, never a regex. | @@ -1059,15 +1059,15 @@ host: ```yaml routes: - - {path: /mcp, match: exact, workload: knowledge-api, surface: http, audience: anonymous} - - {path: /, match: prefix, workload: knowledge-api, surface: http} + - {path: /mcp, match: exact, process: knowledge-api, surface: http, audience: anonymous} + - {path: /, match: prefix, process: knowledge-api, surface: http} ``` and a path may redirect: ```yaml routes: - - {path: /, match: exact, workload: stalwart, surface: http, redirectTo: /admin/} + - {path: /, match: exact, process: stalwart, surface: http, redirectTo: /admin/} ``` **Precedence is derived, not inherited from the proxy** @@ -1101,30 +1101,30 @@ certificates, and the middleware chain that assembles them A zone mapping does exist, so the derivation was available and was rejected on the evidence rather than on principle. `homelab-inventory/catalog/reachability.yml` groups every reachable host into a channel (`public-frankfurt`, `lan`) which is -the input a `.` rule would need. What that rule cannot survive is -the host labels themselves, because they do not follow the Service id: +the input a `.` rule would need. What that rule cannot survive is +the host labels themselves, because they do not follow the Application id: - `knowledge.jorisjonkers.dev` and `kb.jorisjonkers.dev` both resolve, and one - Service id cannot derive two labels. + Application id cannot derive two labels. - `platform-rabbitmq` serves `rabbitmq.jorisjonkers.dev`, dropping the prefix its id carries. -- `root`, `status`, `dashboard` and `faro` belong to no Service at all. +- `root`, `status`, `dashboard` and `faro` belong to no Application at all. A derivation would therefore be right for most of the set and silently wrong for the rest, and the wrong ones are precisely the ones nobody would catch: a derived hostname is written down nowhere, so there is no second copy for a reader to -disagree with. The host is authored instead: one FQDN, in full, in the Service -that serves it. There is no zone field, no `.` rule and no suffix +disagree with. The host is authored instead: one FQDN, in full, in the Application +that serves it. There is no zone field, no `.` rule and no suffix appended anywhere in the render. An apex host needs no field either. `host: jorisjonkers.dev` is a host like any -other, and two Services claiming it collide exactly as two Services claiming any +other, and two Applications claiming it collide exactly as two Applications claiming any other name do. Authoring the host does not make it uncontended. A hostname must be unique across the estate, which is what contention means, and [0004](../../docs/adr/model/0004-contention-decides-authority.md), as this chapter's -preamble restates it, decides who **arbitrates**, not who **authors**. The Service +preamble restates it, decides who **arbitrates**, not who **authors**. The Application writes the FQDN it serves; composition refuses the collision with `E_DUPLICATE_HOST`, over the composed union together with the Registered Unmanaged Surfaces, so an authored host cannot quietly take a name the estate already @@ -1132,31 +1132,31 @@ answers on. What this ends is the duplication. `kb.jorisjonkers.dev` was declared in seven authoritative places: the reachability channel, both edge catalogs, both Traefik -IngressRoutes, the Gatus endpoint, and the Service itself. It is now written once, +IngressRoutes, the Gatus endpoint, and the Application itself. It is now written once, here, and the other six derive from it; anything that needs the literal reads it back through `${exposure:…}` rather than repeating it (see [Secret references](#secret-references)). The two conformance tests that existed only to detect their disagreement become unnecessary, not merely green. -### Why exposure sits on the Service and `provides` stays on the Workload +### Why exposure sits on the Application and `provides` stays on the Process `provides` and `exposure` look like one fact and are two. `provides` says *this process listens on this port*, which is a property of a process and stays on the -Workload. `exposure` says *this hostname routes here*, which is a property of the -product and belongs to the Service. +Process. `exposure` says *this hostname routes here*, which is a property of the +product and belongs to the Application. The case that forced the split is live and unexceptional: `auth.jorisjonkers.dev` -serves `/api` from `auth-api` and `/` from `auth-ui`. One hostname, two Workloads. -At the Workload level that is inexpressible: each Workload would have to declare +serves `/api` from `auth-api` and `/` from `auth-ui`. One hostname, two Processes. +At the Process level that is inexpressible: each Process would have to declare a host the other also claims, the two halves of one hostname would be authored in two files with nothing joining them but a repeated string, and the estate would be -back to the duplication the previous section just removed. On the Service the -hostname is written once and its routes name the Workloads they reach. +back to the duplication the previous section just removed. On the Application the +hostname is written once and its routes name the Processes they reach. -It also puts the hostname on the boundary that already governs it. A Service is +It also puts the hostname on the boundary that already governs it. An Application is the unit of atomic release -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)), so the Workloads -behind one host switch together; a hostname authored per Workload would have been +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)), so the Processes +behind one host switch together; a hostname authored per Process would have been a per-process fact spanning a release boundary no single process controls. ### Audience is the single vocabulary @@ -1168,14 +1168,14 @@ three carrying seven values: | where | values | |---|---| -| service `route.authMode` | `anonymous`, `sso`, `forward-auth` | +| application `route.authMode` | `anonymous`, `sso`, `forward-auth` | | tier `authModes` | `forward-auth`, `internal`, `lan` | | rule `auth.scope` | `anonymous`, `authenticated`, `application` | Because the values were never comparable, the gate that should have caught a mismatch could not, and did not fire anyway: `src/deployment/v2-model.ts:199-203` checks `authMode` against a tier only `if (tier && …)`, and three of the four -routed services declare no `expose.tier` at all. `E_ROUTE_AUTH_MODE_NOT_IN_TIER` +routed applications declare no `expose.tier` at all. `E_ROUTE_AUTH_MODE_NOT_IN_TIER` was implemented, had an error code, and was vacuous exactly where it mattered; it is deleted rather than repaired. `E_NO_TIER_FOR_AUDIENCE` (chapter 40) replaces it and cannot be vacuous, because the audience is always present. @@ -1186,7 +1186,7 @@ and cannot be vacuous, because the audience is always present. the closure is a decision rather than an oversight: no provider-shaped passthrough, no raw middleware reference, no headers block, no annotations map, no escape hatch shaped like any of them. Layer 1 carries no mechanism, and a Traefik -middleware name written into Service Intent is a mechanism +middleware name written into Project Intent is a mechanism ([0030](../../docs/adr/model/0030-runtime-mechanics-derived.md)). The vocabulary is two fields because the estate's own edge is four middlewares, @@ -1219,7 +1219,7 @@ would readmit every provider fragment at once and take the mechanism rule with i shape (an exact root sent to a subpath) and the author writes the destination path. The renderer produces the provider's `redirectRegex` form from it, so `${1}`-style capture groups appear nowhere in layer 1: a capture group is a -pattern language, and a pattern language in Service Intent brings its own +pattern language, and a pattern language in Project Intent brings its own escaping rules, its own tests and its own way to fail silently. `AUTH_CORS_ALLOWED_ORIGINS` is the case that tested the closure hardest, and it is @@ -1235,9 +1235,9 @@ gap, not an argument for a field. | condition | error | |---|---| | two exposures declare the same `host` | `E_DUPLICATE_HOST` | -| two exposures of one Service share a `name` | `E_DUPLICATE_EXPOSURE_NAME` | +| two exposures of one Application share a `name` | `E_DUPLICATE_EXPOSURE_NAME` | | two routes of one exposure share the same `path` + `match` pair | `E_DUPLICATE_ROUTE_MATCH` | -| a route's `{workload, surface}` pair names no surface that Workload provides | `E_UNKNOWN_SURFACE` | +| a route's `{process, surface}` pair names no surface that Process provides | `E_UNKNOWN_SURFACE` | `E_DUPLICATE_HOST` is evaluated at composition over the whole union, Registered Unmanaged Surfaces included, because a name the estate already answers on is taken @@ -1246,9 +1246,9 @@ scoped to a single document and are refused as soon as the fragment is read. `E_DUPLICATE_EXPOSURE_NAME` has had an implementation and an error code for longer than it has had a definition: nothing said what a name was, or whether an -exposure had one. It is unique **within the Service**. `jellyfin` may declare -`public` and `lan`, and no other Service is thereby prevented from having a -`public` of its own, because a placeholder that reads one names the Service too. +exposure had one. It is unique **within the Application**. `jellyfin` may declare +`public` and `lan`, and no other Application is thereby prevented from having a +`public` of its own, because a placeholder that reads one names the Application too. `E_DUPLICATE_ROUTE_MATCH` catches the pair that cannot be ordered rather than merely duplicated: two routes with the same path and the same match on one @@ -1256,7 +1256,7 @@ hostname have no defined winner, and the provider picks one without saying so. `E_UNKNOWN_SURFACE` is the same code a dependency edge uses (chapter 16), holding routes to the same rule: a route names a surface by name, never by port, so the integer stays written once, by the process that listens on it. Either half of the -pair failing raises it: a route naming a Workload this Service does not hold names +pair failing raises it: a route naming a Process this Application does not hold names no surface either. A hostname the estate serves but does not deploy is a Registered Unmanaged Surface @@ -1266,19 +1266,19 @@ equal terms with everything authored. ## Observability -One optional block on the Service, or nothing at all +One optional block on the Application, or nothing at all ([0021](../../docs/adr/model/0021-observability-scrape-and-alert-class.md)): ```yaml observability: alertClass: business-hours scrape: - workload: notes-api # which Workload publishes it - surface: metrics # a name from that Workload's `provides` + process: notes-api # which Process publishes it + surface: metrics # a name from that Process's `provides` path: /metrics ``` -**Absent means no monitoring, and that is a complete answer.** A Service that +**Absent means no monitoring, and that is a complete answer.** An Application that declares nothing gets no monitor, no rule and no alert, and nothing is refused. The vocabulary carries no `none` member, because an omission already says it and a member that means "I wrote the field to say I did not want the field" is @@ -1300,10 +1300,10 @@ genuinely varies ( `/actuator/prometheus`, `/api/actuator/prometheus`, `/metrics`) so it is authored, and a platform that guessed would collect nothing and report success. -The whole block sits on the **Service** and is never raised to the domain -header, for the same reason `owner` is: urgency is a per-Service fact, and a -domain that pages because one of its Services does is a domain that gets muted. -`workload` points into the Service's own Workloads, which is what lets one +The whole block sits on the **Application** and is never raised to the project +header, for the same reason `owner` is: urgency is a per-Application fact, and a +project that pages because one of its Applications does is a project that gets muted. +`process` points into the Application's own Processes, which is what lets one declaration name the exporter sidecar's surface without the sidecar authoring anything. @@ -1320,14 +1320,14 @@ other and takes part in the derivation map's properties From `alertClass` the model derives **nothing at all**. It is carried into `resolved.yml` as a resolved fact and stops there. Rule expressions, severity mapping, receiver routing and notifier channels are the monitoring stack's, and -they are the parts a deployment model has no business owning: PromQL in a domain +they are the parts a deployment model has no business owning: PromQL in a project file is a mechanism in layer 1, and a receiver is a shared notification channel, so by [0004](../../docs/adr/model/0004-contention-decides-authority.md) it is platform-assigned. | concern | where it lives | |---|---| -| `alertClass`, `scrape {workload, surface, path}` | authored, per Service | +| `alertClass`, `scrape {process, surface, path}` | authored, per Application | | ServiceMonitor / PodMonitor | derived by the model, from `scrape` and `provides` | | scrape cadence | the Platform document, one value for the estate | | rule expressions, severity, receivers | the monitoring stack, reading `resolved.yml` | @@ -1341,21 +1341,21 @@ wants the estate's alert classes reads the published projection, which is what ### What does not move `probes`, `startupBudget` and the release gate stay in the model. Readiness and -liveness are facts about the application process, and the Service's atomic +liveness are facts about the application process, and the Application's atomic switchover waits on the declared readiness surface ([The release gate](20-resolved-deployment.md#the-release-gate)). Moving the checks into an out-of-band configuration would make the gate depend on a file the -model does not read. A Workload with no listener continues to say `probes: none`. +model does not read. A Process with no listener continues to say `probes: none`. ## Secrets -A `secrets` list declares what a Workload may do to a Secret Store path. It sits -at **whichever level the secret is shared**: on the Service when every Workload -holds it, on a Workload when only that one does -([0022](../../docs/adr/model/0022-grants-live-on-the-service.md)). +A `secrets` list declares what a Process may do to a Secret Store path. It sits +at **whichever level the secret is shared**: on the Application when every Process +holds it, on a Process when only that one does +([0022](../../docs/adr/model/0022-grants-live-on-the-application.md)). ```yaml -# on the Service: every Workload gets these +# on the Application: every Process gets these secrets: - path: secret/data/platform/postgres/kb keys: [user, password] @@ -1363,9 +1363,9 @@ secrets: delivery: env rotation: {tolerates: restart} -workloads: +processes: - name: knowledge-ingest-worker - # on the Workload: only this one gets it + # on the Process: only this one gets it secrets: - path: secret/data/knowledge-system/vault-deploy-key keys: [key] @@ -1385,7 +1385,7 @@ defaults to `kv`, so every grant written before this rule stays valid. |---|---|---|---| | `engine` | all | no | `kv` \| `database` \| `transit`; defaults to `kv` | | `path` | `kv` | yes | The full KV path. This is the grant unit ([0023](../../docs/adr/model/0023-grant-unit-is-the-path.md)). | -| `keys` | `kv` | yes | The keys the Workload expects there. Documentation and a validation input, **not** an access boundary. No wildcard exists. | +| `keys` | `kv` | yes | The keys the Process expects there. Documentation and a validation input, **not** an access boundary. No wildcard exists. | | `access` | `kv` | yes | `read` \| `self-renew` \| `self-roll` \| `custody`. A KV intent, and only a KV intent. | | `role` | `database` | yes | The database role that issues the credential. The read path derives as `database/creds/`. | | `key` | `transit` | yes | The transit key name. | @@ -1411,26 +1411,26 @@ credential is actually read from, which is what R20 recorded as missing: the declared thing and the readable thing were different, and no policy covered the second. -There is a third level the list does **not** have: the domain header. A -domain-level grant would hand every Service in the file a reader slot on a path +There is a third level the list does **not** have: the project header. A +project-level grant would hand every Application in the file a reader slot on a path it may not need, and a read grant covers the whole document ([0009](../../docs/adr/model/0009-vault-read-is-per-path.md)), so the widening would be -real rather than notional. `secrets` stays per Service and per Workload -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). +real rather than notional. `secrets` stays per Application and per Process +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)). -A Workload's effective set is the Service-level list plus its own. There is no -override or removal syntax: a Workload that must *not* hold a shared secret is +A Process's effective set is the Application-level list plus its own. There is no +override or removal syntax: a Process that must *not* hold a shared secret is evidence the secret was never shared, and it moves down a level. Sharing is the common case and duplication is what drifts: `knowledge` holds six grants across -two Workloads and two are identical for both. +two Processes and two are identical for both. -The two levels are an access boundary **only** because identity is per Workload. -The ServiceAccount and Vault role are derived as the **Workload name alone** ( +The two levels are an access boundary **only** because identity is per Process. +The ServiceAccount and Vault role are derived as the **Process name alone** ( `auth-system.auth-api`, never `auth-system.auth-auth-api`) unique within the -domain file ([0024](../../docs/adr/model/0024-identity-per-workload.md), specified in -chapter 16). At review time they were not: `serviceAccountName()` in -`src/adapters/kubernetes.ts:665-669` returned `serviceName`, so two Workloads of -one Service authenticated as the same principal and received the union of both +project file ([0024](../../docs/adr/model/0024-identity-per-process.md), specified in +chapter 16). At review time they were not: `applicationAccountName()` in +`src/adapters/kubernetes.ts:665-669` returned `applicationName`, so two Processes of +one Application authenticated as the same principal and received the union of both policies whatever level a grant was written at. The nesting was documentation. The declaration and the identity ship together or not at all. @@ -1462,7 +1462,7 @@ Three consequences are normative here: 2. **No path may hold keys for more than one reader set.** That is the Secret Subtree's layout rule, and it is what draws the boundary the store can actually enforce. It is one path per reader **set**, not one path per consumer: a path - read by exactly one Service's Workloads stays whole, and splits on the day a + read by exactly one Application's Processes stays whole, and splits on the day a second reader is granted it. `secret/data/platform/postgres` splits per consumer under that rule, and `secret/platform/observability` (Prometheus token, Discord webhook and Grafana client secret in one document) must split @@ -1470,7 +1470,7 @@ Three consequences are normative here: 3. **There is no wildcard.** `keys: ['*']` is not vocabulary. It makes a reader set undecidable without reading live Vault contents, which the pinned-input rule forbids; `auth-api` enumerates the keys of `secret/data/auth-api` instead, and - adding a key becomes a Service edit. + adding a key becomes an Application edit. Reader sets are therefore computable from the composed union with no Vault read, and `E_ROLL_AFFECTS_OTHER_READERS` (chapter 40) computes over the readers of a @@ -1518,7 +1518,7 @@ grant declares `operations` instead, because no tier means anything there: and takes no tier at all: the engine issues the credential, so there is no capability to choose. -The tiers are intents, not a privilege lattice. A Workload that both reads a path +The tiers are intents, not a privilege lattice. A Process that both reads a path and rolls it declares two entries. ### Which tier may use which delivery @@ -1599,7 +1599,7 @@ declaration claiming otherwise would be a promise the substrate cannot keep. `auth-api` is the case this exists for. It runs `delivery: self` today, its client re-reads from Vault, and its credential can be replaced while it serves -traffic. A Service that needs the same property declares `delivery: self` with +traffic. An Application that needs the same property declares `delivery: self` with `rotation.tolerates: reload` and gets it; one that declares `env` has chosen a rollout, and the model says so at schema time rather than at rotation time. @@ -1616,7 +1616,7 @@ Two gates apply to the two deliveries that persist a Secret: ## Secret references -An env-delivered grant is bound to a variable by a placeholder in the Workload's +An env-delivered grant is bound to a variable by a placeholder in the Process's env file, and the placeholder's path half **byte-matches the grant's derived read path** ([0027](../../docs/adr/model/0027-secret-reference-join-key.md), amended by [0085](../../docs/adr/model/0085-a-grant-is-a-union-on-engine.md)): @@ -1666,14 +1666,14 @@ reader-set model auditable from the repository. | placeholder | resolves to | resolved from | |---|---|---| | `${secret:#}` | one key of one granted Secret Store path | the grant, byte-matched ([0027](../../docs/adr/model/0027-secret-reference-join-key.md)) | -| `${dependency:.}` | one coordinate of a Service this Workload depends on | the edge set (chapter 16) | -| `${exposure:.#}` | one field of a declared exposure | the composed union's exposure set ([Exposure](#exposure)) | +| `${dependency:.}` | one coordinate of an Application this Process depends on | the edge set (chapter 16) | +| `${exposure:.#}` | one field of a declared exposure | the composed union's exposure set ([Exposure](#exposure)) | -`${exposure:…}` addresses an exposure by the Service that declares it and the +`${exposure:…}` addresses an exposure by the Application that declares it and the `name` it carries there (which is what that `name` is for) and `` is one of exactly three: -| field | for `exposure: {name: public, host: auth.jorisjonkers.dev}` on Service `auth` | +| field | for `exposure: {name: public, host: auth.jorisjonkers.dev}` on Application `auth` | |---|---| | `url` | `https://auth.jorisjonkers.dev`: scheme and host, no trailing slash and no path | | `host` | `auth.jorisjonkers.dev` | @@ -1686,7 +1686,7 @@ line in twenty-four (`auth_oauth2.issuer`). Under this rule each becomes one placeholder, plus ordinary text after it where a path is needed: ``` -# platform/env//base.env, in each Workload that needs the host +# platform/env//base.env, in each Process that needs the host AUTH_ISSUER=${exposure:auth.public#url} AUTH_LOGIN_URL=${exposure:auth.public#url}/login CONFIRMATION_URL=${exposure:auth.public#url}/confirm @@ -1703,10 +1703,10 @@ whatever each appends, which is the same audit the byte-match rule buys for secrets. Both halves of the address are checked at composition, over the union that -already checks the other two sources: the Service must resolve in it, exactly as -a `dependsOn` target must (`E_UNRESOLVED_SERVICE`), and it must declare an +already checks the other two sources: the Application must resolve in it, exactly as +a `dependsOn` target must (`E_UNRESOLVED_APPLICATION`), and it must declare an exposure by that name. Reading a host this way is **not** a dependency edge: it -resolves to a string at build time and derives no egress, so a Workload that +resolves to a string at build time and derives no egress, so a Process that actually calls the host still declares `dependsOn` ([0035](../../docs/adr/model/0035-network-policy-default-deny.md)). @@ -1743,11 +1743,11 @@ cutover: rolling # required: continuity during the cutover, or an accepte Derived from these plus `stateful`, `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. +deadline, and the health-gate deadline the Application's switchover waits on. ### Cutover is declared, not promised -`cutover` is **required on every Workload** and has **no default**. It is the +`cutover` is **required on every Process** and has **no default**. It is the owner's answer to one question (must the next revision keep serving while it cuts over?) and requiring the answer is what keeps the availability consequence visible in every declaration instead of implicit in a boolean nobody reads: @@ -1765,10 +1765,10 @@ adapter and appear nowhere in layer 1 An RWO volume cannot attach to two pods at once, so a `rolling` cutover over one is a promise the substrate cannot keep. Refusing it is the point: the old `zeroDowntime: true` could ask for continuity while the derived strategy was -`Recreate`, and the contradiction was silent: the Workload rendered, reported +`Recreate`, and the contradiction was silent: the Process rendered, reported success, and simply stopped serving during every roll -([0030](../../docs/adr/model/0030-runtime-mechanics-derived.md)). A Workload -whose storage forces `recreate` now says so, and a Workload with no such storage +([0030](../../docs/adr/model/0030-runtime-mechanics-derived.md)). A Process +whose storage forces `recreate` now says so, and a Process with no such storage says `rolling` only if its owner actually requires continuity. ## Capacity @@ -1781,12 +1781,12 @@ replicas: `replicas` derives as **1** ([0089](../../docs/adr/model/0089-replicas-derived-no-minavailable.md)). Storage -is `local-path` and every claim is `ReadWriteOnce`, so a stateful Workload is +is `local-path` and every claim is `ReadWriteOnce`, so a stateful Process is pinned to one machine by construction; on one node, two replicas are two processes on one kernel. **`replicas` is the only exception to a derived value in layer 1, and it is -narrow on purpose.** Where a Workload genuinely needs more than one, the count +narrow on purpose.** Where a Process genuinely needs more than one, the count is stated here rather than routed through a general mechanism: - `count` must be **greater than one**: the field cannot become a verbose @@ -1797,9 +1797,9 @@ is stated here rather than routed through a general mechanism: ([Replicas, and the disruption budget](#replicas-and-the-disruption-budget)). There is no generic override, no free-form exception map, and no second -override vocabulary. Where a derived value is wrong for a whole workload class, +override vocabulary. Where a derived value is wrong for a whole process class, the central derivation is repaired and re-rendered against the estate; where it -is genuinely a fact only one Service knows, it earns one narrowly named field +is genuinely a fact only one Application knows, it earns one narrowly named field with its own authority and validation ([0031](../../docs/adr/model/0031-derived-overrides-with-reason.md)). @@ -1815,8 +1815,8 @@ The escape this replaces existed because the alternative was said to be a falsified input: an owner who needed a different deadline and could not say so would misreport their `startupBudget` to coax the number out of the derivation. That argument proved to license more than it justified: it was used to carry -values that were either a workload class the central rule should have covered, or -platform policy a Service had no business setting. Both are now handled where +values that were either a process class the central rule should have covered, or +platform policy an Application had no business setting. Both are now handled where they belong: the rule, or the platform. An owner whose `startupBudget` is genuinely special states it accurately, and the derivation reads it. @@ -1831,10 +1831,10 @@ already told them, and the lines reaching them made the model harder to read. | vocabulary | named by | values | |---|---|---| -| `Lifecycle` | `Workload.lifecycle` | `service`, `job` | -| `Runtime` | `Workload.runtime` | `jvm`, `python`, `node`, `static`, `none` | -| `Engine` | `Workload.engine` | `postgres`, `rabbitmq`, `valkey`, `files` | -| `Cutover` | `Workload.cutover` | `rolling`, `recreate` | +| `Lifecycle` | `Process.lifecycle` | `application`, `job` | +| `Runtime` | `Process.runtime` | `jvm`, `python`, `node`, `static`, `none` | +| `Engine` | `Process.engine` | `postgres`, `rabbitmq`, `valkey`, `files` | +| `Cutover` | `Process.cutover` | `rolling`, `recreate` | | `DurabilityClass` | `Volume.durability` | `reconstructible`, `recoverable`, `irreplaceable` | | `Arch` | `Placement.arch` | `amd64`, `arm64` | | `Media` | `DiskRequest.media` | `nvme`, `ssd`, `hdd` | @@ -1851,12 +1851,12 @@ already told them, and the lines reaching them made the model harder to read. Three carry a constraint the list alone does not state. `AccessTier` is `kv`-only except for `read`, and `TransitOp` applies to a `transit` grant only -([Access tiers](#access-tiers)). `AlertClass` has no `none` member: a Service +([Access tiers](#access-tiers)). `AlertClass` has no `none` member: an Application that wants no monitoring omits its `observability` block ([Observability](#observability)). Every other attribute type is either a primitive (`string`, `int`, `bool`, -`map`) or a named string this chapter constrains: `DomainName`, `ServiceId`, +`map`) or a named string this chapter constrains: `ProjectName`, `ApplicationId`, `ImageAlias`, `Fqdn`, `ExposureName`, `VaultPath`, `ClusterTarget`, `Site`, `Capability`, `GpuClassName`, `Path`, `Quantity`, `Duration`, `FileMode`, `SemVer` and `dotenv`. Each is defined where the field that uses it is defined. @@ -1869,17 +1869,17 @@ declaring site is fixed: | forbidden | where the value comes from | |---|---| -| a hostname another Service serves, written as a literal | `${exposure:…}`, addressing the exposure that declares it | -| a namespace | derived from `domain`, as `-system` | +| a hostname another Application serves, written as a literal | `${exposure:…}`, addressing the exposure that declares it | +| a namespace | derived from `project`, as `-system` | | a node label or selector | `placement` | | a scheduler weight, or any soft placement term | every dimension is hard ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)) | | `replicas` | derived as **1**, and more than one is a `replicas: {count, reason}` declaration ([0089](../../docs/adr/model/0089-replicas-derived-no-minavailable.md)), never a live cluster read | | storage class, volume capacity | assigned | | `resources`, requests or limits | derived from `placement` | | a `securityContext` field | the platform's `hardening` posture, and `writablePaths` | -| a ServiceAccount, Vault role or policy name | derived per Workload (chapter 16) | +| a ServiceAccount, Vault role or policy name | derived per Process (chapter 16) | | a Reconcile Unit or `platform.layer` | derived from the edge set | -| a field coupling the release of two Services | one Service, or two that release independently ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)) | +| a field coupling the release of two Applications | one Application, or two that release independently ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)) | | 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 | @@ -1905,9 +1905,9 @@ work lives in [docs/adr/deferred/](../../docs/adr/deferred/README.md). The model's complete interface to that work is three demands, all decided here: -1. **Service atomicity**: no Workload of a Service switches until every Workload - of that Service is healthy - ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)). +1. **Application atomicity**: no Process of an Application switches until every Process + of that Application is healthy + ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)). 2. **Durability Class gating**: a destructive operation on a non-`reconstructible` claim is refused ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)). 3. **Pinned inputs only**: every rendered value is a function of digested inputs, @@ -1930,20 +1930,20 @@ Two items no decision in the register covers: The list was five. Two items left it by being answered rather than graded, and `sidecars` left it by being graded -([0064](../../docs/adr/model/0064-sidecars-are-workload-vocabulary.md)). +([0064](../../docs/adr/model/0064-sidecars-are-process-vocabulary.md)). -The first asked what checks that a Workload's declared capacity can be satisfied +The first asked what checks that a Process's declared capacity can be satisfied by a node it is also allowed to run on: capacity and eligibility are one comparison against the node contract, and failing it is `E_PLACEMENT_UNSATISFIABLE` ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). The second asked how an exposure entry is named, and it is now [vocabulary](#exposure). An exposure carries an authored `name`, unique within -its Service, and an authored `host` that is the full FQDN, so the chapters no +its Application, and an authored `host` that is the full FQDN, so the chapters no longer disagree about what an exposure is called, and `E_DUPLICATE_EXPOSURE_NAME` finally has a definition to check. An apex host is `host: jorisjonkers.dev` and needs no flag, no field and no check of its own: two -Services claiming it is `E_DUPLICATE_HOST`, like any other collision. That closes +Applications claiming it is `E_DUPLICATE_HOST`, like any other collision. That closes chapter 00's first open item, of which this entry was the chapter-10 half. What the entry flagged (a value the contention test had placed on the platform side, now authored) is the same move `placement` makes, and it is settled the same @@ -1954,25 +1954,25 @@ way: contention decides who arbitrates, not who authors | example | what it exercises | |---|---| -| [`minimal/notes.domain.yml`](examples/minimal/notes.domain.yml) + [`env`](examples/minimal/env/notes-api/base.env) | **read this first.** One domain, one Service, one Workload, 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.domain.yml`](examples/knowledge/knowledge.domain.yml) + [`env`](examples/knowledge/env/knowledge-api.base.env) + [`worker env`](examples/knowledge/env/knowledge-ingest-worker.base.env) | two Workloads, 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.domain.yml`](examples/auth/auth.domain.yml) + [`env`](examples/auth/env/auth-api.base.env) | one Service, two Workloads 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.domain.yml`](examples/data/data.domain.yml) + [`env`](examples/data/env/platform-postgres.base.env) | three Services releasing independently in one domain, third-party images, a `disk` dimension, TCP probes, and a surface eight Services consume | +| [`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 | 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 5 placeholders matching 5 env-delivered keys, and its ingest worker 4 more against -the same Service-level grants; `platform-postgres` has 1 matching 1; `auth-api` has +the same Application-level grants; `platform-postgres` has 1 matching 1; `auth-api` has **0 and 0**, because all three of its grants are `delivery: self`, which demonstrates the check does not false-positive on runtime fetch. The byte-match rule changes how each placeholder is spelled, not how many there are. No dead grants, no unauthorised references, and no `delivery: env` paired with `tolerates: reload`. -Two negative fixtures sit beside them: `negative/duplicate-service-id/` asserts -`E_DUPLICATE_SERVICE_ID` across two repositories, and -`negative/duplicate-workload-name/` asserts `E_DUPLICATE_WORKLOAD_NAME` for two -Services in one domain reusing a Workload name: the check that lets a -ServiceAccount be the Workload name alone. +Two negative fixtures sit beside them: `negative/duplicate-application-id/` asserts +`E_DUPLICATE_APPLICATION_ID` across two repositories, and +`negative/duplicate-process-name/` asserts `E_DUPLICATE_PROCESS_NAME` for two +Applications in one project reusing a Process name: the check that lets a +ServiceAccount be the Process name alone. ## Diagram sources @@ -1989,18 +1989,18 @@ an ADR. classDiagram direction LR - class Domain { - +DomainName domain + class Project { + +ProjectName project +string owner +SemVer schemaVersion } - class Service { - +ServiceId id + class Application { + +ApplicationId id } class Observability { +AlertClass alertClass } - class Workload { + class Process { +string name +Lifecycle lifecycle +ImageAlias image @@ -2026,7 +2026,7 @@ classDiagram +Quantity cpu } class DependencyEdge { - +ServiceId service + +ApplicationId application +string surface +bool required } @@ -2039,7 +2039,7 @@ classDiagram class Route { +Path path +Match match - +string workload + +string process +string surface +Audience audience +Path redirectTo @@ -2075,7 +2075,7 @@ classDiagram +Quantity memory } class Scrape { - +string workload + +string process +string surface +Path path } @@ -2105,36 +2105,36 @@ classDiagram +Duration maxAge } - Domain "1" *-- "1..*" Service : services - Service "1" *-- "1..*" Workload : workloads - - Workload "1" *-- "0..*" Surface : provides - Workload "1" *-- "0..*" Sidecar : sidecars - Workload "1" *-- "0..*" DependencyEdge : dependsOn - Workload "1" *-- "0..1" Probe : readiness - Workload "1" *-- "0..1" Probe : liveness - Workload "1" *-- "0..*" Asset : assets - Workload "1" *-- "0..*" Volume : volumes - Workload "1" *-- "1" Placement : placement - Service "1" *-- "0..1" Observability : observability + Project "1" *-- "1..*" Application : applications + Application "1" *-- "1..*" Process : processes + + Process "1" *-- "0..*" Surface : provides + Process "1" *-- "0..*" Sidecar : sidecars + Process "1" *-- "0..*" DependencyEdge : dependsOn + Process "1" *-- "0..1" Probe : readiness + Process "1" *-- "0..1" Probe : liveness + Process "1" *-- "0..*" Asset : assets + Process "1" *-- "0..*" Volume : volumes + Process "1" *-- "1" Placement : placement + Application "1" *-- "0..1" Observability : observability Observability "1" *-- "1" Scrape : scrape - Workload "1" *-- "0..1" Capacity : replicas + Process "1" *-- "0..1" Capacity : replicas Placement "1" *-- "0..1" DiskRequest : disk Placement "1" *-- "0..1" GpuRequest : gpu - Service "1" *-- "0..*" Exposure : exposure + Application "1" *-- "0..*" Exposure : exposure Exposure "1" *-- "1..*" Route : routes Route ..> Surface : resolves by name DependencyEdge ..> Surface : resolves by name - Workload "1" *-- "1..*" EnvFile : env per workload + Process "1" *-- "1..*" EnvFile : env per process EnvFile "1" *-- "0..*" Placeholder : resolves - Service "1" *-- "0..*" Grant : secrets - Workload "1" *-- "0..*" Grant : secrets + Application "1" *-- "0..*" Grant : secrets + Process "1" *-- "0..*" Grant : secrets Grant "1" *-- "0..1" Rotation : rotation Placeholder ..> Grant : byte-matches - Placeholder ..> Exposure : addresses service.name + Placeholder ..> Exposure : addresses application.name ``` diff --git a/spec/v1/14-platform-intent.md b/spec/v1/14-platform-intent.md index 5faf80d..89eb08a 100644 --- a/spec/v1/14-platform-intent.md +++ b/spec/v1/14-platform-intent.md @@ -1,11 +1,11 @@ # Chapter 14: Platform Intent -Layer 1 has **two** authored documents, and this chapter is the second. Service -Intent (chapter 10) says what a Service needs; Platform Intent says what the +Layer 1 has **two** authored documents, and this chapter is the second. Project +Intent (chapter 10) says what an Application needs; Platform Intent says what the estate offers. Both are held to the same rule, **requirements and facts, never mechanisms**, and one test decides which document a value lives in: the contention test ([0004](../../docs/adr/model/0004-contention-decides-authority.md)). -A value a Service could state for itself belongs in chapter 10; a value that +A value an Application could state for itself belongs in chapter 10; a value that must be unique across the estate or draws on a shared finite resource belongs here. @@ -26,7 +26,7 @@ owner: joris One Platform document per estate. It is published as an **Intent Fragment** ([chapter 40](40-composition.md#fragments)) by the repository that owns the -platform, pushed by digest like any domain, and it is a **required participant** +platform, pushed by digest like any project, and it is a **required participant** whose staleness bound is the same seven days ([chapter 40](40-composition.md#participants)). There is no side channel: a render that cannot find the platform fragment is `E_PARTICIPANT_MISSING`, and a @@ -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 | domain files the platform owns, as ordinary Services ([The foundation is declared](#the-foundation-is-declared)) | a Service is a Service; 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-layer-meta-model.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 | @@ -95,19 +95,19 @@ bootstrap: | k3s itself | it is what applies | | the Flux source | it pulls the tree that everything else is in | | Vault's unseal | a secret the model must never hold | -| the CRDs the estate uses | cluster-scoped schema that must exist before any object of that kind can apply; the components that *use* them are declared Services | +| the CRDs the estate uses | cluster-scoped schema that must exist before any object of that kind can apply; the components that *use* them are declared Applications | -Everything not in this table is a declared Service. The set is a +Everything not in this table is a declared Application. The set is a [Bidirectional Ledger](30-deliverables.md#ledgers) in shape: an entry nothing needs fails the build, and a component that should be declared and is not is `E_UNATTRIBUTED_OBJECT`. ## The foundation is declared -Vault, VSO, Traefik, Prometheus and Gatus are Services in domain files the +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`, Workloads, `engine`, grants, -`exposure`, volumes and a Durability Class like any tenant Service +`platform/observability.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 [chapter 40](40-composition.md#the-estate-wide-invariants) sees them. @@ -118,15 +118,15 @@ Two consequences are normative: declared from its image; what the chart added (defaults and CRDs) is respectively what a declaration replaces and what the bootstrap set pins. `HelmRelease` and `HelmRepository` are not rendered kinds. -- **Two Traefik instances are two Services**, placed by capability: one on the +- **Two Traefik instances are two Applications**, placed by capability: one on the `public-ingress` node, one on a LAN node. That placement, and the tier facts below, are what keep LAN traffic off the Frankfurt proxy, not which adapter emitted the route. The estate-scoped Deliverables that used to have adapters of their own, the Gatus endpoints, the edge catalogs, are **inbound derivations** of the platform -Service that consumes them ([chapter 16](16-dependencies.md#what-an-edge-derives-read-inbound)), -rendered as that Service's own Assets, exactly as the database catalog is for +Application that consumes them ([chapter 16](16-dependencies.md#what-an-edge-derives-read-inbound)), +rendered as that Application's own Assets, exactly as the database catalog is for `postgres` ([0080](../../docs/adr/model/0080-database-catalog-is-derived-data.md)). ## Tiers @@ -142,7 +142,7 @@ tiers: listener: tls # tls | plain certificates: acme # acme | none forwardAuth: http://auth-api.auth-system.svc.cluster.local:8081/api/auth/forward - traefik: traefik-public # the declared Service that is this tier's proxy + traefik: traefik-public # the declared Application that is this tier's proxy - name: lan audiences: [lan] listener: plain @@ -156,7 +156,7 @@ tiers: | `listener` | whether the edge terminates TLS | | `certificates` | how certificates are issued for what it terminates | | `forwardAuth` | the endpoint that authenticates for it; required where `authenticated` is carried, `E_NO_FORWARD_AUTH_ENDPOINT` otherwise ([0076](../../docs/adr/model/0076-middleware-has-one-producer.md)) | -| `traefik` | the platform Service whose proxy this tier is | +| `traefik` | the platform Application whose proxy this tier is | `entryPoint`, `certResolver` and every other Traefik spelling appear only in the adapter. A route's audience is the **only** way it reaches a tier, so a `lan` @@ -194,7 +194,7 @@ engines: ``` A shell command in an authored file is what [0012](../../docs/adr/model/0012-assets-not-code.md) -refuses for a Service, and it is refused here for the same reason: what the +refuses for an Application, and it is refused here for the same reason: what the image does is versioned and digested; a string in YAML is neither. ## Monitor cadence @@ -206,13 +206,13 @@ monitors: ``` One cadence for every monitor the estate renders, here for the same reason the -probe cadence below is: it is contended, and no Service knows better +probe cadence below is: it is contended, and no Application knows better ([0004](../../docs/adr/model/0004-contention-decides-authority.md)). That is the whole observability surface of this document. No receiver map, no severity mapping, no rule catalog: those belong to the monitoring stack, which reads `alertClass` from the published projection -([chapter 10](10-service-intent.md#observability)). +([chapter 10](10-project-intent.md#observability)). ## Hardening policy @@ -225,10 +225,10 @@ hardening: restricted The class is the platform's because it is uniform and contended: thirty declarations of the only legal value are thirty copies of one decision -([0004](../../docs/adr/model/0004-contention-decides-authority.md)). A Workload +([0004](../../docs/adr/model/0004-contention-decides-authority.md)). A Process therefore authors no hardening at all: it declares the paths it must write, and an image that cannot meet the class is `E_HARDENING_UNMET` -([chapter 10](10-service-intent.md#pod-hardening)). There is no per-control +([chapter 10](10-project-intent.md#pod-hardening)). There is no per-control relaxation to author, because a relaxation carried with a reason is an override under another name. @@ -250,7 +250,7 @@ ephemeral: {sizeLimit: 64Mi} ## Providers -Things the estate runs and this model does not deploy, that a Service may +Things the estate runs and this model does not deploy, that an Application may depend on. A **provider is a fact, not a hole** ([0095](../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)): it has an address and surfaces, an edge resolves against it @@ -275,7 +275,7 @@ against facts, never against exemptions. **Layer 1 has no generic override mechanism and this document carries no overridable-derivations table.** A derived value has one declaring site, the derivation, and an assignment has one author, the platform. The sole local -exception is capacity ([chapter 10](10-service-intent.md#capacity)): +exception is capacity ([chapter 10](10-project-intent.md#capacity)): ```yaml replicas: @@ -286,14 +286,14 @@ replicas: There is no `E_UNKNOWN_OVERRIDE`, because there is no key set to be outside. What used to sit in a ten-row table resolves three ways: -- **A workload-class difference is a derivation bug.** If one rule is wrong for a - whole class of Workload, the rule is repaired and the estate re-rendered, +- **A process-class difference is a derivation bug.** If one rule is wrong for a + whole class of Process, the rule is repaired and the estate re-rendered, which is what `startupDeadline` was, and why it is now one rule over - `startupBudget` rather than a per-Workload exception. + `startupBudget` rather than a per-Process exception. - **A platform policy stays platform policy.** Cadence, retention, ephemeral size, probe timing and route precedence are contended and shared; they are - stated once here or derived, and no Service restates them. -- **An irreducible Service fact earns a named field** with its own authority, + stated once here or derived, and no Application restates them. +- **An irreducible Application fact earns a named field** with its own authority, validation and example, not a generic entry pointing at a rendered field. That is deliberately more demanding than adding a row. An unbounded exception diff --git a/spec/v1/16-dependencies.md b/spec/v1/16-dependencies.md index 2f8931e..8addbe9 100644 --- a/spec/v1/16-dependencies.md +++ b/spec/v1/16-dependencies.md @@ -1,8 +1,8 @@ # Chapter 16: Dependencies, identity, and derivation -Chapter 10 defined what a domain file declares: Services, and the Workloads +Chapter 10 defined what a project file declares: Applications, and the Processes under them. This chapter defines what those declarations *produce*: the edge -set between Services, the identity each Workload authenticates as, the network +set between Applications, the identity each Process authenticates as, the network policy both derive, and the derivation map that gives this specification its one machine-checkable property. @@ -14,30 +14,30 @@ consumer requires it ```yaml dependsOn: - - {service: platform-postgres, surface: postgres} - - {service: auth, surface: http, required: false} + - {application: platform-postgres, surface: postgres} + - {application: auth, surface: http, required: false} ``` | field | required | meaning | |---|---|---| -| `service` | yes | A Service Id, the only referencable identity ([0010](../../docs/adr/model/0010-flat-service-identity.md)). It must resolve in the composed union: `E_UNRESOLVED_SERVICE`. | -| `surface` | yes | One surface declared by one of that Service's Workloads. The port is written once, by the provider, and never restated by a consumer: `E_UNKNOWN_SURFACE` where the name matches nothing. | +| `application` | yes | An Application Id, the only referencable identity ([0010](../../docs/adr/model/0010-flat-application-identity.md)). It must resolve in the composed union: `E_UNRESOLVED_APPLICATION`. | +| `surface` | yes | One surface declared by one of that Application's Processes. The port is written once, by the provider, and never restated by a consumer: `E_UNKNOWN_SURFACE` where the name matches nothing. | | `required` | no | Defaults to `true`. | -**`provides` moved to the Workload; the edge did not.** A port is a property of -a process, so surfaces are declared by the Workload that listens -([chapter 10](10-service-intent.md#ports-and-surfaces)). `dependsOn` still -targets `{service, surface}` and nothing a consumer writes changes. Surface -names stay unique within a Service, so the pair resolves to exactly one -Workload, one port and one address: `{service: auth, surface: http}` is carried -by Workload `auth-api`, and the consumer neither names that Workload nor learns -it exists. A provider may move a surface between its own Workloads without a -single consumer edit. The Service Id remains the only referencable identity, and -a Workload is not referencable from outside its Service -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)). - -Edges are declared **per Workload**, and a Service's edge set is the union of -its Workloads' edges. Within `knowledge` the API reaches Postgres while the +**`provides` moved to the Process; the edge did not.** A port is a property of +a process, so surfaces are declared by the Process that listens +([chapter 10](10-project-intent.md#ports-and-surfaces)). `dependsOn` still +targets `{application, surface}` and nothing a consumer writes changes. Surface +names stay unique within an Application, so the pair resolves to exactly one +Process, one port and one address: `{application: auth, surface: http}` is carried +by Process `auth-api`, and the consumer neither names that Process nor learns +it exists. A provider may move a surface between its own Processes without a +single consumer edit. The Application Id remains the only referencable identity, and +a Process is not referencable from outside its Application +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)). + +Edges are declared **per Process**, and an Application's edge set is the union of +its Processes' edges. Within `knowledge` the API reaches Postgres while the ingest worker reaches RabbitMQ, and neither inherits the other's egress. An id alone would not carry enough. The only NetworkPolicy code this estate @@ -63,29 +63,29 @@ default) buys both. The graph of required edges must be acyclic (`E_DEPENDENCY_CYCLE`, [chapter 40](40-composition.md)). An edge orders; it does not group. Things that must switch versions together are -Workloads of **one Service**: a Service is the unit of atomic release, its -Workloads switch together or none switches, and there is no mechanism to couple -two Services ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)). -Atomicity is authored by drawing the Service boundary, because the graph cannot +Processes of **one Application**: an Application is the unit of atomic release, its +Processes switch together or none switches, and there is no mechanism to couple +two Applications ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)). +Atomicity is authored by drawing the Application boundary, because the graph cannot see it: a frontend depends on its API, but a dependency edge does not mean the two must cut over together, and deriving atomicity from every edge would make -the whole estate one unit. A lockstep pair that survives as two Services is not +the whole estate one unit. A lockstep pair that survives as two Applications is not a missing feature: it is evidence the boundary is drawn wrong, and the fix is redrawing it. ### What an edge derives, read inbound -The same edges read from the provider's side produce derivations no Service -could declare locally, because no Service knows its own consumers. They are +The same edges read from the provider's side produce derivations no Application +could declare locally, because no Application knows its own consumers. They are computable only over the composed union ([0037](../../docs/adr/model/0037-composition-oci-fragments.md)), which is this chapter's hard dependency on [chapter 40](40-composition.md). | inbound derivation | evidence it is needed | |---|---| -| a database and owning user per consumer | `init-databases.sh` creates `auth_db`, `agents_db`, `knowledge_db` and `n8n_db`, one per Service claiming a Postgres credential. 98 lines the graph already knows. | -| the Gatus endpoint list | one check per route on every exposure in the union, for the declared `gatus` Service, 41 derived references in 288 hand-maintained lines today ([0098](../../docs/adr/model/0098-one-publication-path.md)) | -| the edge catalogs | every host and route the estate serves, for the declared Traefik Services, 30 and 28 derived references in two hand-maintained ConfigMaps | +| a database and owning user per consumer | `init-databases.sh` creates `auth_db`, `agents_db`, `knowledge_db` and `n8n_db`, one per Application claiming a Postgres credential. 98 lines the graph already knows. | +| the Gatus endpoint list | one check per route on every exposure in the union, for the declared `gatus` Application, 41 derived references in 288 hand-maintained lines today ([0098](../../docs/adr/model/0098-one-publication-path.md)) | +| the edge catalogs | every host and route the estate serves, for the declared Traefik Applications, 30 and 28 derived references in two hand-maintained ConfigMaps | | NetworkPolicy **ingress** | a provider must admit its consumers, and only the inbound set says who they are | | browser origin allow-lists | `auth-api` hand-maintains `AUTH_CORS_ALLOWED_ORIGINS` with nine hostnames | | rotation blast radius | "who breaks if I rotate this?" is the reader set of a Secret Subtree **path**, computed over readers of the path and never over declared key sets | @@ -98,9 +98,9 @@ specification, see [Defined separately](#defined-separately). The first row of that table has a producer ([0080](../../docs/adr/model/0080-database-catalog-is-derived-data.md)). For a -provider Workload whose [`engine`](10-service-intent.md#workload) is a datastore +provider Process whose [`engine`](10-project-intent.md#process) is a datastore that owns databases, the inbound edge set derives a **catalog**: one entry per -consuming Service naming its database, its owning user, and the Vault role that +consuming Application naming its database, its owning user, and the Vault role that issues that user's credentials. The catalog is **data, not a procedure**. It renders as a `ConfigMap` and the @@ -109,7 +109,7 @@ same split [0077](../../docs/adr/model/0077-durability-derives-a-backup.md) make for backups, and for the same reason: [0012](../../docs/adr/model/0012-assets-not-code.md) forbids an executable Asset, and a rendered shell script is a diff no reviewer can validate except by running it. What exists today is 98 lines of -`init-databases.sh` creating `auth_db`, `agents_db`, `knowledge_db` and `n8n_db`, one per Service claiming a Postgres credential, which is exactly the inbound +`init-databases.sh` creating `auth_db`, `agents_db`, `knowledge_db` and `n8n_db`, one per Application claiming a Postgres credential, which is exactly the inbound edge set. **No password is rendered.** The catalog names a Vault role; Vault's database @@ -126,51 +126,51 @@ mismatch R20 recorded (a grant path is not the path a credential is read from) and it is why the catalog could not render until the grant vocabulary became a union on engine. -## Workload identity +## Process identity -Every Workload authenticates as its own principal. The ServiceAccount, the +Every Process authenticates as its own principal. The ServiceAccount, the Vault Kubernetes auth role and the Vault policy bound to it are derived **per -Workload** and named for the **Workload alone** -([0024](../../docs/adr/model/0024-identity-per-workload.md)). The namespace is the -domain's, `-system` -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)), so the principal a -Pod presents is `-system.`. No author writes an identity name +Process** and named for the **Process alone** +([0024](../../docs/adr/model/0024-identity-per-process.md)). The namespace is the +project's, `-system` +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)), so the principal a +Pod presents is `-system.`. No author writes an identity name ([0030](../../docs/adr/model/0030-runtime-mechanics-derived.md)). -| domain | Service | Workloads | derived identity | +| project | Application | Processes | derived identity | |---|---|---|---| | `auth` | `auth` | `auth-api`, `auth-ui` | `auth-system.auth-api`, the identity already live, `VAULT_KUBERNETES_ROLE: auth-api`, and `auth-system.auth-ui` | | `knowledge` | `knowledge` | `knowledge-api`, `knowledge-ingest-worker` | `knowledge-system.knowledge-api`, `knowledge-system.knowledge-ingest-worker` | -A `-` prefix is what the domain file makes absurd. Service -`auth` holds Workload `auth-api`, so the prefixed rule would render +A `-` prefix is what the project file makes absurd. Application +`auth` holds Process `auth-api`, so the prefixed rule would render `auth-system.auth-auth-api` for no gain: `auth-api` is the process name, and it is the role the live cluster already carries. The uniqueness the prefix existed -to give moves to where a reader can check it: two Workloads in one domain may -not share a name, `E_DUPLICATE_WORKLOAD_NAME` at composition +to give moves to where a reader can check it: two Processes in one project may +not share a name, `E_DUPLICATE_PROCESS_NAME` at composition ([chapter 40](40-composition.md#identity)). Vault's Kubernetes auth method binds a role to ServiceAccount names and namespaces and to nothing finer, so two Pods presenting one ServiceAccount token are one principal holding the union of the policies bound to it. Two things -follow. Deriving the account from the Service Id (which +follow. Deriving the account from the Application Id (which `src/adapters/kubernetes.ts:665-669` does today, and which the previous version of this chapter drew as `id --> ServiceAccount`) makes the two grant levels of -[0022](../../docs/adr/model/0022-grants-live-on-the-service.md) documentation rather +[0022](../../docs/adr/model/0022-grants-live-on-the-application.md) documentation rather than a boundary. Under it, `knowledge-api`, which serves anonymous paths from the public internet, authenticated as the principal holding `read` on `secret/data/knowledge-system/vault-deploy-key`, the `0400` deploy key only the ingest worker declares. And because the binding's other half is the namespace, -while a namespace now holds every Service of its domain by construction, the +while a namespace now holds every Application of its project by construction, the **namespace is not a trust boundary**: `auth-system` is shared, and no grant is -narrowed by living in it. What separates two Workloads is the ServiceAccount +narrowed by living in it. What separates two Processes is the ServiceAccount name alone, which is exactly why its uniqueness is checked across the whole -domain rather than within one Service. +project rather than within one Application. -A Workload's **effective grant set** is the Service-level `secrets` list plus -its own. Layer 2 flattens that set per Workload before deriving policy, so a -Service-level grant renders one policy statement per Workload that holds it, -never one shared statement. Renaming a Workload renames its identity: role, +A Process's **effective grant set** is the Application-level `secrets` list plus +its own. Layer 2 flattens that set per Process before deriving policy, so a +Application-level grant renders one policy statement per Process that holds it, +never one shared statement. Renaming a Process renames its identity: role, policy and bindings churn, and the new identity must be granted before it starts. @@ -200,11 +200,11 @@ one of its readers holds `read` on its neighbours' credentials. ### Worked trace: one secret grant ```yaml -# the knowledge domain file: the grant sits on the Service, since both -# Workloads hold it -domain: knowledge +# the knowledge project file: the grant sits on the Application, since both +# Processes hold it +project: knowledge owner: joris -services: +applications: - id: knowledge secrets: - path: secret/data/platform/postgres/kb # one path, one reader set @@ -227,14 +227,14 @@ selects which value fills the variable and confers nothing. | derives | detail | |---|---| -| `VaultStaticSecret` | in the domain's namespace, `knowledge-system`, syncing the granted path | +| `VaultStaticSecret` | in the project's namespace, `knowledge-system`, syncing the granted path | | `Secret` | the synced document, every key at the path, because that is what a read returns; not a projection of `keys:` | | `envFrom` secretRef | where the two placeholders resolve; they never become literal `env` entries | | Vault policy + auth role | bound to `knowledge-api` in `knowledge-system`, carrying the tier's capabilities on the granted **path**. `read` covers the whole document | | `rolloutRestartTargets` | from `tolerates: restart`, no longer hand-declared | | engine choice | static, because `restart` does not require `delivery: self` | -| `NetworkPolicy` egress | to the Secret Store, from the Workloads holding the grant and not from their siblings | -| Secret Subtree cross-check | the `data` domain must declare this path and list this Service as a reader | +| `NetworkPolicy` egress | to the Secret Store, from the Processes holding the grant and not from their siblings | +| Secret Subtree cross-check | the `data` project must declare this path and list this Application as a reader | | reader set and roll impact | the readers of the path, over the composed union | | **inbound**, on the provider | one database and one owning user in `init-databases.sh` | @@ -250,7 +250,7 @@ form could not express: | a `${secret:…}` placeholder whose path byte-matches no grant | `E_UNAUTHORISED_SECRET_REFERENCE` | | `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 Services read, unacknowledged | `E_ROLL_AFFECTS_OTHER_READERS`, computed over the readers of the path | +| `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)) | The roll-impact check is the one nothing in the estate has today: @@ -260,27 +260,27 @@ those keys. ## Network policy -Policy is **default-deny and derived**. A Workload's legal flows are exactly its +Policy is **default-deny and derived**. A Process's legal flows are exactly its declared edges, the surfaces it declares, the exposure routes that name it, its -effective grant set, and a platform baseline no Service authors +effective grant set, and a platform baseline no Application authors ([0035](../../docs/adr/model/0035-network-policy-default-deny.md)). -It is evaluated **per pod**, and it has to be. A namespace holds every Service -of its domain ([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)), so a +It is evaluated **per pod**, and it has to be. A namespace holds every Application +of its project ([0063](../../docs/adr/model/0063-intent-authored-per-project.md)), so a namespace wall separates nothing and no isolation claim may rest on one. -Isolation in this model is the derived edge set plus per-Workload identity -([0024](../../docs/adr/model/0024-identity-per-workload.md)), both per Workload, both +Isolation in this model is the derived edge set plus per-Process identity +([0024](../../docs/adr/model/0024-identity-per-process.md)), both per Process, both readable in one file. Opt-in was already measured here and it lost: three NetworkPolicy objects exist -for roughly thirty workloads, so the cluster is effectively open east-west. +for roughly thirty processes, so the cluster is effectively open east-west. Three of thirty is what opt-in produces on this estate, and the number is the argument. Default-deny is expressible only because the edge set is complete: every legal flow named by a declaration someone owns. The producer is the `networking` adapter ([0074](../../docs/adr/model/0074-networking-adapter-emits-policy.md)): every -`NetworkPolicy` in the estate, per Workload from the allow set below plus the two -baseline rules, and one namespace-wide default-deny per domain. Nothing else +`NetworkPolicy` in the estate, per Process from the allow set below plus the two +baseline rules, and one namespace-wide default-deny per project. Nothing else emits one, which is what makes the DNS assertion checkable against a single producer. @@ -288,37 +288,37 @@ producer. | rule | derived from | direction | |---|---|---| -| to a provider's surface port | each `dependsOn` edge of the Workload; for an edge to a Registered Unmanaged Surface, to the address and port the register carries ([0090](../../docs/adr/model/0090-edges-resolve-against-the-register.md)) | egress | +| to a provider's surface port | each `dependsOn` edge of the Process; for an edge to a Registered Unmanaged Surface, to the address and port the register carries ([0090](../../docs/adr/model/0090-edges-resolve-against-the-register.md)) | egress | | from each consumer of a surface | the inbound edge set, over the composed union | ingress | -| to the Secret Store | any grant in the Workload's effective set | egress | -| from the route tier carrying the audience | a route on the Service's `exposure` naming this Workload | ingress | -| from the metrics stack, to the scrape port | the Workload's `scrape` surface | ingress | +| to the Secret Store | any grant in the Process's effective set | egress | +| from the route tier carrying the audience | a route on the Application's `exposure` naming this Process | ingress | +| from the metrics stack, to the scrape port | the Process's `scrape` surface | ingress | ### The baseline -Two rules are in the rendered set for every Workload and appear in no +Two rules are in the rendered set for every Process and appear in no declaration: | baseline rule | why it cannot be optional | |---|---| | **egress UDP/53 to the cluster DNS service**, in every policy carrying `Egress` in `policyTypes` | once any egress policy selects a pod, all unmatched egress is denied, DNS included. The dead renderer generation shows the failure: `providerPolicy` (`src/deployment/render/networkpolicy.ts:86-102`) emits an egress rule to the provider's pod and nothing else, so the consumer cannot resolve the `svc.cluster.local` name the coordinate derivation just handed it, and fails with a DNS timeout diagnosed as "Postgres is down". TCP/53 rides the same rule, for truncated responses. | -| **ingress from the metrics stack** to any declared scrape port | the same file omits it; a workload that silently loses scrape stops alerting, which is the failure observability exists to prevent | +| **ingress from the metrics stack** to any declared scrape port | the same file omits it; a process that silently loses scrape stops alerting, which is the failure observability exists to prevent | The DNS half is checkable statically: **every rendered NetworkPolicy carrying `Egress` in `policyTypes` also matches UDP/53**. A `conftest` rule asserts it over the rendered set, and that assertion is the property this baseline exists to hold. -A baseline rule is not authorable and not exceptable from a Service document. An +A baseline rule is not authorable and not exceptable from an Application document. An exception to one is a change to the derivation, reviewed once, applied to every -Workload at once. +Process at once. ### The token is mounted only where the pod authenticates `automountServiceAccountToken` derives from **`delivery`**, and from nothing else ([0087](../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md)): -| the Workload's grants | token | +| the Process's grants | token | |---|---| | at least one with `delivery: self` | mounted | | only `env` or `file`, or none at all | **not** mounted | @@ -330,12 +330,12 @@ authenticates to anything. Under `delivery: file` the kubelet does the projecting. Only `delivery: self` means *the pod itself* presents its ServiceAccount token to Vault, which is the one case a token is for. -This is [0075](../../docs/adr/model/0075-no-workload-rbac-in-v1.md)'s reasoning +This is [0075](../../docs/adr/model/0075-no-process-rbac-in-v1.md)'s reasoning applied to the token instead of the Role, and it reaches the same place: the -privilege a Workload of this estate actually needs is smaller than the default, +privilege a Process of this estate actually needs is smaller than the default, and the field that says so already exists. -A Workload that calls the **Kubernetes** API (`agents-api` creates Services at +A Process that calls the **Kubernetes** API (`agents-api` creates Applications at runtime) needs a token that no grant implies. It declares so with a reason, recorded in the projection its owner reads back, which lets the estate count how many pods hold a token they were not derived one for @@ -343,23 +343,23 @@ many pods hold a token they were not derived one for ### No Role grants what an absence already denies -Three Services share `data-system`, and the only thing stopping `platform-valkey`'s +Three Applications share `data-system`, and the only thing stopping `platform-valkey`'s ServiceAccount from reading `platform-postgres`'s Secret is that no Role grants it. That is an absence rather than a boundary, and the model turns it into a checked property rather than rendering RBAC -([0075](../../docs/adr/model/0075-no-workload-rbac-in-v1.md)). +([0075](../../docs/adr/model/0075-no-process-rbac-in-v1.md)). **v1 renders no `Role`, `ClusterRole`, `RoleBinding` or `ClusterRoleBinding` for -a Workload**, and no rendered Deliverable may grant access to `secrets`, -`E_WORKLOAD_RBAC_GRANT`, a composition-time invariant +a Process**, and no rendered Deliverable may grant access to `secrets`, +`E_PROCESS_RBAC_GRANT`, a composition-time invariant ([chapter 40](40-composition.md#secrets)). Under `delivery: env` and `delivery: file` the kubelet projects the Secret and the pod never calls the API, -so a least-privilege Role for these Workloads grants nothing; rendering sixty +so a least-privilege Role for these Processes grants nothing; rendering sixty objects that grant nothing would make an empty Role read as an oversight and give a future broad grant somewhere to hide. -A Workload that genuinely needs the Kubernetes API (`agents-api` creates -Services at runtime) is the case this rule refuses to guess at. It is an +A Process that genuinely needs the Kubernetes API (`agents-api` creates +Applications at runtime) is the case this rule refuses to guess at. It is an unregistered capability today, so it belongs in a Bidirectional Ledger with an owner until the model has vocabulary for it ([0055](../../docs/adr/model/0055-bidirectional-ledgers.md)), not in an @@ -392,22 +392,22 @@ so an unpicked CNI does not block the render. | audit | the set is loaded into the non-enforcing stage; observed flows are diffed against the rendered allow set | **zero undeclared flows over 14 days** | | enforce | the set is enforced estate-wide | - | -An edge whose target resolves to neither a Service in the union nor a Registered -Unmanaged Surface is `E_UNRESOLVED_SERVICE`, and one resolving to a register +An edge whose target resolves to neither an Application in the union nor a Registered +Unmanaged Surface is `E_UNRESOLVED_APPLICATION`, and one resolving to a register entry without coordinates for that surface is `E_UNMANAGED_SURFACE_WITHOUT_COORDINATES` ([0090](../../docs/adr/model/0090-edges-resolve-against-the-register.md)). Both -existed as silence before: `{service: stalwart, surface: smtp}` derived no +existed as silence before: `{application: stalwart, surface: smtp}` derived no coordinates and therefore no egress rule, producing a valid policy with a missing rule, a timeout on-call rather than a build error. One cost is accepted rather than mitigated: an undeclared east-west path (this estate is known to hold some) stays invisible until promotion, and then breaks -a workload. +a process. The second cost this section used to accept is now refused. A typo in a `surface` name is `E_UNKNOWN_SURFACE` on the consuming edge, and a target -outside both namespaces is `E_UNRESOLVED_SERVICE`; a rendered policy can no +outside both namespaces is `E_UNRESOLVED_APPLICATION`; a rendered policy can no longer be silently short a rule while every gate stays green. What remains genuinely silent is a flow nobody declared at all, which is what the audit stage exists to find. @@ -420,7 +420,7 @@ column down for everything an output rests on. Ninety arrows between two tall columns is a hairball no layout fixes (which line ends where stops being answerable), so the relation is carried by position instead. -The first matrix has the fields of Service Intent and the pinned input set of +The first matrix has the fields of Project Intent and the pinned input set of [chapter 20](20-resolved-deployment.md#pinned-inputs) as rows, and the layer-2 assignments as columns. The second has those assignments **and** the declared fields as rows, and the Deliverables as columns. The `in` row under each grid is @@ -440,20 +440,20 @@ no assignment in between.* [Diagram source](#the-derivation-map) · edit by opening the SVG in draw.io -Two edges carry the amendment. `namespace` hangs off `domain`, not off `id`, so -ten live namespaces come out unchanged and no Service can name its own -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). And `placement` -feeds both `nodeSelector` and `requests + limits`, so the numbers a Workload +Two edges carry the amendment. `namespace` hangs off `project`, not off `id`, so +ten live namespaces come out unchanged and no Application can name its own +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)). And `placement` +feeds both `nodeSelector` and `requests + limits`, so the numbers a Process asks for and the nodes it may land on are one declaration compared against one pinned input: the node contract's `allocatable`, never a live read ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). No node satisfying every declared dimension is `E_PLACEMENT_UNSATISFIABLE` at build, -before an object is rendered. Eligibility is not bin-packing: three Workloads +before an object is rendered. Eligibility is not bin-packing: three Processes asking `memory: 2Gi` each pass against a 4096Mi node, and the scheduler refuses the third at apply. A node left the map altogether, and with it four edges. There is no derived -`hostname (FQDN)` any more: `exposure` hangs off the **Service**, and the `host` +`hostname (FQDN)` any more: `exposure` hangs off the **Application**, and the `host` it carries is a full authored FQDN ([0018](../../docs/adr/model/0018-exposure-by-audience.md)), so the IngressRoute, the reachability entry, both edge catalogs, the Gatus endpoint and @@ -462,9 +462,9 @@ a value layer 2 assembled from a label, a tier policy and a cluster domain. The Platform Intent no longer contributes to a hostname at all. What layer 2 still decides on that path is `r_tier` (the tier carrying the audience and the middleware chain that comes with it), which is why the exposure node keeps an -arrow into it. `provides` stays on the Workload, so the two ends of a route are -declared in the same document without a port ever being restated: the Service -says which host and path, the Workload says which port. +arrow into it. `provides` stays on the Process, so the two ends of a route are +declared in the same document without a port ever being restated: the Application +says which host and path, the Process says which port. The map is dense on purpose and is not meant to be read by eye. Its value is that the three properties below are **checkable by a script** over the @@ -482,10 +482,10 @@ One declaration, six artefacts, plus the two conformance tests that existed only to detect when those six disagreed (`route-auth-conformance.test.js`, `gatus-route-coverage.test.js`). Under property 1 those tests have nothing left to check, because the six cannot disagree: they share one upstream. That -upstream is a **Service** field: one host fronting two Workloads, +upstream is an **Application** field: one host fronting two Processes, `auth.jorisjonkers.dev/api` to `auth-api` and `/` to `auth-ui`, is a single exposure with two routes, and it is unexpressible while `exposure` sits on a -Workload. +Process. The hostname is no longer assembled. `host` is the full FQDN as authored and is carried through untouched; what layer 2 decides on this path is the tier that @@ -530,12 +530,12 @@ that would have caught the estate's clearest example. `rollbackTargetRetention` was validated for `minimumDays >= 90` and `acknowledged: true`, appeared in the readiness scorecard, was documented in three `PLATFORM.md` files as failing *never*, and was read by no renderer or -adapter. Every service declared the identical value. Out-degree zero. +adapter. Every application declared the identical value. Out-degree zero. No surface is exempt from this check. The override mechanism that used to be exempt is deleted ([0031](../../docs/adr/model/0031-derived-overrides-with-reason.md)), so the -dead-declaration property now runs over every declaration in every domain file. +dead-declaration property now runs over every declaration in every project file. ## What the properties would have caught @@ -543,8 +543,8 @@ dead-declaration property now runs over every declaration in every domain file. |---|---|---| | `kb.jorisjonkers.dev` in seven places | 1 | six Deliverables with in-degree zero | | `rollbackTargetRetention` inert | 3 | a declaration with out-degree zero | -| `platform.layer` wrong in 7 of 7 services | 3 | out-degree zero, it fed a registry, never the Reconcile Unit | -| a ServiceAccount per Service, two Workloads sharing one principal | 2 | one identity field with two Workloads' grant sets declaring it | +| `platform.layer` wrong in 7 of 7 applications | 3 | out-degree zero, it fed a registry, never the Reconcile Unit | +| a ServiceAccount per Application, two Processes sharing one principal | 2 | one identity field with two Processes' grant sets declaring it | | 41 Gatus checks, no notifier | 1 | `notifier route` unreachable from any declaration | | 60 duplicated `OTEL_*` lines | 2 | six declaring sites for one field | | a secret granted but never referenced | 3 | a `delivery: env` grant with out-degree zero | @@ -556,8 +556,8 @@ How the estate deploys, and how dependency on other units for testing gates a deploy, are defined separately from this model. This chapter derives the edge set, the identities and the policy set; it does not say who applies them, in what order a pipeline runs, or which suites must pass first. The model's whole -interface to that work is three demands: all-or-nothing switchover per Service -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)), Durability Class +interface to that work is three demands: all-or-nothing switchover per Application +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)), Durability Class gating on destructive operations, and rendering from pinned inputs only. The parked direction work is in [docs/adr/deferred/](../../docs/adr/deferred/README.md). @@ -566,7 +566,7 @@ parked direction work is in 1. **The CORS predicate.** `AUTH_CORS_ALLOWED_ORIGINS` lists nine hostnames, and the inbound derivation above claims they are the inbound edge set projected - onto the hosts those Services declare. The shape is right; the predicate is not + onto the hosts those Applications declare. The shape is right; the predicate is not established. A browser origin is needed only by a consumer making cross-origin requests *to* `auth-api`, whereas an OIDC redirect flow (what `GrafanaOidc`, `N8nOidc` and `RabbitMqOidc` exercise) needs no CORS entry. @@ -597,7 +597,7 @@ an ADR. ```mermaid flowchart LR - E["dependsOn
{service, surface, required}"] + E["dependsOn
{application, surface, required}"] E --> O1["Reconcile Unit ordering
apps-knowledge after apps-data"] E --> O2["dependency coordinates
${dependency:platform-postgres.host}"] @@ -613,20 +613,20 @@ flowchart LR ```mermaid flowchart LR - subgraph DEC["Declared, Service Intent (layer 1)"] - d_dom["domain"] + subgraph DEC["Declared, Project Intent (layer 1)"] + d_dom["project"] d_own["owner"] d_id["id"] - d_obs["observability
alertClass + scrape
{workload, surface, path}"] - d_wl["workload name"] - d_prov["provides
surface: port
on the Workload"] + d_obs["observability
alertClass + scrape
{process, surface, path}"] + d_wl["process name"] + d_prov["provides
surface: port
on the Process"] d_dep["dependsOn"] d_img["image"] d_run["runtime"] - d_env["env files
per Workload"] + d_env["env files
per Process"] d_sec["secrets
path, access, delivery"] d_ast["assets"] - d_exp["exposure, on the Service
name, host (authored FQDN),
audience, contentPolicy,
routes: path, match,
workload, surface"] + d_exp["exposure, on the Application
name, host (authored FQDN),
audience, contentPolicy,
routes: path, match,
process, surface"] d_prb["probes
readiness + liveness"] d_bud["startupBudget"] d_cut["cutover
rolling | recreate"] @@ -644,11 +644,11 @@ flowchart LR end subgraph DER["Derived, assignments and Deliverables (layers 2 and 3)"] - r_ns["namespace
domain-system"] + r_ns["namespace
project-system"] r_tier["route tier + middleware"] r_ru["Reconcile Unit + DAG"] - r_sw["switch gate
per Service"] - r_sa["identity name
the workload name"] + r_sw["switch gate
per Application"] + r_sa["identity name
the process name"] r_vp["Secret Store path grant"] r_dig["image digest"] r_rep["replicas"] @@ -662,7 +662,7 @@ flowchart LR r_bind["recorded PV binding"] k_dep["Deployment / StatefulSet / Job"] - k_svc["Service"] + k_svc["Application"] k_sa["ServiceAccount"] k_cm["ConfigMap"] k_sec["VaultStaticSecret / Secret"] @@ -775,7 +775,7 @@ flowchart LR ```mermaid flowchart LR - X["exposure, on the Service:
name: kb
host: kb.jorisjonkers.dev
audience: authenticated
routes: 5"] + X["exposure, on the Application:
name: kb
host: kb.jorisjonkers.dev
audience: authenticated
routes: 5"] X --> H["host, carried through
kb.jorisjonkers.dev"] X --> T["tier public-frankfurt
+ forward-auth middleware
derived from audience + tier"] @@ -783,7 +783,7 @@ flowchart LR H --> A1["IngressRoute (host)"] H --> A2["IngressRoute (mcp routes)"] H --> A3["reachability channel entry"] - H --> A4["edge catalog, an Asset of the Traefik Service"] + H --> A4["edge catalog, an Asset of the Traefik Application"] H --> A5["edge-route-catalog ConfigMap"] H --> A6["Gatus external endpoint"] T --> A1 diff --git a/spec/v1/20-resolved-deployment.md b/spec/v1/20-resolved-deployment.md index c5f8407..667c963 100644 --- a/spec/v1/20-resolved-deployment.md +++ b/spec/v1/20-resolved-deployment.md @@ -26,7 +26,7 @@ kind: ResolvedService # the projection published back to one repository not separable: the tier carrying each host, the Reconcile Unit DAG, inbound-edge derivations and the reader set of a Secret Store path are global properties ([chapter 16](16-dependencies.md)). `ResolvedService` is a **projection**: the -slice belonging to one Service, obtained by filtering and never computed +slice belonging to one Application, obtained by filtering and never computed separately, so the two cannot disagree about what was decided. The version is the data model's own semver, not the package's @@ -52,9 +52,9 @@ directory name. This repository already contains one resolved tree ( [Diagram source](#the-resolved-deployment-pinned-inputs-and-outputs) · edit by opening the SVG in draw.io -One domain file is one Intent Fragment -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)), so the input a -Service owner edits and the input composition unions are the same document. The +One project file is one Intent Fragment +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)), so the input a +Application owner edits and the input composition unions are the same document. The two node-facing inputs are deliberately drawn apart: what a node **can hold** is declared in the node contract and pinned with the Platform Intent; what the cluster **currently holds** is observed into the ClusterState snapshot. They @@ -70,26 +70,26 @@ change proves it did not. **A value is platform-arbitrated if and only if it must be unique across the estate or draws on a shared finite resource; every other value is -Service-declared and carried through untouched** +Application-declared and carried through untouched** ([0004](../../docs/adr/model/0004-contention-decides-authority.md)). One question ( does the value contend?) replaces a per-field negotiation. Three readings of the rule matter, and none is an exception to it: - **Contention decides who *arbitrates*, not who *authors*.** A contended value - does not silence the Service; it means the Service does not get the last word. - The Service states its requirement, and the platform decides whether it fits + does not silence the Application; it means the Application does not get the last word. + The Application states its requirement, and the platform decides whether it fits and where. Placement forced this reading and settles it. `memory` and `cpu` - are required on every Workload and authored there as raw quantities + are required on every Process and authored there as raw quantities ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)), and both are draws on a finite pool. An authors-only reading of the rule would have to forbid the field, which leaves the estate exactly where it is: BestEffort on - every pod, because a number no Service may write is a number nobody writes. + every pod, because a number no Application may write is a number nobody writes. The platform arbitrates against node `allocatable` published by the node contract ([0056](../../docs/adr/model/0056-node-facts-single-source.md)) and refuses what no node can hold with `E_PLACEMENT_UNSATISFIABLE`. - **Uniqueness alone is not contention.** A value that must be unique but is - drawn from no finite pool is *declared* by the Service and *checked* at + drawn from no finite pool is *declared* by the Application and *checked* at composition; there is nothing to arbitrate. A value drawn from a shared finite pool is *arbitrated*, and only the platform can arbitrate. The table's `placed by` column records which reading placed each row. @@ -103,7 +103,7 @@ Three readings of the rule matter, and none is an exception to it: The cost of the first reading is accepted and named here rather than discovered later: **nothing stops an author writing `memory: 8Gi`.** The rule places arbitration, not restraint, and the arbitration that exists today is a single -eligibility test against one node's allocatable. Every Workload in the estate +eligibility test against one node's allocatable. Every Process in the estate could claim 8Gi, every one of them would pass against `frankfurt-contabo-1`'s 32768Mi, and the only thing that would refuse is the scheduler, at apply, for whichever pods arrive last. That is [open item 5](#open-in-this-chapter). @@ -111,8 +111,8 @@ whichever pods arrive last. That is [open item 5](#open-in-this-chapter). The estate is the argument for having a rule at all. One hostname, `kb.jorisjonkers.dev`, ended up declared in seven authoritative places across three repositories (`homelab-inventory/catalog/reachability.yml`, three -`fleet-infra` manifests, a bearer-token secret and the service's own -`platform/deployment.yml`) plus hardcoded in `ServicePermission.kt`, with two +`fleet-infra` manifests, a bearer-token secret and the application's own +`platform/deployment.yml`) plus hardcoded in `ApplicationPermission.kt`, with two conformance tests existing for no purpose but detecting when the seven disagree. The guard was cheaper to write than the fix. @@ -120,80 +120,80 @@ The `placed by` column takes five values: | value | meaning | |---|---| -| `no contention` | the Service has the last word | -| `unique, checked` | estate-unique, declared by the Service; a collision is a build error | -| `unique, arbitrated` | estate-unique and drawn from no set the Service can see | +| `no contention` | the Application has the last word | +| `unique, checked` | estate-unique, declared by the Application; a collision is a build error | +| `unique, arbitrated` | estate-unique and drawn from no set the Application can see | | `pool` | a draw on a shared finite resource, decided by the platform | -| `pool, stated` | a draw on a shared finite resource the Service states and the platform arbitrates | +| `pool, stated` | a draw on a shared finite resource the Application states and the platform arbitrates | This table is the only place field authority is stated. Records needing a field's placement link to this anchor rather than copying rows. | field | authority | placed by | note | |---|---|---|---| -| `domain` | Service | no contention | the file header, and the unit of fragment publication ([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)); the namespace derives from it | -| `owner` | Service | no contention | the only field raised to the domain header; notification target, never routing | -| `id` | Service | unique, checked | estate-unique; `E_DUPLICATE_SERVICE_ID` at composition. It is also the atomic release boundary ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)) | -| `observability` `{alertClass, scrape}` | Service | no contention | urgency and the surface that carries the signal, per Service and never raised: a domain would page as loudly as its loudest member. Absent means no monitoring ([chapter 10](10-service-intent.md#observability)) | -| 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 | -| 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 | -| `exposure[].host` | Service | unique, checked | the full FQDN, authored: no label, no zone rule, no apex flag. Estate-unique across the composed union taken together with the register of unmanaged surfaces: `E_DUPLICATE_HOST` ([chapter 40](40-composition.md#identity)) | -| `exposure` `audience`, and a route's `audience` override | Service | no contention | one closed audience vocabulary; the per-route form is the anonymous path inside an authenticated host | -| `exposure[].contentPolicy` | Service | no contention | `strict`, `admin` or `workflow`. Which profile an application needs is a fact about the application; the header set it selects is derived | -| `exposure[].routes`: `path`, `match`, `workload`, `surface`, `redirectTo` | Service | no contention | which of the Service's own Workloads serves which path of the host. The surface must be one that Workload `provides` (`E_UNKNOWN_SURFACE`); no two routes may share a `path` + `match` pair (`E_DUPLICATE_ROUTE_MATCH`); `redirectTo` is a path, never a regex | -| `probes`, `startupBudget`, `cutover` | Service | no contention | what only the Service knows about its own start, health and cutover; `cutover` is required and has no default | -| `hardening` | platform | no contention | one estate-wide posture, `restricted`. A Workload authors no hardening at all: it 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)) | -| `volumes[].durability` | Service | no contention | what the data is worth cannot be observed | -| `placement.memory`, `placement.cpu` | Service | pool, stated | required on every Workload; the Service states the requirement, the platform arbitrates it against node allocatable | -| `placement.gpu` | Service | pool, stated | `class` and `memory`, matched against the node contract's `gpus[].class` and `gpus[].memory_mib`; a card is held by one Workload at a time | -| `placement.disk` | Service | pool, stated | a `media` set; it filters the first placement and the PV binding wins thereafter: `E_DISK_BINDING_CONFLICT` | -| `volumes[].size` | Service | pool, stated | how much data the volume holds, matched against the node contract's `disks[].usable_gib`; no eligible node is `E_STORAGE_UNSATISFIABLE` ([0081](../../docs/adr/model/0081-volume-size-is-a-hard-dimension.md)) | -| PVC capacity and the disk capacity filter | derived | - | the volume's `size`, and their sum per Workload for placement | -| `placement.arch`, `.site`, `.capabilities` | Service | no contention | filters over facts the node contract publishes; a list is a set of equally acceptable values, never a ranking | -| `writablePaths` | Service | no contention | which paths the process must write; the size of each is platform-assigned ([0092](../../docs/adr/model/0092-writable-paths-are-declared.md)) | -| `volumes[].durability` | Service | no contention | what losing the data costs; only the owner knows ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)) | -| `engine` | Service | no contention | what the process is, which the platform keys its backup method off ([0078](../../docs/adr/model/0078-engine-is-workload-vocabulary.md)) | -| `replicas` | Service | no contention | the sole local capacity exception: `count` above one with a required `reason` ([No overrides](#no-overrides)) | +| `project` | Application | no contention | the file header, and the unit of fragment publication ([0063](../../docs/adr/model/0063-intent-authored-per-project.md)); the namespace derives from it | +| `owner` | Application | no contention | the only field raised to the project header; notification target, never routing | +| `id` | Application | unique, checked | estate-unique; `E_DUPLICATE_APPLICATION_ID` at composition. It is also the atomic release boundary ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)) | +| `observability` `{alertClass, scrape}` | Application | no contention | urgency and the surface that carries the signal, per Application and never raised: a project would page as loudly as its loudest member. Absent means no monitoring ([chapter 10](10-project-intent.md#observability)) | +| process `name` | Application | unique, checked | unique within the **project**; `E_DUPLICATE_PROCESS_NAME`, and it names the derived identity | +| `provides` surface names and ports | Application | no contention | declared on the Process, because a port is a property of a process; written once, there | +| `dependsOn` edges | Application | no contention | provider, surface, necessity ([chapter 16](16-dependencies.md#dependency-edges)) | +| `image`, `runtime`, `lifecycle`, `stateful` | Application | no contention | what the Process is | +| env files, `assets` | Application | no contention | per Process; derived values appear only as placeholders | +| `secrets` grants: `path`, `keys`, `access`, `delivery`, `rotation` | Application | no contention to declare | per Application and never raised; the *path* is arbitrated (below), what an Application asks of a path is its own | +| `exposure[].name` | Application | unique, checked | required; unique **within the Application**, `E_DUPLICATE_EXPOSURE_NAME` at composition. It is the half `${exposure:.#url}` addresses | +| `exposure[].host` | Application | unique, checked | the full FQDN, authored: no label, no zone rule, no apex flag. Estate-unique across the composed union taken together with the register of unmanaged surfaces: `E_DUPLICATE_HOST` ([chapter 40](40-composition.md#identity)) | +| `exposure` `audience`, and a route's `audience` override | Application | no contention | one closed audience vocabulary; the per-route form is the anonymous path inside an authenticated host | +| `exposure[].contentPolicy` | Application | no contention | `strict`, `admin` or `workflow`. Which profile an application needs is a fact about the application; the header set it selects is derived | +| `exposure[].routes`: `path`, `match`, `process`, `surface`, `redirectTo` | Application | no contention | which of the Application's own Processes serves which path of the host. The surface must be one that Process `provides` (`E_UNKNOWN_SURFACE`); no two routes may share a `path` + `match` pair (`E_DUPLICATE_ROUTE_MATCH`); `redirectTo` is a path, never a regex | +| `probes`, `startupBudget`, `cutover` | Application | no contention | what only the Application knows about its own start, health and cutover; `cutover` is required and has no default | +| `hardening` | platform | no contention | one estate-wide posture, `restricted`. A Process authors no hardening at all: it 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)) | +| `volumes[].durability` | Application | no contention | what the data is worth cannot be observed | +| `placement.memory`, `placement.cpu` | Application | pool, stated | required on every Process; the Application states the requirement, the platform arbitrates it against node allocatable | +| `placement.gpu` | Application | pool, stated | `class` and `memory`, matched against the node contract's `gpus[].class` and `gpus[].memory_mib`; a card is held by one Process at a time | +| `placement.disk` | Application | pool, stated | a `media` set; it filters the first placement and the PV binding wins thereafter: `E_DISK_BINDING_CONFLICT` | +| `volumes[].size` | Application | pool, stated | how much data the volume holds, matched against the node contract's `disks[].usable_gib`; no eligible node is `E_STORAGE_UNSATISFIABLE` ([0081](../../docs/adr/model/0081-volume-size-is-a-hard-dimension.md)) | +| PVC capacity and the disk capacity filter | derived | - | the volume's `size`, and their sum per Process for placement | +| `placement.arch`, `.site`, `.capabilities` | Application | no contention | filters over facts the node contract publishes; a list is a set of equally acceptable values, never a ranking | +| `writablePaths` | Application | no contention | which paths the process must write; the size of each is platform-assigned ([0092](../../docs/adr/model/0092-writable-paths-are-declared.md)) | +| `volumes[].durability` | Application | no contention | what losing the data costs; only the owner knows ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)) | +| `engine` | Application | no contention | what the process is, which the platform keys its backup method off ([0078](../../docs/adr/model/0078-engine-is-process-vocabulary.md)) | +| `replicas` | Application | no contention | the sole local capacity exception: `count` above one with a required `reason` ([No overrides](#no-overrides)) | | route tier | platform | pool | the shared edge is finite; `E_NO_TIER_FOR_AUDIENCE` where no tier carries the audience | | route precedence | derived | - | `exact` before `prefix`, longer prefix before shorter; carried explicitly on the rendered route rather than left to the proxy's sort ([0093](../../docs/adr/model/0093-route-precedence-is-derived.md)) | | middleware chain | platform | pool | tier + audience + `contentPolicy`; `forward-auth` for `authenticated` on a public tier, the security-headers baseline with the named content profile, and the redirect rule a route's `redirectTo` asks for | | backup window, retention count, off-cluster destination | platform | pool | one policy per Durability Class; the window is one node's IO and the destination is one remote target ([0077](../../docs/adr/model/0077-durability-derives-a-backup.md)) | | the backup method | platform | pool | the image the Platform document names per `engine`, resolved through the images lock; nothing executable is authored ([chapter 14](14-platform-intent.md#engines)) | -| alert rules, their severity and their receiver | the monitoring stack | pool | derived nowhere in this model. `alertClass` is published as a resolved fact and the stack that reads it decides what a class means ([chapter 10](10-service-intent.md#observability)) | +| alert rules, their severity and their receiver | the monitoring stack | pool | derived nowhere in this model. `alertClass` is published as a resolved fact and the stack that reads it decides what a class means ([chapter 10](10-project-intent.md#observability)) | | monitor `interval` and `timeout` | platform | pool | the metrics stack's ingest budget is shared, so it is one estate-wide value in the Platform document ([chapter 14](14-platform-intent.md#monitor-cadence)) | | the backup identity's grant on the destination | platform | pool | derived, never authored: the platform chose the destination, so it owns the credential | | Reconcile Unit and its ordering | platform | unique, arbitrated | one estate-wide DAG ([The Reconcile Unit](#the-reconcile-unit)) | -| identity name, Vault role, Vault policy | platform | pool | named for the **Workload alone**; the auth role namespace is shared ([chapter 16](16-dependencies.md#workload-identity)) | +| identity name, Vault role, Vault policy | platform | pool | named for the **Process alone**; the auth role namespace is shared ([chapter 16](16-dependencies.md#process-identity)) | | Secret Store path layout and grants | platform | pool | one path per reader set; `E_SUBTREE_PREFIX_COLLISION` across Subtrees ([chapter 40](40-composition.md#identity)) | | image digest | platform | unique, arbitrated | one image reference resolves to one digest estate-wide, from the pinned images lock | | eligible node set, `nodeSelector` and affinity | platform | pool | every declared dimension matched against the node contract; no eligible node is `E_PLACEMENT_UNSATISFIABLE` ([Derived mechanics](#derived-mechanics)) | | recorded PV binding | platform | pool | one `local-path` PV lives on one node; read from the ClusterState snapshot | | `replicas` | derived | - | **1**; more than one is the `replicas: {count, reason}` declaration ([0089](../../docs/adr/model/0089-replicas-derived-no-minavailable.md)) | | `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-domain.md)); several Services share one by construction | +| `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 | | `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 Workload holds a volume ([0082](../../docs/adr/model/0082-images-lock-carries-uid-and-gid.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)) | | 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` | -| 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)) | +| the Application's release-gate deadline | derived | - | `max` over the Application's Processes of `progressDeadlineSeconds` ([The release gate](#the-release-gate)) | +| the object label set | derived | - | fixed, from Process name, Application Id and the images lock ([chapter 10](10-project-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-project-intent.md#zero-downtime-rotation)) | | an Asset's object name, and the restart it causes | derived | - | content-hashed unconditionally; there is no authored change response ([0094](../../docs/adr/model/0094-asset-change-restarts-unconditionally.md)) | -| env entries and `envFrom` refs | derived | - | from env files, after placeholder resolution, including `${identity:…}`, the Workload's own derived facts ([0091](../../docs/adr/model/0091-identity-placeholders-not-framework-wiring.md)) | +| env entries and `envFrom` refs | derived | - | from env files, after placeholder resolution, including `${identity:…}`, the Process's own derived facts ([0091](../../docs/adr/model/0091-identity-placeholders-not-framework-wiring.md)) | | dependency coordinates | derived | - | from the edge set and the provider's surfaces, bound to the key the consumer chose | | Runtime Profile values | derived | - | from `runtime` | | ServiceMonitor, PodMonitor | derived | - | target and port name from `observability.scrape` and the named surface in `provides`; cadence from the Platform document | -| PrometheusRule, severity, receiver route | the monitoring stack | - | not rendered by this model. PromQL is a mechanism and a receiver is a shared channel ([chapter 10](10-service-intent.md#observability)) | +| PrometheusRule, severity, receiver route | the monitoring stack | - | not rendered by this model. PromQL is a mechanism and a receiver is a shared channel ([chapter 10](10-project-intent.md#observability)) | | backup job and retention sweep | derived | - | from `volumes[].durability`; `reconstructible` renders none | | NetworkPolicy set | derived | - | from the edge set, exposure, grants, plus the baseline ([chapter 16](16-dependencies.md#network-policy)) | @@ -203,20 +203,20 @@ amendment to the rule, never an exceptions row in this table. ### The hostname changed sides -Until this amendment the table carried two rows for one value: a Service-declared +Until this amendment the table carried two rows for one value: an Application-declared *label*, and a platform-arbitrated *fully-qualified hostname* assembled from that label, the tier's hostname policy and the cluster domain. There is no such assembly to run. `knowledge` serves `kb`, `platform-rabbitmq` serves `rabbitmq`, `knowledge.jorisjonkers.dev` and `kb.jorisjonkers.dev` both resolve, and `root`, -`status`, `dashboard` and `faro` belong to no Service at all, and +`status`, `dashboard` and `faro` belong to no Application at all, and so a hostname policy would be right for most hosts and silently wrong for the rest, and the wrong ones are the ones nobody would check. `host` is therefore -authored in full on the Service's `exposure` entry and carried through untouched +authored in full on the Application's `exposure` entry and carried through untouched ([0018](../../docs/adr/model/0018-exposure-by-audience.md)); both old rows are gone, replaced by one. That is the rule's second reading, not an exception to it. A hostname must be -unique across the estate and draws on no pool the platform holds, so the Service +unique across the estate and draws on no pool the platform holds, so the Application declares it and the **uniqueness check is arbitrated at composition**: `E_DUPLICATE_HOST` over the composed union taken together with the Registered Unmanaged Surfaces ([chapter 40](40-composition.md#identity)). Nobody's fragment @@ -229,25 +229,25 @@ What stays on the platform side of this path is everything mechanical about the edge: the tier that carries the audience, and the middleware chain that follows from the tier, the audience and `contentPolicy`. The authored proxy vocabulary is exactly two fields (`contentPolicy` on an exposure and `redirectTo` on a route) -and no Service names a middleware, a listener or a certificate issuer. +and no Application names a middleware, a listener or a certificate issuer. ### The namespace row was wrong, and this is the correction -Until this amendment the table derived `namespace` from `id`, with a per-Service +Until this amendment the table derived `namespace` from `id`, with a per-Application exception field that could name a different namespace and record a reason. Both halves of that rule are retired, because the rule was wrong about this estate. -Namespaces here have never been per Service. They have always been per domain, +Namespaces here have never been per Application. They have always been per project, and there are ten of them (`auth-system`, `data-system`, `knowledge-system`, `app-system`, `agents-system`, `mail-system`, `media-system`, `notes-system`, -`automation-system`, `utility-system`) each of which equals `-system` -today. Deriving from `domain` renames nothing and moves no live object. +`automation-system`, `utility-system`) each of which equals `-system` +today. Deriving from `project` renames nothing and moves no live object. The exception field existed only because the rule pointed at the wrong input. `home-portal` is the repository and the product, so the id rule derives -`home-portal-system`: a namespace that does not exist and never has. The Service -runs in `app-system`, because its domain is `app`. Once the derivation reads -`domain`, `app-system` falls out directly and there is nothing left for an +`home-portal-system`: a namespace that does not exist and never has. The Application +runs in `app-system`, because its project is `app`. Once the derivation reads +`project`, `app-system` falls out directly and there is nothing left for an exception to express, which is why the field is deleted rather than narrowed. Two consequences follow, and both are now the normal case rather than a @@ -255,20 +255,20 @@ footnote to an exception: - **`namespace` is derived, not arbitrated.** It leaves the platform half of this table. There is no pool to draw from and no collision to resolve, because - a namespace is shared on purpose. A Service owner can therefore read their own + a namespace is shared on purpose. An Application owner can therefore read their own namespace out of their own file, which is the one decision [Publish back](#publish-back) no longer has to tell them about. -- **A namespace is not a trust boundary.** It holds several Services by +- **A namespace is not a trust boundary.** It holds several Applications by construction, so no isolation claim may rest on a namespace wall. Isolation is the derived default-deny edge set ([0035](../../docs/adr/model/0035-network-policy-default-deny.md)), evaluated per - pod, plus per-Workload identity - ([0024](../../docs/adr/model/0024-identity-per-workload.md)), and nothing else. + pod, plus per-Process identity + ([0024](../../docs/adr/model/0024-identity-per-process.md)), and nothing else. ## Pinned inputs > **Every assignment is a pure function of the pinned input set: every Intent -> Fragment (the domain files and the Platform document +> Fragment (the project files and the Platform document > ([chapter 14](14-platform-intent.md)), the node contract the Platform document > names, the locks, and the ClusterState snapshot) each carried by digest.** > Identical inputs, identical output, always. @@ -276,7 +276,7 @@ footnote to an exception: The set is **closed**. No assignment consults live cluster state, a mutable pool, a counter, or state remembered between renders. There is no allocation registry and no assignment state, which is why `renderHash` means something and -why publishing assignments back to a service repository cannot drift. +why publishing assignments back to a project repository cannot drift. Placement is the case that tests the rule hardest, and it stays inside it. Every declared dimension is matched against node `allocatable`, the node's @@ -326,7 +326,7 @@ premise and must be fixed in the renderer before the gate is trusted. ## Cluster state Some assignments need facts the cluster alone can supply: which node holds a -bound PersistentVolume, and where a Workload currently runs. Those facts are +bound PersistentVolume, and where a Process currently runs. Those facts are captured **once**, by a read-only collector, into a snapshot that is digested and pinned like every other input ([0034](../../docs/adr/model/0034-cluster-state-pinned-input.md)). Assignments read @@ -334,7 +334,7 @@ the snapshot. Nothing reads the live cluster. | the snapshot enumerates | used by | |---|---| -| PersistentVolume bindings, with the node holding each | recording where a Workload's data already sits; `E_DISK_BINDING_CONFLICT` where a declared `disk` dimension contradicts the binding | +| PersistentVolume bindings, with the node holding each | recording where a Process's data already sits; `E_DISK_BINDING_CONFLICT` where a declared `disk` dimension contradicts the binding | | current placements | detecting a move before it is rendered | **What a node can hold is not on that list.** `allocatable`, `site`, `arch`, @@ -349,10 +349,10 @@ gets fixed. The distinction is not bookkeeping. Observed capacity is free capacity, and free capacity is a function of whatever else was scheduled when the collector ran: -the same Workload would be eligible at 03:00 and ineligible at 09:00 with no +the same Process would be eligible at 03:00 and ineligible at 09:00 with no input of its own changed, and the build result would depend on the hour. Matching declared requirements against declared allocatable is -**eligibility, not bin-packing**: three Workloads each declaring `memory: 2Gi` +**eligibility, not bin-packing**: three Processes each declaring `memory: 2Gi` all pass against a 4096Mi node, because each is compared against allocatable alone. The scheduler refuses the third at apply. That is the accepted cost of keeping the answer a pure function of pinned inputs, and it is why the estate @@ -406,22 +406,22 @@ snapshot's age is on the artifact. ## Derived mechanics -A Service declares what only it can know (its cold-start budget, whether its +An Application declares what only it can know (its cold-start budget, whether its next cutover must keep serving, which paths answer readiness and liveness, what a volume's data is worth, what it can survive when an input changes) and what -only it can state: how much memory and cpu each of its Workloads needs. Probe +only it can state: how much memory and cpu each of its Processes needs. Probe timings, rollout strategy, surge and unavailability, progress deadlines, health timeout classes, object kind, resource requests and limits, pod hardening, backup jobs and retention sweeps all follow ([0030](../../docs/adr/model/0030-runtime-mechanics-derived.md)). **None of the derived values may be authored**, and writing one in an env file -or a Service document is a build error ([chapter 10](10-service-intent.md)). +or an Application document is a build error ([chapter 10](10-project-intent.md)). The rollout configuration is the evidence. All four first-party deployments carry the same pattern (`RollingUpdate` with `maxSurge: 1` and `maxUnavailable: 0`, `startupProbe` at `periodSeconds: 5` and `failureThreshold: 120`, readiness and liveness at `timeoutSeconds: 5`, and -`progressDeadlineSeconds: 1800` on the three JVM services) and the comments +`progressDeadlineSeconds: 1800` on the three JVM applications) and the comments record what it cost to arrive there: *"under `Recreate` every image roll opened a zero-pod window, so a slow cold start or a flaky ghcr image pull took the MCP fully down (503)"*; *"JVM cold start (~250–300 s); the 600 s startupProbe budget @@ -431,18 +431,18 @@ hand, with the reasoning trapped in comments no tool can read. Five rules carry most of the weight: - **Strategy is a function of `cutover` and volumes, not a preference.** A - `ReadWriteOnce` volume cannot attach to two pods at once, so a Workload - holding one cannot surge, and a Workload that declares `cutover: rolling` + `ReadWriteOnce` volume cannot attach to two pods at once, so a Process + holding one cannot surge, and a Process that declares `cutover: rolling` over one is refused with `E_CUTOVER_UNHONOURABLE` rather than silently rendered as `Recreate`. Estate-wide the split is 21 `Recreate` to 9 `RollingUpdate`, and every RWO holder is on the `Recreate` side. The renderer today reads an authored enum (`src/adapters/kubernetes.ts:608`) and inspects - no volume, which is a trap: a stateful Workload whose author forgets + no volume, which is a trap: a stateful Process whose author forgets `strategy: recreate` gets `maxSurge: 1` against an RWO volume, appears to work on one node, and wedges the first time a second worker exists. Under `cutover`, that forgetting is impossible: the two declarations are checked against each other at composition, and the contradiction is a build error - naming the Workload and the volume. + naming the Process and the volume. - **The progress deadline must exceed the startup budget, strictly.** It derives as budget × 3, floored. The current renderer emits `600` against a 600-second budget, so a JVM still inside its legitimate startup window is marked @@ -450,29 +450,29 @@ Five rules carry most of the weight: - **There is no health timeout class.** The generation being replaced carried a table over declarations (`stateless: 5m`, `stateful: 10m`, `control-plane: 15m`, `job: 10m` - (`src/schemas/health-timeout-map.ts:1-6`), strongest class across a Service) + (`src/schemas/health-timeout-map.ts:1-6`), strongest class across an Application) and it is a second derivation over the same input as `progressDeadlineSeconds`. The two already disagree: `auth-api` declares a 600-second `startupBudget`, derives an 1800-second deadline, and its class - gives up at 5 minutes on a Workload the model says may legitimately take ten. - One input has one derivation, and the Service-scoped number that a switchover + gives up at 5 minutes on a Process the model says may legitimately take ten. + One input has one derivation, and the Application-scoped number that a switchover waits on is the release-gate deadline below. - **Durability derives objects, not just a label.** A volume of class `recoverable` derives a backup `CronJob` and a retention sweep; `irreplaceable` derives both plus an off-cluster copy and a derived grant for the destination; `reconstructible` derives nothing. The schedule, retention and destination come - from the platform's per-class policy and the method from the Workload's - `engine`, so two Services of the same class and engine derive the same objects + from the platform's per-class policy and the method from the Process's + `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` ( `runAsNonRoot`, `readOnlyRootFilesystem`, all capabilities dropped, seccomp `RuntimeDefault`) is declared once in the Platform document and authored by no - Workload; each declared exception names one control and carries a reason + Process; each declared exception names one control and carries a reason ([0016](../../docs/adr/model/0016-pod-hardening.md)). - **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 - Workload declares, under two shape rules the author does not write. Memory + Process declares, under two shape rules the author does not write. Memory request **equals** memory limit, because memory is incompressible and an OOM kill beats eviction roulette. Cpu is a request with **no** limit, because throttling gets misdiagnosed as slow application code @@ -485,14 +485,14 @@ Neither hardening nor resources exists in either renderer today: src/ schemas/` returns 0 hits, so every rendered pod runs as its image's UID with a writable root and no reservation at all: BestEffort is the estate's standing QoS class, and ending that is what `memory` and `cpu` being required -on every Workload buys. +on every Process buys. ### Layer 2 does not assign a node Earlier drafts said layer 2 decides "which node". That is wrong. Kubernetes schedules pods; the platform only constrains where they may land. Layer 2 -computes an **eligible node set** from the placement dimensions the Workload -declared ([chapter 10](10-service-intent.md#placement)) matched against the node +computes an **eligible node set** from the placement dimensions the Process +declared ([chapter 10](10-project-intent.md#placement)) matched against the node contract, and emits a **selector and an affinity** that express it. Every dimension is hard. A list is a set of equally acceptable values ( `arch: @@ -524,11 +524,11 @@ the address of the endpoint that authenticates for it, beside the audiences it serves; a tier that serves no `authenticated` route carries no such field and needs none. -It is not derived from the authenticating Service's own surface. `auth-api`'s +It is not derived from the authenticating Application's own surface. `auth-api`'s estate-wide role *is* this middleware -([chapter 10](10-service-intent.md#service-identity)), and resolving it as if it -were a dependency edge would make the edge tree depend on resolving a Service -and would write one Service's id into a platform derivation. It is a platform +([chapter 10](10-project-intent.md#application-identity)), and resolving it as if it +were a dependency edge would make the edge tree depend on resolving an Application +and would write one Application's id into a platform derivation. It is a platform fact, so it sits where platform facts sit: the Platform Intent, pinned by digest ([Pinned inputs](#pinned-inputs)). @@ -538,19 +538,19 @@ rather than discovered as a 500 at the edge. ## The release gate -A Service is the Release Unit, and no member's new version receives traffic +An Application is the Release Unit, and no member's new version receives traffic until every member's new version is healthy ([chapter 50](50-lifecycle.md#release-unit-switchover)). *Performing* the switch belongs to delivery, which is defined separately. What the model owes is the gate's **inputs**, and it owes them as a derivation rather than as an object ([0071](../../docs/adr/model/0071-release-gate-inputs-are-layer-2.md)). -Layer 2 therefore carries, per Service: +Layer 2 therefore carries, per Application: | field | derived from | |---|---| -| the member list | the Service's Workloads; membership is structural | -| each member's readiness reference | that Workload's `probes.readiness`: its `path` + `port`, or its `tcp` port | +| the member list | the Application's Processes; membership is structural | +| each member's readiness reference | that Process's `probes.readiness`: its `path` + `port`, or its `tcp` port | | the gate deadline | `max` over the members of `progressDeadlineSeconds`, itself `startupBudget × 3` | `max` is the reading "held, not partial" requires: the unit waits for its @@ -560,15 +560,15 @@ because a UI that is ready in 30 seconds must still not receive traffic while the API it talks to is inside its own legitimate startup window. **Nothing is rendered for the gate.** The inputs live in the Resolved -Deployment and in each Service's projection, which is where decisions live and +Deployment and in each Application's projection, which is where decisions live and where a delivery mechanism reading a pinned lock already looks ([0006](../../docs/adr/model/0006-pinned-inputs.md)). Layer 3 emits the fixed -label set ([chapter 10](10-service-intent.md#the-label-set)) and nothing else on -the Service's behalf: an object no controller consumes is the defect +label set ([chapter 10](10-project-intent.md#the-label-set)) and nothing else on +the Application's behalf: an object no controller consumes is the defect `app.kubernetes.io/instance` already is, and rendering a second one would not make the gate real. -A Service whose Workloads all declare `probes: none` publishes no readiness +An Application whose Processes all declare `probes: none` publishes no readiness signal and cannot be gated: `E_RELEASE_UNIT_NO_READINESS`, checked at composition time ([chapter 40](40-composition.md#completeness)), not discovered by a delivery mechanism at apply time. @@ -588,13 +588,13 @@ answerable for the field. A decision taken while serialising appears in no schema, is recorded in no lock, and is invisible in the projection its owner reads back. -Two live cases show that the alternative does not work. A per-domain object ( +Two live cases show that the alternative does not work. A per-project object ( `namespace.yaml`, and the namespace-wide default-deny) is one object per -domain, while an Adapter keyed off the Service emits one directory per Service: -`auth` has one Service and nothing collides, `data` has three and produces three +project, while an Adapter keyed off the Application emits one directory per Application: +`auth` has one Application and nothing collides, `data` has three and produces three identical Namespace objects at three paths. And an estate-scoped Deliverable, the Gatus endpoints ConfigMap, lands in `utility-system` rather than in the -namespace of the Service that motivated it. Under an adapter-computed path both +namespace of the Application that motivated it. Under an adapter-computed path both are accidents of who ran last; under a path plan both are assignments, with one owner and a recorded reason. @@ -613,7 +613,7 @@ chapter is restated with a reason, and there is no `E_UNKNOWN_OVERRIDE` because there is no key set to fall outside. The one local exception is capacity, and it is a named field rather than a hatch -([chapter 10](10-service-intent.md#capacity)): +([chapter 10](10-project-intent.md#capacity)): ```yaml replicas: @@ -633,19 +633,19 @@ threshold) so an owner who needed 600 and could not say so would declare a then used to license more than it justified. The historic case is `app-ui`: `progressDeadlineSeconds: 600` against the three -JVM services' `1800`, justified as *\"nginx pods, ~10–20Mi RAM each\"*. That is +JVM applications' `1800`, justified as *\"nginx pods, ~10–20Mi RAM each\"*. That is not evidence of a value only its owner could know; it is evidence that one rule -over `startupBudget` was wrong for a whole workload class. A JVM cold start and a +over `startupBudget` was wrong for a whole process class. A JVM cold start and a static-bundle start differ by **two orders of magnitude**, and the correct -response is a rule that reads an input the Workload already declares, not a -per-Workload exception carrying a number the rule should have produced. +response is a rule that reads an input the Process already declares, not a +per-Process exception carrying a number the rule should have produced. So the classification, and the evidence it rests on: | old override key | what it actually was | where it went | |---|---|---| | `replicas` | irreducible local capacity knowledge | the named `replicas: {count, reason}` field, the one survivor | -| `startupDeadline` | a workload class (`runtime: static` starts in seconds, `jvm` in minutes) | repaired central rule over `startupBudget` and `runtime` | +| `startupDeadline` | a process class (`runtime: static` starts in seconds, `jvm` in minutes) | repaired central rule over `startupBudget` and `runtime` | | `gateDeadline` | `max` over members, already a derivation, never a decision | derived, unchanged | | `automountToken` | a derivation from `delivery: self` | derived, unchanged ([0087](../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md)) | | `ephemeralSize`, `probeCadence`, `backupTerms`, `monitorCadence` | platform policy over shared resources | platform, stated once | @@ -656,24 +656,24 @@ So the classification, and the evidence it rests on: as the classification, not as a settled multiplier: the estate's actual deadline differences and their rollout evidence must be inventoried before the corrected rule is selected and tested. Until that inventory passes, the single -`startupBudget × 3` rule stands and no Workload restates it. +`startupBudget × 3` rule stands and no Process restates it. Two properties were bought by the hatch and are kept without it. A wrong derivation is now visible as a wrong render for a whole class rather than hidden -behind a per-Workload reason, which is what makes it fixable. And no value has +behind a per-Process reason, which is what makes it fixable. And no value has two declaring sites, so chapter 16's single-authority property runs over every surface with no exemption for hand-tuning. ### `namespace` is still not restatable -`-system` has exactly one input, the author writes it, and an author who -wants a different namespace changes `domain`: one edit, in the open, which moves -the Service to another file and another fragment. A second way to say where a -Service lives is a second record of one fact, and it drifts. +`-system` has exactly one input, the author writes it, and an author who +wants a different namespace changes `project`: one edit, in the open, which moves +the Application to another file and another fragment. A second way to say where a +Application lives is a second record of one fact, and it drifts. ### Hardening has no exception surface either -The posture is platform policy and a Workload authors none of it. There is no +The posture is platform policy and a Process authors none of it. There is no per-control relaxation carried with a reason, because that is an override under another name and it outlives the image that justified it. An image that cannot meet `restricted` is `E_HARDENING_UNMET`; the fix is the image, or a @@ -683,10 +683,10 @@ Bidirectional Ledger entry with an owner while it is replaced ## The Reconcile Unit The Reconcile Unit is **derived from the dependency graph**, never declared -([0032](../../docs/adr/model/0032-reconcile-unit-derived.md)). A Service's unit is -`apps-`; the ordering between units is the edge set of -[chapter 16](16-dependencies.md#dependency-edges) projected onto domains, plus an -edge to the secrets-provisioning unit wherever a Service holds any grant. +([0032](../../docs/adr/model/0032-reconcile-unit-derived.md)). An Application's unit is +`apps-`; the ordering between units is the edge set of +[chapter 16](16-dependencies.md#dependency-edges) projected onto projects, plus an +edge to the secrets-provisioning unit wherever an Application holds any grant. ![The Reconcile Unit DAG](diagrams/20-reconcile-unit-dag.drawio.svg) @@ -694,13 +694,13 @@ edge to the secrets-provisioning unit wherever a Service holds any grant. An arrow means *must be Ready first*. `apps-knowledge` follows `apps-data` because `knowledge` depends on `platform-postgres` and `platform-rabbitmq`; -`apps-agents` follows `apps-knowledge` because the agent services consume +`apps-agents` follows `apps-knowledge` because the agent applications consume `knowledge`, and follows `apps-vso-secrets` because they hold grants: a -Workload cannot start before the credential it holds is materialised. That is +Process cannot start before the credential it holds is materialised. That is the fourteen-node graph `fleet-infra` maintains by hand today, rendered instead. -`platform.layer` is **deleted from Service Intent**. It was a free-form string -typed `"type": "string"` with no enumeration; every Service declared `apps-core` +`platform.layer` is **deleted from Project Intent**. It was a free-form string +typed `"type": "string"` with no enumeration; every Application declared `apps-core` and not one reconciled there: `auth-api`, `agents-api` and `app-ui` land in `apps-stateless`, `knowledge` in `apps-knowledge`, `agent-runtime` in `apps-agents`. A field wrong in 100% of observed cases at no cost is a comment, @@ -710,22 +710,22 @@ two units at once, and one string cannot name two. What survives is optional and in, useful only for diffing observation against derivation, never authored, and not part of the pinned snapshot. -Two consequences are the price. A Service owner cannot pin their reconcile +Two consequences are the price. An Application owner cannot pin their reconcile position; a wrong order is fixed by correcting the dependency declaration that -produced it. And a Service's objects may split across units with nothing +produced it. And an Application's objects may split across units with nothing declaring that they do, as `agents-login`'s do, which is what makes a partially -applied Service hard to read. A dependency cycle becomes a build failure +applied Application hard to read. A dependency cycle becomes a build failure (`E_DEPENDENCY_CYCLE`) rather than a reconcile deadlock. **The Reconcile Unit orders; it does not make anything atomic.** Ordering is -derived from the graph. Atomicity is the **Service boundary itself** -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)): every Workload of -one Service switches together or none switches, and there is no mechanism to -couple two Services. The two are orthogonal: postgres before knowledge is +derived from the graph. Atomicity is the **Application boundary itself** +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)): every Process of +one Application switches together or none switches, and there is no mechanism to +couple two Applications. The two are orthogonal: postgres before knowledge is ordering; `auth-api` and `auth-ui` moving together is atomicity, and they move -together because they are two Workloads of one Service, not because they agree +together because they are two Processes of one Application, not because they agree on a name declared in two repositories. A pair that must release together and -cannot be one Service is not a missing feature; it is evidence the Service +cannot be one Application is not a missing feature; it is evidence the Application boundary is drawn wrong. The derived unit has one consumer in v1: the Flux `Kustomization` DAG, whose @@ -734,25 +734,25 @@ push-based applier would do with the same ordering (apply its slice layer by layer) belongs to the separately-defined delivery work in [docs/adr/deferred/](../../docs/adr/deferred/README.md), along with everything else about how objects reach a cluster, and with the mechanism that makes a -Service's switchover all-or-nothing. The derivation does not change if that +Application's switchover all-or-nothing. The derivation does not change if that consumer is ever added; only the number of consumers does. ## Publish back -Because contended values are platform-arbitrated, a Service owner cannot read +Because contended values are platform-arbitrated, an Application owner cannot read their own node placement or Secret Store paths out of their own repository. -Composition therefore writes each Service's `ResolvedService` projection into -that Service's repository as a generated file ( +Composition therefore writes each Application's `ResolvedService` projection into +that Application's repository as a generated file ( `platform/resolved.yml`) and opens a pull request when it changes ([0033](../../docs/adr/model/0033-assignments-published-back.md)). -The projection carries, per Workload, the **image it runs at the digest the lock +The projection carries, per Process, the **image it runs at the digest the lock resolved**, the image metadata the previous generation rendered as a separate document. That is a layer-2 fact and it is published back like every other ([0098](../../docs/adr/model/0098-one-publication-path.md)); nothing about it was ever a Deliverable. -Two entries left this list. The namespace is now derived from `domain`, which +Two entries left this list. The namespace is now derived from `project`, which the owner writes in the header of the file they are already editing, so answering "which namespace am I in" needs no published assignment at all. The hostname followed it for another reason: `host` is authored, so the owner reads @@ -791,40 +791,40 @@ arrives as a review request. ## Worked example: knowledge's projection ```yaml -# services/knowledge/platform/resolved.yml +# applications/knowledge/platform/resolved.yml # GENERATED. Never hand-edit. Written by compose; guarded by a drift check. apiVersion: resolved.jorisjonkers.dev/v1 kind: ResolvedService -domain: knowledge -service: knowledge +project: knowledge +application: knowledge provenance: renderHash: sha256:… contextRef: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:… inputDigests: - intent: sha256:… # the knowledge domain file + intent: sha256:… # the knowledge project file imagesLock: sha256:… clusterState: sha256:… # the snapshot the bindings below were read from assigned: - namespace: knowledge-system # -system, derived, not arbitrated + namespace: knowledge-system # -system, derived, not arbitrated reconcileUnit: apps-knowledge reconcileAfter: [apps-core, apps-data, apps-vso-secrets] - healthTimeoutClass: stateful # 10m, strongest class across the two Workloads + healthTimeoutClass: stateful # 10m, strongest class across the two Processes - exposure: # on the Service: one host, its routes + exposure: # on the Application: one host, its routes public: host: knowledge.jorisjonkers.dev # authored; carried through untouched tier: public-frankfurt # arbitrated: the tier carrying `authenticated` middleware: [forward-auth] # derived from audience + tier; the # anonymous routes render without it routes: # five authored, two shown - - {path: /mcp, match: exact, workload: knowledge-api, surface: http, audience: anonymous} - - {path: /, match: prefix, workload: knowledge-api, surface: http} + - {path: /mcp, match: exact, process: knowledge-api, surface: http, audience: anonymous} + - {path: /, match: prefix, process: knowledge-api, surface: http} - workloads: + processes: knowledge-api: - serviceAccount: knowledge-api # the Workload name alone + applicationAccount: knowledge-api # the Process name alone objectKind: Deployment image: ghcr.io/jorisjonkers-dev/knowledge/knowledge-api@sha256:1ad39d5… probes: @@ -850,7 +850,7 @@ assigned: - {kind: VaultStaticSecret, path: secret/data/platform/postgres/kb} knowledge-ingest-worker: - serviceAccount: knowledge-ingest-worker + applicationAccount: knowledge-ingest-worker objectKind: Deployment strategy: {type: Recreate} # forced: RWO volume resources: @@ -866,9 +866,9 @@ assigned: Four things in that block are worth reading closely. -`exposure` sits beside `workloads:`, not inside one, because it belongs to the -Service ([0018](../../docs/adr/model/0018-exposure-by-audience.md)): a host fronts -Workloads, and the routes under it are how it picks between them. The projection +`exposure` sits beside `processes:`, not inside one, because it belongs to the +Application ([0018](../../docs/adr/model/0018-exposure-by-audience.md)): a host fronts +Processes, and the routes under it are how it picks between them. The projection records the entry even though the owner authored `host` and every route themselves, because the two lines they did not write are the ones worth a pull request: `tier` and `middleware`, which change when the platform's edge changes @@ -876,9 +876,9 @@ and not when the `knowledge` repository does. A route carrying `audience: anonymous` derives a different chain from the same host, which is the whole purpose of the override. -`placement.declared` echoes back what the Workload authored, beside what the +`placement.declared` echoes back what the Process authored, beside what the platform did with it. That is the whole of [Authority](#authority)'s first -reading on one screen: the requirement is the Service's, the eligible set and +reading on one screen: the requirement is the Application's, the eligible set and the binding are the platform's, and the diff shows both moving. Where the two disagree (a `disk` dimension the bound node no longer satisfies) composition fails with `E_DISK_BINDING_CONFLICT` rather than re-placing. @@ -891,14 +891,14 @@ again. Re-rendering with the same digests reproduces both byte for byte; a rebound volume produces a different `clusterStateDigest` and therefore a new lock, which someone lands deliberately. -Two identities appear because `knowledge` has two Workloads -([chapter 16](16-dependencies.md#workload-identity)), and each is named for its -Workload alone: `knowledge-system.knowledge-api`, never -`knowledge-system.knowledge-knowledge-api`. There is no Service prefix, and no -collapsing rule for a single-Workload Service, a Service with one Workload -shows that Workload's name, which may or may not equal the Service id. The -uniqueness the prefix used to guarantee now comes from the domain file, where -two Workloads may not share a name (`E_DUPLICATE_WORKLOAD_NAME`). +Two identities appear because `knowledge` has two Processes +([chapter 16](16-dependencies.md#process-identity)), and each is named for its +Process alone: `knowledge-system.knowledge-api`, never +`knowledge-system.knowledge-knowledge-api`. There is no Application prefix, and no +collapsing rule for a single-Process Application, an Application with one Process +shows that Process's name, which may or may not equal the Application id. The +uniqueness the prefix used to guarantee now comes from the project file, where +two Processes may not share a name (`E_DUPLICATE_PROCESS_NAME`). ## Open in this chapter @@ -907,7 +907,7 @@ two Workloads may not share a name (`E_DUPLICATE_WORKLOAD_NAME`). it (`knowledge` serves `kb`, `auth-api` serves `auth`, `headlamp` `dashboard`, `gatus` `status`, `agents-api` two) and the conclusion drawn from it is that nothing derives a hostname at all: `host` is the full FQDN, - authored on the Service's `exposure` entry + authored on the Application's `exposure` entry ([0018](../../docs/adr/model/0018-exposure-by-audience.md)), placed *unique, checked* above, and arbitrated only as a collision at composition (`E_DUPLICATE_HOST`). No third category was needed and no row of the table is @@ -918,8 +918,8 @@ two Workloads may not share a name (`E_DUPLICATE_WORKLOAD_NAME`). needs none. With `host` authored in full and no zone derivation anywhere, `home-portal` writes `host: jorisjonkers.dev` exactly as `auth` writes `host: auth.jorisjonkers.dev`; `apex: true` is not vocabulary - ([0018](../../docs/adr/model/0018-exposure-by-audience.md)). Two Services writing - the bare domain are one duplicated host like any other + ([0018](../../docs/adr/model/0018-exposure-by-audience.md)). Two Applications writing + the bare project are one duplicated host like any other ([chapter 40](40-composition.md#identity)), which is what `E_DUPLICATE_APEX` names when the contested name is that one. 3. **The drift check's failure mode for upstream-caused staleness.** A @@ -945,7 +945,7 @@ two Workloads may not share a name (`E_DUPLICATE_WORKLOAD_NAME`). observed. **Blocks:** trusting `E_PLACEMENT_UNSATISFIABLE` on the two small nodes. 5. **Nothing arbitrates a declared requirement.** `memory` and `cpu` are stated - by the Service and arbitrated by the platform, but the arbitration today is + by the Application and arbitrated by the platform, but the arbitration today is one eligibility test against one node's allocatable. Nothing compares the sum of what the estate has declared against what the estate has, so every author writing `memory: 8Gi` passes the build (each claim fits @@ -957,7 +957,7 @@ two Workloads may not share a name (`E_DUPLICATE_WORKLOAD_NAME`). a build error, a warning, or a number carried on the artifact. **Blocks:** nothing today. It is the conceded cost of restating [0004](../../docs/adr/model/0004-contention-decides-authority.md) as - who-arbitrates, and it comes due the first time a Service cannot place. + who-arbitrates, and it comes due the first time an Application cannot place. ## Diagram sources @@ -973,18 +973,18 @@ an ADR. ```mermaid flowchart LR subgraph IN["Pinned inputs: each carried by digest"] - i1["Intent Fragment
this domain's file + env/"] + i1["Intent Fragment
this project's file + env/"] i2["Platform Intent
contextRef + node contract
(site, allocatable, gpus, disks)"] i3["images lock"] i4["ClusterState snapshot
clusterStateDigest
(PV bindings, current placements)"] - i5["Intent Fragments
of every other domain"] + i5["Intent Fragments
of every other project"] end RD["ResolvedDeployment
one document, whole estate"] subgraph OUT["Outputs"] o1["Deliverable Set
layer 3, per adapter"] - o2["ResolvedService
per-Service projection"] + o2["ResolvedService
per-Application projection"] o3["renderHash
+ inputDigests"] end diff --git a/spec/v1/30-deliverables.md b/spec/v1/30-deliverables.md index b035a20..9badeea 100644 --- a/spec/v1/30-deliverables.md +++ b/spec/v1/30-deliverables.md @@ -13,12 +13,12 @@ worth separating) it contains **no decisions**. Layer 2 decided everything (chapter 20). Layer 3 serialises. If a renderer has to choose, the choice belongs one layer up, and the choice being made here is the defect, because a decision taken during serialisation appears in no schema, is -recorded in no lock, and is invisible in the published projection a Service owner +recorded in no lock, and is invisible in the published projection an Application owner reads back. An earlier form of this rule made the path a function of the adapter and the -object it carries. That form could not answer which Service directory owns a -per-domain object, and it put an estate-scoped Deliverable in whichever +object it carries. That form could not answer which Application directory owns a +per-project object, and it put an estate-scoped Deliverable in whichever namespace the emitting adapter happened to key off; both are recorded in chapter 20's path plan as the reason authority moved up a layer. An Adapter still declares a `defaultPath` (that is how the registry states what an Adapter @@ -57,11 +57,11 @@ one of them is a **central** adapter running once over the composed union: | adapter | subsystem | emits | |---|---|---| -| `kubernetes` | workloads | per Service: the controller, `Service`, `ServiceAccount`, `ConfigMap` (including every inbound-derived Asset) `PersistentVolumeClaim`, `PodDisruptionBudget` above one replica, the backup and sweep `CronJob`, `Namespace` per domain, and the kustomize `Kustomization` per directory | +| `kubernetes` | processes | per Application: the controller, `Service`, `ServiceAccount`, `ConfigMap` (including every inbound-derived Asset) `PersistentVolumeClaim`, `PodDisruptionBudget` above one replica, the backup and sweep `CronJob`, `Namespace` per project, and the kustomize `Kustomization` per directory | | `networking` | policy | every `NetworkPolicy` ([0074](../../docs/adr/model/0074-networking-adapter-emits-policy.md)) | -| `prometheus` | monitoring | one `ServiceMonitor` or `PodMonitor` per Service that declares `observability`, from the named surface and the Platform document's cadence. No `PrometheusRule`: PromQL is the monitoring stack's ([chapter 10](../../spec/v1/10-service-intent.md#observability)) | +| `prometheus` | monitoring | one `ServiceMonitor` or `PodMonitor` per Application that declares `observability`, from the named surface and the Platform document's cadence. No `PrometheusRule`: PromQL is the monitoring stack's ([chapter 10](../../spec/v1/10-project-intent.md#observability)) | | `traefik` | edge | one `IngressRoute` set and one `Middleware` set **per tier** the Platform document declares ([0076](../../docs/adr/model/0076-middleware-has-one-producer.md), [0098](../../docs/adr/model/0098-one-publication-path.md)) | -| `vault-policy` | secret store | one policy and one auth role per Workload identity, as JSON ([0073](../../docs/adr/model/0073-vault-policy-is-a-deliverable.md)) | +| `vault-policy` | secret store | one policy and one auth role per Process identity, as JSON ([0073](../../docs/adr/model/0073-vault-policy-is-a-deliverable.md)) | | `vso` | secret delivery | `VaultConnection`, `VaultAuth`, the operator `ServiceAccount` per namespace, `VaultStaticSecret`, `VaultDynamicSecret` | **There is one publication path.** A repository publishes its Intent Fragment @@ -70,14 +70,14 @@ adapter runs at publish time. The five publish-time `*-fragment` producers of th previous generation, and the `adapter-compat` map that paired them with their consumers, are deleted: every kind they emitted is derived centrally from the same declaration, so they were a second render of the same intent, and their -map's digest padded `renderHash` for no reason but to pair them. A Service owner +map's digest padded `renderHash` for no reason but to pair them. An Application owner who wants to see their own render runs the same core locally over the same pinned inputs: a use-case, not a second adapter set. Three former adapters are not deleted so much as reclassified. The Gatus endpoints and the two edge catalogs are **inbound derivations** of the platform -Service that consumes them ([chapter 16](16-dependencies.md#what-an-edge-derives-read-inbound)), -rendered as that Service's own Assets by `kubernetes`. Image metadata is a +Application that consumes them ([chapter 16](16-dependencies.md#what-an-edge-derives-read-inbound)), +rendered as that Application's own Assets by `kubernetes`. Image metadata is a **projection of the images lock** and joins the Resolved Deployment artifact set ([chapter 20](20-resolved-deployment.md#publish-back)). `flux-root` (one Flux `Kustomization` per layer, with `dependsOn` and health checks) is one delivery @@ -152,15 +152,15 @@ that [0025](../../docs/adr/model/0025-access-tiers-derive-policy.md) derives had no output at all, and a derivation with no output is not total ([0005](../../docs/adr/model/0005-derivation-is-total.md)). -The `vault-policy` adapter emits, **per Workload identity**, two documents: +The `vault-policy` adapter emits, **per Process identity**, two documents: | document | derived from | |---|---| -| the Vault policy | the Workload's grants and their access tiers: `read` on the granted path, `patch` for `self-roll`, `create`/`update`/`delete` on a prefix for `custody`, nothing for `self-renew` | -| the Kubernetes auth role | the Workload's ServiceAccount and namespace ([0024](../../docs/adr/model/0024-identity-per-workload.md)), bound to that one policy | +| the Vault policy | the Process's grants and their access tiers: `read` on the granted path, `patch` for `self-roll`, `create`/`update`/`delete` on a prefix for `custody`, nothing for `self-renew` | +| the Kubernetes auth role | the Process's ServiceAccount and namespace ([0024](../../docs/adr/model/0024-identity-per-process.md)), bound to that one policy | -One document per identity, not per Service: identity is per Workload, so a -two-Workload Service produces two policies and a diff says which principal's +One document per identity, not per Application: identity is per Process, so a +two-Process Application produces two policies and a diff says which principal's privilege changed. Both are JSON, which Vault accepts and which lets the one serializer own key order. @@ -173,10 +173,10 @@ the documents and attributes them; nothing here says who writes them. configuring its JWT issuer and CA, and creating the KV mounts are estate-unique and draw on a shared resource, so by [0004](../../docs/adr/model/0004-contention-decides-authority.md) they are -platform-assigned, and they are Assets of the declared `vault` Service in the -platform's secrets domain ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md), -[chapter 60](60-setup.md#secrets-at-rest)) rather than per-Service render. The -per-Service render owns what varies per Workload and nothing else. +platform-assigned, and they are Assets of the declared `vault` Application in the +platform's secrets project ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md), +[chapter 60](60-setup.md#secrets-at-rest)) rather than per-Application render. The +per-Application render owns what varies per Process and nothing else. ## Attribution @@ -206,7 +206,7 @@ day Nomad arrived. If a second target ever exists, that is when the abstraction gets lifted, with two real consumers to shape it. Two adapters may render objects of the same **kind**, `kubernetes` creates the -Service's own `ServiceAccount`, `vso` mirrors the VSO operator's `ServiceAccount` +Application's own `ServiceAccount`, `vso` mirrors the VSO operator's `ServiceAccount` into each target namespace. That is allowed: attribution is per Deliverable, and single authority is per **field**, not per kind. What is never allowed is two adapters claiming one path. @@ -215,13 +215,13 @@ adapters claiming one path. Paths are assigned by the Resolved Deployment's path plan ([chapter 20](20-resolved-deployment.md#the-path-plan)) from each adapter's -`defaultPath`, never configured per Service: +`defaultPath`, never configured per Application: ``` -/apps///.yaml -/apps//namespace.yaml +/apps///.yaml +/apps//namespace.yaml /apps/vso-secrets/… -/apps/vso-secrets/policies/.{policy,role}.json +/apps/vso-secrets/policies/.{policy,role}.json /apps/edge//… ``` @@ -244,7 +244,7 @@ already has and states: | ledger | holds | absent from it | stale entry in it | |---|---|---|---| | coverage ledger | every live object with no producing adapter | `E_UNATTRIBUTED_OBJECT` | `E_LEDGER_ENTRY_STALE` | -| accepted fragment drift | differences between the render and what a Service published, each a deferred fix | `E_UNACCEPTED_DRIFT` | `E_LEDGER_ENTRY_STALE` | +| accepted fragment drift | differences between the render and what an Application published, each a deferred fix | `E_UNACCEPTED_DRIFT` | `E_LEDGER_ENTRY_STALE` | | registered unmanaged surfaces | hostnames the model does not deploy ([0019](../../docs/adr/model/0019-registered-unmanaged-surfaces.md), chapter 40) | `E_UNREGISTERED_SURFACE` | `E_LEDGER_ENTRY_STALE` | Every entry carries three fields and a build consequence: @@ -268,8 +268,8 @@ point of the stale half: without it, a coverage entry outlives the adapter that closed it and no build says so. One live drift entry marks where the limit genuinely is: `agent-gateway` cannot be -probed because it is *"a sidecar jar inside agent-runner pods, not a workload of -its own"*, and its per-runner Services are created and destroyed by `agents-api` +probed because it is *"a sidecar jar inside agent-runner pods, not a process of +its own"*, and its per-runner Applications are created and destroyed by `agents-api` at runtime. Some objects are outside any declarative model. The ledger is where they belong: with an owner and a reason, not with silence. @@ -298,23 +298,23 @@ them is wrong by 74. | class | objects | share | how it is produced | |---|---|---|---| -| **A, derived from Service Intent** | 364 | 81% | a registered adapter, per Service | -| **B, the foundation** | 41 | 9% | was pack-delivered from `flux-modules` at a pinned ref; now **declared** as Services of the platform domains and rendered like class A ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)). The CRDs among them are the bootstrap set | +| **A, derived from Project Intent** | 364 | 81% | a registered adapter, per Application | +| **B, the foundation** | 41 | 9% | was pack-delivered from `flux-modules` at a pinned ref; now **declared** as Applications of the platform projects and rendered like class A ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)). The CRDs among them are the bootstrap set | | **C, authored content** | 45 | 10% | not derivable; ledgered until its owner lands | Class C is entirely Grafana: 31 `GrafanaDashboard`, 14 `GrafanaFolder`. Nothing -in Service Intent implies a dashboard's panels; deriving one would be inventing a -dashboard DSL. It is ledgered rather than permanent: 14 service dashboards become -Assets on the owning Service ([0012](../../docs/adr/model/0012-assets-not-code.md)), 3 +in Project Intent implies a dashboard's panels; deriving one would be inventing a +dashboard DSL. It is ledgered rather than permanent: 14 application dashboards become +Assets on the owning Application ([0012](../../docs/adr/model/0012-assets-not-code.md)), 3 runtime-family dashboards ship with the Runtime Profile, 14 platform dashboards -become Assets of the declared observability Services, and `service-overview` / `service-template` derive -per Service from the scrape surface and exposure +become Assets of the declared observability Applications, and `application-overview` / `application-template` derive +per Application from the scrape surface and exposure ([0021](../../docs/adr/model/0021-observability-scrape-and-alert-class.md)). ### The true gap Every kind the 2026-08-31 survey found unrendered now has a decision: `Role` and -`RoleBinding` are not rendered ([0075](../../docs/adr/model/0075-no-workload-rbac-in-v1.md)), +`RoleBinding` are not rendered ([0075](../../docs/adr/model/0075-no-process-rbac-in-v1.md)), `NetworkPolicy` is `networking`'s ([0074](../../docs/adr/model/0074-networking-adapter-emits-policy.md)), `ServiceMonitor` and `PodMonitor` are `prometheus`'s, from the declared `observability.scrape` surface, and `PrometheusRule` is rendered by nothing here @@ -355,8 +355,8 @@ needs a decision, not an allowlist entry."* | forbidden | why | |---|---| -| a kind on the forbidden list, `Secret`, `ClusterRole`, `ClusterRoleBinding`, `CustomResourceDefinition` | `E_FORBIDDEN_KIND`; a CRD is a bootstrap fact ([chapter 14](14-platform-intent.md#the-bootstrap-set)), a Secret arrives through VSO, and RBAC is not rendered ([0075](../../docs/adr/model/0075-no-workload-rbac-in-v1.md)) | -| an object in a namespace the Service does not own | `E_FOREIGN_NAMESPACE` | +| a kind on the forbidden list, `Secret`, `ClusterRole`, `ClusterRoleBinding`, `CustomResourceDefinition` | `E_FORBIDDEN_KIND`; a CRD is a bootstrap fact ([chapter 14](14-platform-intent.md#the-bootstrap-set)), a Secret arrives through VSO, and RBAC is not rendered ([0075](../../docs/adr/model/0075-no-process-rbac-in-v1.md)) | +| an object in a namespace the Application does not own | `E_FOREIGN_NAMESPACE` | | a floating image tag | `E_FLOATING_IMAGE`; digests only | | a path claimed by two adapters | `E_PATH_COLLISION`; attribution becomes ambiguous | | a path outside the gitops root, or containing `..` | `E_UNSAFE_OUTPUT_PATH` | @@ -377,18 +377,18 @@ Deliverable Set claims.** Any delivery definition that reconciles a cluster towards this tree will treat an unclaimed object as removable, so a coverage gap is a correctness problem in the model, not a tidiness problem downstream. That is why the coverage assertion fails the build, why the ledger fails in both -directions, and why a domain that silently fails to publish must show up as a +directions, and why a project that silently fails to publish must show up as a stale participant (chapter 40) rather than as a quietly smaller render. ## Open in this chapter The first three items this section carried (`rbac`, `NetworkPolicy`, `PrometheusRule`) are decided -([0075](../../docs/adr/model/0075-no-workload-rbac-in-v1.md), +([0075](../../docs/adr/model/0075-no-process-rbac-in-v1.md), [0074](../../docs/adr/model/0074-networking-adapter-emits-policy.md), [0079](../../docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md)). Four more, the `E_PATH_COLLISION` implementation, the `@ts-nocheck` ratchet, the -Service `ServiceAccount` name and the gitops root in the allocator, are code +Application `ServiceAccount` name and the gitops root in the allocator, are code work against a compiler that does not exist yet, and belong in its issue tracker rather than in a normative chapter; `docs/architecture.md` carries the gates that will hold them. What remains open here is a model question: @@ -415,7 +415,7 @@ an ADR. ```mermaid flowchart LR subgraph pub["every repository, publish"] - IF["Intent Fragment
(domain file, or the Platform document)
pushed by digest"] + IF["Intent Fragment
(project file, or the Platform document)
pushed by digest"] end subgraph agg["central render, over the ComposedIntent"] RD["Resolved Deployment
+ path plan + clusterStateDigest"] diff --git a/spec/v1/40-composition.md b/spec/v1/40-composition.md index 2844271..d686aad 100644 --- a/spec/v1/40-composition.md +++ b/spec/v1/40-composition.md @@ -11,16 +11,16 @@ in isolation: | property | needs | decided in | |---|---|---| -| Service Id uniqueness | every Service in the estate | [0010](../../docs/adr/model/0010-flat-service-identity.md) | -| domain uniqueness, and exactly one publisher per domain | every fragment in the estate | [0063](../../docs/adr/model/0063-intent-authored-per-domain.md) | +| Application Id uniqueness | every Application in the estate | [0010](../../docs/adr/model/0010-flat-application-identity.md) | +| project uniqueness, and exactly one publisher per project | every fragment in the estate | [0063](../../docs/adr/model/0063-intent-authored-per-project.md) | | hostname uniqueness | every exposure in the estate, plus the register of surfaces the model does not deploy | [0018](../../docs/adr/model/0018-exposure-by-audience.md) | | reachability completeness, derived ∪ registered | every exposure plus the unmanaged register | [0019](../../docs/adr/model/0019-registered-unmanaged-surfaces.md) | | the Reconcile Unit DAG | every required dependency edge | [0032](../../docs/adr/model/0032-reconcile-unit-derived.md) | -| inbound derivations, CORS origins, one database per consumer | edges pointing *at* a Service | [0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md) | +| inbound derivations, CORS origins, one database per consumer | edges pointing *at* an Application | [0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md) | | the reader set of a secret path | every grant in the estate | [0023](../../docs/adr/model/0023-grant-unit-is-the-path.md) | -| placement eligibility, at least one node per Workload | every declared dimension against the fleet's node contract | [0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md) | +| placement eligibility, at least one node per Process | every declared dimension against the fleet's node contract | [0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md) | -No Service knows its own consumers, and no domain file holds the fleet's node +No Application knows its own consumers, and no project file holds the fleet's node contract, so none of these are locally computable. That is the whole argument for composition ([0037](../../docs/adr/model/0037-composition-oci-fragments.md)), and it is why this chapter is a hard dependency of chapters 16, 20 and 30. @@ -28,18 +28,18 @@ why this chapter is a hard dependency of chapters 16, 20 and 30. Two properties left this list on 2026-09-07. **Co-test membership** moved out with the delivery and co-testing split; see [docs/adr/deferred/README.md](../../docs/adr/deferred/README.md). **Release Unit -membership** moved out because a Service is now itself the unit of atomic release -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md), superseding -[0060](../../docs/adr/model/0060-release-unit.md)): its members are the Workloads in +membership** moved out because an Application is now itself the unit of atomic release +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md), superseding +[0060](../../docs/adr/model/0060-release-unit.md)): its members are the Processes in its own document, so membership is readable in one file and needs no union at all. ## Fragments -The unit of publication is a **domain file**. One domain file is one Intent -Fragment, holding the many Services of that domain, and a fragment therefore -declares exactly one domain -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)): +The unit of publication is a **project file**. One project file is one Intent +Fragment, holding the many Applications of that project, and a fragment therefore +declares exactly one project +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)): ```yaml apiVersion: intent.jorisjonkers.dev/v1 @@ -49,47 +49,47 @@ metadata: sourceSha: 22b9d332a9e059eaeebaffbe49ab25f762985029 spec: schemaVersion: 1.0.0 - domain: knowledge # exactly one; the domain file's header - owner: joris # the only field raised to the domain + project: knowledge # exactly one; the project file's header + owner: joris # the only field raised to the project contains: - services: [knowledge] + applications: [knowledge] secretSubtrees: [knowledge-system/] ``` This narrows the wording of -[0037](../../docs/adr/model/0037-composition-oci-fragments.md), which says "each domain -repository publishes". **One repository may hold several domain files**, and it -then publishes one fragment per domain file rather than one fragment per +[0037](../../docs/adr/model/0037-composition-oci-fragments.md), which says "each project +repository publishes". **One repository may hold several project files**, and it +then publishes one fragment per project file rather than one fragment per repository: `homelab-collections` stays one repository and publishes one fragment -for each domain it holds. Splitting it into separate repositories remains a +for each project it holds. Splitting it into separate repositories remains a convenience rather than a prerequisite, because composition behaves identically -either way: it unions fragments, and every fragment is already a whole domain. +either way: it unions fragments, and every fragment is already a whole project. -**A domain never spans repositories, and composition rejects one that does.** A -domain name may be declared by exactly one fragment across the union; a second -fragment declaring `domain: knowledge` (in the same repository or in another) -is `E_DUPLICATE_DOMAIN`. That check is what makes the union total: composition -unions fragments and never has to union a domain, so a domain's membership is +**A project never spans repositories, and composition rejects one that does.** A +project name may be declared by exactly one fragment across the union; a second +fragment declaring `project: knowledge` (in the same repository or in another) +is `E_DUPLICATE_PROJECT`. That check is what makes the union total: composition +unions fragments and never has to union a project, so a project's membership is never a fact that becomes knowable only after composition has run. Without it, two repositories could each hold half of `knowledge` and no single file would state who is in it. A fragment carries: -- the domain file: its Services, their Workloads, and the per-Workload env files - those Workloads name -- the Secret Subtree the domain owns: paths, keys, engines, readers +- the project file: its Applications, their Processes, and the per-Process env files + those Processes name +- the Secret Subtree the project owns: paths, keys, engines, readers - the node contract, for the fragment that owns the fleet: the `allocatable` table every `placement` is matched against ([0056](../../docs/adr/model/0056-node-facts-single-source.md)) -- Registered Unmanaged Surfaces the domain is responsible for +- Registered Unmanaged Surfaces the project is responsible for A fragment publishes **on merge to the default branch, independently of any image release**. An intent-only change (a changed exposure, a secret grant, a dependency edge, a raised `placement.memory`) produces no image, and tying publication to a version tag would leave such a change unpublished behind a staleness window. The worked workflow is -[`examples/workflows/service-publish-fragment.yml`](examples/workflows/service-publish-fragment.yml). +[`examples/workflows/project-publish-fragment.yml`](examples/workflows/project-publish-fragment.yml). ### Publication, and why the lock is an output @@ -154,38 +154,38 @@ Normative. Composition fails on any of these, and produces no `ComposedIntent`. | invariant | error | |---|---| -| Service Ids are unique across the union | `E_DUPLICATE_SERVICE_ID` | -| a domain name is declared by exactly one fragment, so a domain sits in exactly one repository | `E_DUPLICATE_DOMAIN` | -| Workload names are unique within their domain | `E_DUPLICATE_WORKLOAD_NAME` | +| Application Ids are unique across the union | `E_DUPLICATE_APPLICATION_ID` | +| a project name is declared by exactly one fragment, so a project sits in exactly one repository | `E_DUPLICATE_PROJECT` | +| Process names are unique within their project | `E_DUPLICATE_PROCESS_NAME` | | a `host` is unique across the composed union | `E_DUPLICATE_HOST` | -| exposure names are unique within their Service | `E_DUPLICATE_EXPOSURE_NAME` | +| exposure names are unique within their Application | `E_DUPLICATE_EXPOSURE_NAME` | | two routes on one exposure do not share a `path` + `match` pair | `E_DUPLICATE_ROUTE_MATCH` | -| at most one Service claims the apex host | `E_DUPLICATE_APEX` | +| at most one Application claims the apex host | `E_DUPLICATE_APEX` | | Secret Store path prefixes do not overlap between Subtrees | `E_SUBTREE_PREFIX_COLLISION` | -**Service ids stay estate-unique even though they no longer determine the -namespace.** The namespace derives from `domain`, as `-system` -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)), which is why a -namespace now holds several Services and is not a trust boundary. The id's +**Application ids stay estate-unique even though they no longer determine the +namespace.** The namespace derives from `project`, as `-system` +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)), which is why a +namespace now holds several Applications and is not a trust boundary. The id's uniqueness follows from what *references* it, not from what it names: it is the -join key every `dependsOn.service` resolves against -([0010](../../docs/adr/model/0010-flat-service-identity.md), -[0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md)), and two Services +join key every `dependsOn.application` resolves against +([0010](../../docs/adr/model/0010-flat-application-identity.md), +[0020](../../docs/adr/model/0020-dependency-edges-carry-surface.md)), and two Applications answering to one id would make an edge ambiguous wherever they live. -`E_DUPLICATE_WORKLOAD_NAME` is scoped to the **domain**, not to the Service, -because the Workload name alone is the ServiceAccount and the Vault role name -under the domain's namespace, `auth-system.auth-api`, not -`auth-system.auth-auth-api` ([0024](../../docs/adr/model/0024-identity-per-workload.md)). -Two Services in one domain file therefore cannot both call a Workload `api`, -while the same name may repeat freely across domains. Since a domain is exactly +`E_DUPLICATE_PROCESS_NAME` is scoped to the **project**, not to the Application, +because the Process name alone is the ServiceAccount and the Vault role name +under the project's namespace, `auth-system.auth-api`, not +`auth-system.auth-auth-api` ([0024](../../docs/adr/model/0024-identity-per-process.md)). +Two Applications in one project file therefore cannot both call a Process `api`, +while the same name may repeat freely across projects. Since a project is exactly one fragment, the check reads one fragment at a time; it is asserted here because composition is the one step every fragment passes through. **`host` uniqueness is a composition check, not a structural guarantee.** Nothing in the model makes a hostname unique by construction: `host` is a full -FQDN authored on a Service's `exposure` entry -([0018](../../docs/adr/model/0018-exposure-by-audience.md)), and two domain files in +FQDN authored on an Application's `exposure` entry +([0018](../../docs/adr/model/0018-exposure-by-audience.md)), and two project files in two repositories can write the same string with neither able to read the other. `E_DUPLICATE_HOST` over the union is the only place the property holds at all, and it is evaluated over **derived hosts and Registered Unmanaged Surfaces @@ -195,18 +195,18 @@ exposure claiming `samba.lan.jorisjonkers.dev` collides with the register entry that excuses it, which is the collision worth catching. Because the whole FQDN is authored, the apex is a host value rather than a -marker. `home-portal` writes `host: jorisjonkers.dev`, and two Services writing +marker. `home-portal` writes `host: jorisjonkers.dev`, and two Applications writing it are the same collision as any other duplicated host; `E_DUPLICATE_APEX` names that pair specifically so the message can say which name was contested. **`E_DUPLICATE_EXPOSURE_NAME` has a definition at last: unique within the -Service.** It checked a field nothing defined until `name` became required, and +Application.** It checked a field nothing defined until `name` became required, and it is deliberately not estate-wide. The name is a local handle: the second half -of `${exposure:.#url}`, already qualified by the Service id, so -`public` may repeat in every domain in the estate, while a Service fronting +of `${exposure:.#url}`, already qualified by the Application id, so +`public` may repeat in every project in the estate, while an Application fronting several hosts, `jellyfin` public and lan, needs exactly this to tell its own -apart. Being Service-scoped it is computable inside one fragment, and it is -asserted here for the reason `E_DUPLICATE_WORKLOAD_NAME` is: composition is the +apart. Being Application-scoped it is computable inside one fragment, and it is +asserted here for the reason `E_DUPLICATE_PROCESS_NAME` is: composition is the one step every fragment passes through. `E_DUPLICATE_ROUTE_MATCH` covers the other half of that pair. Two routes on one @@ -221,60 +221,60 @@ because the vocabulary they were written in had no path to declare: the | invariant | error | |---|---| -| every `dependsOn.service` resolves to a Service in the union **or to a provider the Platform document declares** ([0090](../../docs/adr/model/0090-edges-resolve-against-the-register.md), [0095](../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)) | `E_UNRESOLVED_SERVICE` | -| every `dependsOn.surface` is provided by a Workload of that Service, or listed by that provider | `E_UNKNOWN_SURFACE` | +| every `dependsOn.application` resolves to an Application in the union **or to a provider the Platform document declares** ([0090](../../docs/adr/model/0090-edges-resolve-against-the-register.md), [0095](../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)) | `E_UNRESOLVED_APPLICATION` | +| every `dependsOn.surface` is provided by a Process of that Application, or listed by that provider | `E_UNKNOWN_SURFACE` | | every provider an edge targets carries an address and the port for that surface ([chapter 14](14-platform-intent.md#providers)) | `E_PROVIDER_WITHOUT_COORDINATES` | -| every route's `surface` is provided by the Workload that route names | `E_UNKNOWN_SURFACE` | +| every route's `surface` is provided by the Process that route names | `E_UNKNOWN_SURFACE` | | no two routes on one host share a `path` and `match` ([0093](../../docs/adr/model/0093-route-precedence-is-derived.md)) | `E_DUPLICATE_ROUTE` | | the graph of **required** edges is acyclic | `E_DEPENDENCY_CYCLE` | | every exposure's audience is carryable by some tier | `E_NO_TIER_FOR_AUDIENCE` | -An edge still targets `{service, surface}`, and surface names are still unique -within a Service. What moved is where the surface is declared: `provides` sits on -the **Workload** that listens, because a port is a property of a process. So -resolving `E_UNKNOWN_SURFACE` is a lookup for the Service in the union and then -for the Workload of that Service carrying the name: the edge itself never names -a Workload, and a surface moving between Workloads of one Service breaks no +An edge still targets `{application, surface}`, and surface names are still unique +within an Application. What moved is where the surface is declared: `provides` sits on +the **Process** that listens, because a port is a property of a process. So +resolving `E_UNKNOWN_SURFACE` is a lookup for the Application in the union and then +for the Process of that Application carrying the name: the edge itself never names +a Process, and a surface moving between Processes of one Application breaks no reference. `E_UNKNOWN_SURFACE` carries two cases, and they resolve differently. A route -inside an `exposure` names `{path, match, workload, surface}`, so it names the -Workload outright: the check is that *that* Workload declares *that* surface in -its own `provides`, with no search across the Service. A route is the one place -a Workload is named from outside itself, and it is named from inside the same -Service document, which is why `exposure` sits on the Service while `provides` -stays on the Workload ([0018](../../docs/adr/model/0018-exposure-by-audience.md)). -Moving a surface between two Workloads of one Service therefore breaks no -`dependsOn` edge and does break a route still naming the old Workload, and that +inside an `exposure` names `{path, match, process, surface}`, so it names the +Process outright: the check is that *that* Process declares *that* surface in +its own `provides`, with no search across the Application. A route is the one place +a Process is named from outside itself, and it is named from inside the same +Application document, which is why `exposure` sits on the Application while `provides` +stays on the Process ([0018](../../docs/adr/model/0018-exposure-by-audience.md)). +Moving a surface between two Processes of one Application therefore breaks no +`dependsOn` edge and does break a route still naming the old Process, and that asymmetry is correct: the edge asked for a capability, the route asked for a process. Optional edges are excluded from the cycle check deliberately. `required: false` -means a Workload starts without its peer, so a cycle through optional edges +means a Process starts without its peer, so a cycle through optional edges cannot deadlock a rollout. Two release-unit invariants left this table on 2026-09-07. `E_RELEASE_UNIT_SINGLETON` existed only because `releaseUnit` was a free string -joined at composition and nowhere else: a Service held at most one, no Service +joined at composition and nowhere else: an Application held at most one, no Application could see its co-members, and a misspelt name yielded two units of one rather -than an error, atomicity silently gone with every gate green. A Service is now +than an error, atomicity silently gone with every gate green. An Application is now itself the unit of atomic release -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)), so there is no join +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)), so there is no join key to misspell, no membership for composition to materialise, and nothing left -for that error to catch: the members are the Workloads listed in the Service's +for that error to catch: the members are the Processes listed in the Application's own document. The readiness requirement the second error carried is unchanged in substance (no member's new version takes traffic until every member is healthy, health meaning that member's own declared readiness ([0014](../../docs/adr/model/0014-probes-are-siblings.md)), but it is now a property -of one Service in one file rather than of a set assembled across repositories, +of one Application in one file rather than of a set assembled across repositories, and checking it needs no estate-wide view. ### Placement | invariant | error | |---|---| -| every Workload's `placement` has at least one eligible node in the pinned node contract | `E_PLACEMENT_UNSATISFIABLE` | -| no `disk` dimension conflicts with that Workload's existing PV binding | `E_DISK_BINDING_CONFLICT` | +| every Process's `placement` has at least one eligible node in the pinned node contract | `E_PLACEMENT_UNSATISFIABLE` | +| no `disk` dimension conflicts with that Process's existing PV binding | `E_DISK_BINDING_CONFLICT` | Every declared dimension is **hard**: all of them must match, a list is a set of equally acceptable values with no ordering and no weight, and matching is against @@ -288,15 +288,15 @@ could speak about flat strings and nothing else: it now covers every dimension: `memory`, `cpu`, `arch`, `site`, `disk`, `gpu` and `capabilities` alike. This is the check no single fragment can run. The node contract belongs to the -fragment that owns the fleet, so a domain file declaring `placement` cannot know +fragment that owns the fleet, so a project file declaring `placement` cannot know whether any node satisfies it; composition is the first place both halves exist. **It is eligibility, not bin-packing, and the difference must not be papered -over.** Each Workload is compared against one node's allocatable on its own. -Three Workloads declaring `memory: 2Gi` all pass against a 4096Mi node ( +over.** Each Process is compared against one node's allocatable on its own. +Three Processes declaring `memory: 2Gi` all pass against a 4096Mi node ( `enschede-pi-2` and `enschede-pi-3` are exactly that), and the scheduler refuses the third at apply. Composition asserts that some node *could* hold each -Workload; it never asserts that the fleet can hold all of them at once. That +Process; it never asserts that the fleet can hold all of them at once. That residue is open item 5 below. `gpu` is structured, matched against the node contract's `gpus[].class` and @@ -316,7 +316,7 @@ the binding decides the node. A `disk` dimension the bound node cannot satisfy i therefore `E_DISK_BINDING_CONFLICT` (a build error naming the conflict), rather than a silent re-placement or a `Pending` pod. The live shape to hold in mind: `knowledge-vault-clone` is bound to `enschede-t1000-1`, whose disks are nvme and -hdd, so a later `disk: {media: [ssd]}` on that Workload is the error, not a move. +hdd, so a later `disk: {media: [ssd]}` on that Process is the error, not a move. Note also what `disk` is not: it is a media and capacity filter over node facts, not a storage class. Longhorn is declared eligible on four nodes, but no PVC in `fleet-infra` sets a `storageClassName` (everything takes k3s's default @@ -336,12 +336,12 @@ scheduler drops without an event, a warning or a condition. | invariant | error | |---|---| | every grant's `path` is declared by exactly one Subtree | `E_UNDECLARED_SECRET_PATH` | -| the Subtree lists the granting Service as a reader of that path | `E_READER_NOT_DECLARED` | +| the Subtree lists the granting Application as a reader of that path | `E_READER_NOT_DECLARED` | | every grant with `delivery: env` is named by at least one placeholder | `E_UNBOUND_SECRET_GRANT` | | every `${secret:#}` placeholder byte-matches a grant's **derived read path** ([0085](../../docs/adr/model/0085-a-grant-is-a-union-on-engine.md)) | `E_UNAUTHORISED_SECRET_REFERENCE` | | `access: self-roll` on a path with other readers carries an acknowledgement | `E_ROLL_AFFECTS_OTHER_READERS` | | no literal secret value appears in an env file or an Asset | `E_RAW_SECRET` | -| no rendered Deliverable grants a Workload access to `secrets` | `E_WORKLOAD_RBAC_GRANT` | +| no rendered Deliverable grants a Process access to `secrets` | `E_PROCESS_RBAC_GRANT` | Three points of precision, all following from the grant unit being the path ([0009](../../docs/adr/model/0009-vault-read-is-per-path.md), @@ -383,27 +383,27 @@ exactly as fresh as that snapshot: composition reads no live cluster. ## Participants The expected set is **enumerated**, not derived. Deriving it from inbound -references was considered and rejected: a **leaf** Service that nothing depends +references was considered and rejected: a **leaf** Application that nothing depends on can vanish without breaking any reference, and leaves are the majority: `immich`, `jellyfin`, `sonarr`, `radarr`, `bazarr`, `prowlarr`, `qbittorrent`. -Seven media services, zero inbound edges, invisible to any edge-derived guard. +Seven media applications, zero inbound edges, invisible to any edge-derived guard. **The Platform document is a required participant.** It publishes as an Intent -Fragment like any domain ([0095](../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)), +Fragment like any project ([0095](../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)), appears in `participants.yml` under the platform's own repository, and is held to the same seven-day bound: a render without it is `E_PARTICIPANT_MISSING`, a render against a stale one is `E_PARTICIPANT_STALE`. There is no side channel by which platform facts reach the render. `participants.yml` is the one central artefact that survives composition by -fragments. It changes when a domain is added or retired, never when a -declaration changes, and since one fragment is exactly one domain -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)), that sentence is now -literal rather than approximate. The list enumerates domains, and because a -domain has exactly one publisher it is also the domain-to-repository map that -`E_DUPLICATE_DOMAIN` is checked against. A repository holding several domain -files appears once per domain rather than once per repository, so dropping one -domain file out of a repository that still publishes its others is +fragments. It changes when a project is added or retired, never when a +declaration changes, and since one fragment is exactly one project +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)), that sentence is now +literal rather than approximate. The list enumerates projects, and because a +project has exactly one publisher it is also the project-to-repository map that +`E_DUPLICATE_PROJECT` is checked against. A repository holding several project +files appears once per project rather than once per repository, so dropping one +project file out of a repository that still publishes its others is `E_PARTICIPANT_MISSING` rather than an unremarked absence. ```yaml @@ -435,22 +435,22 @@ an override without one is a build error. like any other (chapter 30): owner, reason, review date, and a date in the past fails the build. It exempts a participant from `maxAge` **and from nothing else**: a dormant fragment still unions, still satisfies every invariant above, -and still has to sit inside the accepted version range below. A domain nobody is +and still has to sit inside the accepted version range below. A project nobody is otherwise touching must therefore still be republished when the model moves. ### A missed publish is a deletion `E_PARTICIPANT_MISSING` is not pedantry, and this is the reason the participants -list is load-bearing rather than hygiene. **A render that omits an entire domain +list is load-bearing rather than hygiene. **A render that omits an entire project is a valid render.** Nothing inside it is wrong; it simply does not contain that -domain, so every one of the invariants above passes and the composed digest is +project, so every one of the invariants above passes and the composed digest is perfectly reproducible. The absence is indistinguishable from a retirement. -At the model level that means: an unpublished domain reaches whatever consumes +At the model level that means: an unpublished project reaches whatever consumes the `ComposedIntent` as an *intentional* absence. Composition is the only place that can tell the difference, because it is the only place holding the enumeration of what was expected. What a delivery mechanism then does with an -absent domain (including whether it removes objects), is defined separately +absent project (including whether it removes objects), is defined separately ([docs/adr/deferred/README.md](../../docs/adr/deferred/README.md)); the model's obligation is to refuse to emit the render in the first place. @@ -500,7 +500,7 @@ Equality is rejected for the opposite reason: it fails closed over the **union** One stale participant blocks every composition, including the composition carrying the fix, and dormancy does not exempt a fragment from a version check. The estate already demonstrates that skew is survivable: `0.16.0` in four -service repos, `0.20.0` in `stalwart-provisioner`, `0.22.0` in the published +project repos, `0.20.0` in `stalwart-provisioner`, `0.22.0` in the published contexts, and it functions. **Admission and reproduction are different jobs.** The range governs what @@ -524,7 +524,7 @@ claiming there is nothing to build. The sequence: | step | who | verified by | |---|---|---| | 1. publish the toolkit at the new model version | release operator | pull by digest, read `schemaVersion` back out | -| 2. republish each fragment or context carrying documents at that version | that domain's owner | pull by digest, read `schemaVersion` back out | +| 2. republish each fragment or context carrying documents at that version | that project's owner | pull by digest, read `schemaVersion` back out | | 3. open the pin bump in each consumer | Renovate | the ordering gate | | 4. merge | a human | the gate, green | @@ -569,11 +569,11 @@ reaches a cluster, is defined separately ## Unmanaged surfaces -Service Intent covers Kubernetes workloads only. The estate has three deployment +Project Intent covers Kubernetes processes only. The estate has three deployment targets, not one: `samba` exists only as a NixOS module yet owns `samba.lan.jorisjonkers.dev`; `wolf` exists in neither target and owns `wolf.jorisjonkers.dev`; `adguard` and `ollama` exist in both; and host-level -services (`tailscale`, `media-storage`, `backup-storage`, +applications (`tailscale`, `media-storage`, `backup-storage`, `btrfs-backup-snapshots`), have no cluster presence at all. `tailscale` here is the host daemon; it is not a placement capability, that use having retired with the flat capability vocabulary @@ -602,7 +602,7 @@ never against exemptions ([0095](../../docs/adr/model/0095-platform-intent-is-th unmanagedSurfaces: - host: samba.lan.jorisjonkers.dev owner: joris - reason: NixOS module; no Kubernetes workload exists + reason: NixOS module; no Kubernetes process exists reviewBy: 2026-11-30 - host: wolf.jorisjonkers.dev owner: joris @@ -624,7 +624,7 @@ The third row is [Identity](#identity)'s `E_DUPLICATE_HOST` reaching across this boundary. The two sets do not merely cover the hostname space between them, they **partition** it, so the check is asserted over their union rather than over the derived half alone: `wolf.jorisjonkers.dev` is spoken for by a ledger -entry, and a Service later authoring `host: wolf.jorisjonkers.dev` has to +entry, and an Application later authoring `host: wolf.jorisjonkers.dev` has to collide with it rather than quietly take the name back. Derived entries come from Audience declarations @@ -660,7 +660,7 @@ spec: fragments: intent-knowledge: ref: ghcr.io/jorisjonkers-dev/intent-knowledge@sha256:… - domain: knowledge # exactly one per fragment + project: knowledge # exactly one per fragment repository: JorisJonkers-dev/knowledge schemaVersion: 1.0.0 # exact resolved model version sourceSha: 22b9d33… @@ -672,11 +672,11 @@ spec: clusterStateDigest: sha256:… # the pinned snapshot, chapter 20 ``` -`domain` and `repository` are recorded per fragment because the union is over -domains and a domain has exactly one publisher +`project` and `repository` are recorded per fragment because the union is over +projects and a project has exactly one publisher ([0037](../../docs/adr/model/0037-composition-oci-fragments.md), -[0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). A replay can then -show which repository published a domain at that digest, and a domain that moved +[0063](../../docs/adr/model/0063-intent-authored-per-project.md)). A replay can then +show which repository published a project at that digest, and a project that moved repositories between two locks appears as a diff rather than as a quietly different render. @@ -693,25 +693,25 @@ toolkit version, and must yield the identical `composedDigest`. Combined with chapter 20's pinned-input rule and chapter 30's `renderHash`, that gives one unbroken chain from a published fragment to a rendered file. -## Cross-service references +## Cross-application references -A reference is a Service Id and, where it names a connection, a surface name. +A reference is an Application Id and, where it names a connection, a surface name. Resolution is a lookup in the union: no URL, no repository coordinate, no -network call at authoring time. The surface is found on the Workload of that -Service which provides it, so a reference names a Service and a surface and never -a Workload or a namespace. +network call at authoring time. The surface is found on the Process of that +Application which provides it, so a reference names an Application and a surface and never +a Process or a namespace. -Renaming a Service is therefore a breaking change to every inbound reference, -which is what `E_UNRESOLVED_SERVICE` reports, and there is no escape hatch left: +Renaming an Application is therefore a breaking change to every inbound reference, +which is what `E_UNRESOLVED_APPLICATION` reports, and there is no escape hatch left: the `aliases` block is deleted -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). It existed so a +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)). It existed so a *coordinate* could diverge from the identity, and it now has nothing to express: -the namespace comes from `domain`, and the Workload name and the image are fields +the namespace comes from `project`, and the Process name and the image are fields the author already writes explicitly -([0010](../../docs/adr/model/0010-flat-service-identity.md)). A Service id that reads -nothing like its processes is not a divergence to be recorded: Service -`home-portal` holding Workload `app-ui` with image `app-ui` is simply what those -things are called. A rename lands in every referring domain file, or composition +([0010](../../docs/adr/model/0010-flat-application-identity.md)). An Application id that reads +nothing like its processes is not a divergence to be recorded: Application +`home-portal` holding Process `app-ui` with image `app-ui` is simply what those +things are called. A rename lands in every referring project file, or composition fails. ## Delivery and co-testing are defined separately @@ -724,8 +724,8 @@ version of the model**. They are defined separately; the parked direction work is [docs/adr/deferred/README.md](../../docs/adr/deferred/README.md). The model makes exactly three demands on whatever that definition turns out to -be: all-or-nothing switchover of a Service's Workloads -([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)), destructive +be: all-or-nothing switchover of an Application's Processes +([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)), destructive operations gated by Durability Class ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)), and rendering from pinned inputs only @@ -744,7 +744,7 @@ pinned inputs only settling test (the maximum inter-publish gap per participant over 90 days) and is recorded there. 3. **Whether the union may span clusters.** The lock is keyed by cluster, but the - invariants (Service Id uniqueness in particular), are estate-wide rather than + invariants (Application Id uniqueness in particular), are estate-wide rather than per-cluster. - **Owner:** joris. - **Settled by:** a second cluster existing. With one cluster the distinction @@ -789,7 +789,7 @@ flowchart TB end subgraph UNION["2. union"] - u1["merge domain files, Services, Workloads,
Secret Subtrees, unmanaged surfaces"] + u1["merge project files, Applications, Processes,
Secret Subtrees, unmanaged surfaces"] u2["materialise the required-edge DAG
and the node allocatable table"] end diff --git a/spec/v1/50-lifecycle.md b/spec/v1/50-lifecycle.md index b126740..583d2dd 100644 --- a/spec/v1/50-lifecycle.md +++ b/spec/v1/50-lifecycle.md @@ -4,7 +4,7 @@ Model-level lifecycle: what changes over time, and what the model guarantees while it is changing. Three things here have a lifecycle. A **Release Unit** switches all at once or -not at all. A **cross-Service contract** changes in two steps, never one. A +not at all. A **cross-Application contract** changes in two steps, never one. A **lock** names one pinned input set, and a new lock exists exactly when one of those inputs changes. @@ -36,7 +36,7 @@ defined. Three demands, all decided in the model rather than in the parked work: | demand | decided in | what it requires of any delivery mechanism | |---|---|---| | **Release Unit atomicity** | [0060](../../docs/adr/model/0060-release-unit.md) | no member's new version receives traffic until every member's new version is healthy; one failing member holds the whole unit | -| **Durability Class gating** | [0015](../../docs/adr/model/0015-durability-class-per-volume.md) | a destructive operation against a volume declared `recoverable` or `irreplaceable` is refused and reported, never performed; only the owning Service can state that class | +| **Durability Class gating** | [0015](../../docs/adr/model/0015-durability-class-per-volume.md) | a destructive operation against a volume declared `recoverable` or `irreplaceable` is refused and reported, never performed; only the owning Application can state that class | | **Pinned inputs only** | [0006](../../docs/adr/model/0006-pinned-inputs.md), [0034](../../docs/adr/model/0034-cluster-state-pinned-input.md) | what is applied is rendered from a named lock (Intent, Platform Intent, images lock, ClusterState snapshot), never from a live read at render time | A mechanism honouring those three is compatible with this model. Everything @@ -70,7 +70,7 @@ event produces one. | event | new lock | why | |---|---|---| -| a Service repository merges an Intent change and republishes its fragment | **yes** | a new fragment digest is a new input, whether the change was an image, a grant, an exposure or an edge | +| a Project repository merges an Intent change and republishes its fragment | **yes** | a new fragment digest is a new input, whether the change was an image, a grant, an exposure or an edge | | a fragment republishes with byte-identical content | no | digests are content-addressed, so the input set has not moved | | the Platform document is republished: a tier, a durability policy, a receiver, a provider | **yes** | the Platform document is a pinned input, republished deliberately | | the images lock resolves an alias to a new digest | **yes** | the rendered image reference changes | @@ -115,14 +115,14 @@ without diffing published artefacts. ## Release Unit switchover -Membership is structural, not declared: **a Service is the Release Unit**, and -its members are its Workloads ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)). +Membership is structural, not declared: **an Application is the Release Unit**, and +its members are its Processes ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)). Nothing names a unit, because nothing needs to: things that must switch -together are Workloads of one Service, and things that must not are separate -Services. Chapter 10's [Service identity](10-service-intent.md#service-identity) +together are Processes of one Application, and things that must not are separate +Applications. Chapter 10's [Application identity](10-project-intent.md#application-identity) carries the authoring rules; this section is the lifecycle view. -**The Service is the unit of switchover.** The rule: no member's new version receives +**The Application is the unit of switchover.** The rule: no member's new version receives traffic until every member's new version is healthy; if any member fails its budget, none switch and the old versions keep serving. @@ -134,9 +134,9 @@ Every term in that rule is already defined elsewhere in the model: | **budget** | the derived rollout budget: how long a new version has to report ready before it counts as failed | chapter 20's derived mechanics, [0030](../../docs/adr/model/0030-runtime-mechanics-derived.md) | | **switch** | the moment traffic reaches the new versions rather than the old | the delivery mechanism performs it; the model states when it may happen | -A Workload declaring `probes: none` publishes no readiness signal and so cannot -contribute to the gate. A Service must therefore declare readiness on at least -one Workload: `E_RELEASE_UNIT_NO_READINESS`, a composition-time +A Process declaring `probes: none` publishes no readiness signal and so cannot +contribute to the gate. An Application must therefore declare readiness on at least +one Process: `E_RELEASE_UNIT_NO_READINESS`, a composition-time check in [chapter 40](40-composition.md#versioning)'s estate-wide invariants, not something a delivery mechanism discovers at apply time. @@ -150,7 +150,7 @@ whoever shipped the failing member. **Rollback is unit-scoped.** Reverting one member means reverting the unit, and what a revert targets is a lock: the previous lock is the whole coherent input set, so a unit-scoped rollback is a lock-scoped operation. The cost is a larger -rollback scope than a single Service, and the benefit is that the scope is +rollback scope than a single Application, and the benefit is that the scope is consistent: there is no state in which half a unit has been reverted. **A Release Unit is not a Reconcile Unit.** @@ -158,18 +158,18 @@ consistent: there is no state in which half a unit has been reverted. | | Release Unit | Reconcile Unit | |---|---|---| | answers | what switches together | what applies before what | -| origin | **structural**, the Service boundary; its members are its Workloads | **derived** from the dependency graph ([0032](../../docs/adr/model/0032-reconcile-unit-derived.md)) | +| origin | **structural**, the Application boundary; its members are its Processes | **derived** from the dependency graph ([0032](../../docs/adr/model/0032-reconcile-unit-derived.md)) | | property | atomicity | ordering | -| worked case | Service `auth`, Workloads `auth-api` + `auth-ui`: a new UI against an old API is a broken product although each pod reports healthy | `platform-postgres` before `knowledge`: the consumer cannot start without its provider | -| membership changes when | a Workload joins or leaves the Service | an edge is added or removed | +| worked case | Application `auth`, Processes `auth-api` + `auth-ui`: a new UI against an old API is a broken product although each pod reports healthy | `platform-postgres` before `knowledge`: the consumer cannot start without its provider | +| membership changes when | a Process joins or leaves the Application | an edge is added or removed | -Atomicity follows the Service boundary rather than the dependency graph because +Atomicity follows the Application boundary rather than the dependency graph because lockstep release is a product choice the graph cannot see. The frontend depends on the API, but a dependency edge does not mean the two must cut over together; deriving atomicity from every edge takes the transitive closure and turns the estate into one unit, making every deploy estate-wide. Drawing the boundary is therefore the decision, and a pair that must release together but cannot be one -Service is evidence the boundary is drawn wrong. +Application is evidence the boundary is drawn wrong. ![Release Unit switchover](diagrams/50-release-unit-switchover.drawio.svg) @@ -178,8 +178,8 @@ Service is evidence the boundary is drawn wrong. ## Expand and contract A Release Unit makes lockstep safe *inside* the unit. Everything else that -crosses a Service boundary, a surface, a port, a derived value, a name another -Service references, changes in two steps, because the estate's Services merge +crosses an Application boundary, a surface, a port, a derived value, a name another +Application references, changes in two steps, because the estate's Applications merge and publish independently and no moment exists at which they all move. The rule is asymmetric, and composition can enforce it because the union puts @@ -196,7 +196,7 @@ edges (chapter 16): | change | at lock N | verdict | |---|---|---| -| `auth-api` allows an origin whose Service is not deployed yet | additive | harmless | +| `auth-api` allows an origin whose Application is not deployed yet | additive | harmless | | `auth-api` stops allowing an origin that is still live at N−1 | removal | broken, the consumer loses access on the switch | The three phases, and what composition sees at each: @@ -238,8 +238,8 @@ it is decided in `docs/adr/`; everything inside it is decided separately. [0071](../../docs/adr/model/0071-release-gate-inputs-are-layer-2.md)'s own settling test, a switchover mechanism written against a `ResolvedService` projection alone, and is recorded there. [0060](../../docs/adr/model/0060-release-unit.md), - which this item used to cite, is superseded; the unit is the Service - ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)). + which this item used to cite, is superseded; the unit is the Application + ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)). 2. ~~**Whether a unit may span ownership boundaries.**~~ Not this chapter's question: whatever ownership boundary a delivery definition introduces is that definition's, and the item belongs in @@ -264,31 +264,31 @@ an ADR. ```mermaid flowchart LR - N["new lock renders
every Workload of the Service"] --> A1["auth-api
new version starts"] + N["new lock renders
every Process of the Application"] --> A1["auth-api
new version starts"] N --> A2["auth-ui
new version starts"] A1 --> P1{"readiness
within budget?"} A2 --> P2{"readiness
within budget?"} - P1 -->|yes| K{"every Workload
ready?"} + P1 -->|yes| K{"every Process
ready?"} P2 -->|yes| K - P1 -->|no| H["hold the Service
old versions keep serving"] + P1 -->|no| H["hold the Application
old versions keep serving"] P2 -->|no| H - K -->|yes| SW["switch all Workloads together"] - H --> RB["fix forward, or revert the Service
to the previous lock"] + K -->|yes| SW["switch all Processes together"] + H --> RB["fix forward, or revert the Application
to the previous lock"] ``` ### The change, end to end ```mermaid flowchart TB - I["Intent change merged
one Service repository"] --> F["Intent Fragment republished
OCI, by digest"] + I["Intent change merged
one Project repository"] --> F["Intent Fragment republished
OCI, by digest"] X["Platform document republished
tiers, policies, providers"] --> C F --> C["composition
union + estate-wide invariants"] S["ClusterState snapshot changes
PV rebinds, node joins or leaves"] --> C C --> L["new lock
fragments + context + images + clusterStateDigest"] L --> R["render
registered adapters, renderHash"] - R --> G{"Service has more
than one Workload?"} + R --> G{"Application has more
than one Process?"} G -->|"no"| D["delivery and co-testing
defined separately
docs/adr/deferred/"] - G -->|"yes"| U["all-or-nothing switchover
gated on every Workload ready"] + G -->|"yes"| U["all-or-nothing switchover
gated on every Process ready"] U --> D C -.->|"E_CONTRACT_TOO_EARLY"| B["no lock.
Nothing renders."] style D stroke-width:2px,stroke-dasharray:6 4; diff --git a/spec/v1/60-setup.md b/spec/v1/60-setup.md index 906765c..4d2286b 100644 --- a/spec/v1/60-setup.md +++ b/spec/v1/60-setup.md @@ -1,7 +1,7 @@ # Chapter 60: Setup and adoption How to stand this up, what facts have to exist before anything renders, and how -to move ~30 live Services onto the model without deleting any of them. +to move ~30 live Applications onto the model without deleting any of them. Two boundaries apply throughout. **Delivery mechanics and co-testing are defined separately** ([chapter 50](50-lifecycle.md#delivery-and-co-testing-are-defined-separately), @@ -23,14 +23,14 @@ depend on the previous step's output existing. Steps 1–3 look like paperwork and are not: they are the facts every later step reads, and since [0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md) -every Workload's declared `memory` and `cpu` are compared against numbers step 1 +every Process's declared `memory` and `cpu` are compared against numbers step 1 publishes, so step 1 is arithmetic that other repositories' builds now fail against. Step 2 is the whole of what is applied by hand, and it is a table in the Platform document ([chapter 14](14-platform-intent.md#the-bootstrap-set), [0099](../../docs/adr/model/0099-bootstrap-set-is-recorded.md)); everything after it is rendered. Step 4 is new: the foundation is declared ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)), so Vault, -VSO, Traefik and the metrics stack publish like any domain and tenants render +VSO, Traefik and the metrics stack publish like any project and tenants render against them. Step 5 exists because layer 2 may read a pinned snapshot and may never read the live cluster ([0034](../../docs/adr/model/0034-cluster-state-pinned-input.md)). @@ -63,7 +63,7 @@ is the other half of that comparison and its shape is load-bearing: | published fact | shape | what matches against it | |---|---|---| -| `allocatable.cpu`, `allocatable.memory` | quantities, the node total minus the reserve declared in the node file | `placement.cpu` and `placement.memory`, required on every Workload | +| `allocatable.cpu`, `allocatable.memory` | quantities, the node total minus the reserve declared in the node file | `placement.cpu` and `placement.memory`, required on every Process | | `site` | one string | `placement.site` | | `arch` | one string | `placement.arch`, which is a set of acceptable values | | `gpus[]` | one entry per card: `vendor`, `model`, `class`, `memory_mib` | `placement.gpu`, by `class` and by memory | @@ -99,7 +99,7 @@ outside the pinned input set ([0006](../../docs/adr/model/0006-pinned-inputs.md) spread.** An authored reserve is an assertion about what the kubelet, the container runtime, the OS and the k3s agent take before a pod gets anything, and nothing validates it until someone compares it with `kubectl describe node`. -Get it wrong and the contract says a Workload fits while the scheduler refuses +Get it wrong and the contract says a Process fits while the scheduler refuses to place it, a build that passes and a pod that stays `Pending`. It bites first on `enschede-pi-2` and `enschede-pi-3`: at 4096Mi total, a 512Mi error is an eighth of the machine, where the same 512Mi against `frankfurt-contabo-1`'s @@ -107,7 +107,7 @@ eighth of the machine, where the same 512Mi against `frankfurt-contabo-1`'s an implementation detail. Allocatable is also an **eligibility bound, not a budget**. The contract records -what a node has, not what is already running on it, so three Workloads each +what a node has, not what is already running on it, so three Processes each declaring `memory: 2Gi` all pass against a 4096Mi node and the scheduler refuses the third at apply. Nothing in this chapter, and nothing in layer 2, bin-packs. @@ -120,7 +120,7 @@ capability vocabulary with node counts: `adguard` (5), `lan-ingress` (3), `nvidia` (2), `samba` (1), `public-ingress` (1), `llm-host` (1), `backup-store` (1), `amd-gpu` (1). No node carries a taint. The two GPU strings stay node facts and -stop being how a Workload asks for a GPU: `nvidia` cannot separate a 2048MiB +stop being how a Process asks for a GPU: `nvidia` cannot separate a 2048MiB Maxwell from a T1000, and `enschede-rx7900xtx-1` (the fastest card in the estate) is not `nvidia` at all. That selection is `gpus[]`. @@ -139,7 +139,7 @@ capabilities, never labels** resolvable because the contract advertises exactly one label set and one set of facts to validate against. And the selector key comes from the contract's prefix rather than from `platform.name`, so retiring `personal-stack/*` changes -rendered output for every Workload carrying a `nodeSelector`. Retirement goes +rendered output for every Process carrying a `nodeSelector`. Retirement goes through the generated contract, never a `kubectl label`, the estate agent contract records that a hand-applied label drifts back on the next reconcile. @@ -188,7 +188,7 @@ So the numbers are split by how they are obtained: | number | value | how it is obtained | |---|---|---| -| **RPO** | **24 hours** | stated, not measured, it is the daily node backup's period. A Workload wanting better declares `durability: recoverable` and gets an application-level backup job with a retention sweep ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)) | +| **RPO** | **24 hours** | stated, not measured, it is the daily node backup's period. A Process wanting better declares `durability: recoverable` and gets an application-level backup job with a retention sweep ([0015](../../docs/adr/model/0015-durability-class-per-volume.md)) | | **RTO** | **no number until the drill runs** | measured: wall time from a destroyed claim to a passing readiness probe. This record refuses to invent one | **A restore is rehearsed before the first production apply of an @@ -206,10 +206,10 @@ per-consumer database credentials ([0080](../../docs/adr/model/0080-database-catalog-is-derived-data.md)) are estate-unique and draw on a shared resource, so they are platform-assigned ([0004](../../docs/adr/model/0004-contention-decides-authority.md)). They are -Assets of the declared `vault` Service in the platform's secrets domain +Assets of the declared `vault` Application in the platform's secrets project ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)), declarative -Vault configuration, rendered and attributed like any Asset, never per-Service -render. What per-Service render owns is the part that varies per Workload: one +Vault configuration, rendered and attributed like any Asset, never per-Application +render. What per-Application render owns is the part that varies per Process: one derived policy and one auth role per identity ([chapter 30](30-deliverables.md#vault-configuration-is-rendered-not-applied)). Vault's **unseal** alone is a bootstrap fact @@ -234,7 +234,7 @@ without acquiring an owner. v1 makes it mechanical Reading a pinned input rather than the live cluster keeps the check inside layer-2 purity. `delivery: self` and `access: custody` persist nothing and are -unaffected, so a Workload that speaks Vault itself (`auth-api` does, through +unaffected, so a Process that speaks Vault itself (`auth-api` does, through spring-cloud-vault) is never blocked by this gate. Two limits, stated so the gate is not read as more than it is. The fact is @@ -244,8 +244,8 @@ the datastore-file and backup path only, a token with API read still gets plaintext, so path grants ([0009](../../docs/adr/model/0009-vault-read-is-per-path.md), [0023](../../docs/adr/model/0023-grant-unit-is-the-path.md)) and RBAC remain the real -boundary. A namespace is not one: it holds several Services by construction -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). +boundary. A namespace is not one: it holds several Applications by construction +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)). Ticked by: enable the flag, then `kubectl create secret generic canary --from-literal=k=`, then @@ -270,7 +270,7 @@ kube-router controller that has none either. A non-enforcing stage is a **vendor capability**, and no decision had ever picked a CNI: a repo-wide grep for `cilium|calico|kube-router|flannel` returned zero hits outside the review files. The item was unsatisfiable, so it was either going to be ignored (landing -default-deny as enforce across ~30 workloads on a cluster known to contain +default-deny as enforce across ~30 processes on a cluster known to contain undeclared paths) or nothing would ship. The direction is **Cilium**, for the two properties @@ -292,7 +292,7 @@ API server and the datastore. The agent's own footprint is a placement fact, not a free variable: sampled per-node memory has to fit inside the same allocatable the [node contract](#node-facts) publishes, and on the two 4096Mi Pis it comes out -of the reserve every Workload's `memory` is then compared against. +of the reserve every Process's `memory` is then compared against. Installing it restarts the control-plane node's k3s server with flannel and the bundled controller disabled, interrupting east-west traffic on the machine that @@ -303,7 +303,7 @@ scheduled operation, not a step in a bootstrap script. There are none. The foundation the packs delivered (41 objects copied from `flux-modules` at a git ref that this chapter used to describe as *"recorded, not -verified"*) is **declared** as Services of the platform domains and rendered +verified"*) is **declared** as Applications of the platform projects and rendered like everything else ([0096](../../docs/adr/model/0096-the-foundation-is-declared.md)), and the CRDs among them are the bootstrap set ([chapter 14](14-platform-intent.md#the-bootstrap-set)). @@ -312,55 +312,55 @@ decided how packs arrive, is superseded: nothing arrives that way. The `flux-packs` and `flux-source` adapters, and the `--blueprints-root` and `--blueprints-version` inputs they needed, do not exist. -## Onboarding a new Service +## Onboarding a new Application -Start from [`examples/minimal/`](examples/minimal/README.md): one domain, one -Service, one Workload, and nothing that is not required. A Service with no +Start from [`examples/minimal/`](examples/minimal/README.md): one project, one +Application, one Process, and nothing that is not required. An Application with no storage and no secrets is a copy of that file with three names changed. -A Service is added **to a domain file**, not to a repository of its own. One -file per domain holds many Services, that file is one Intent Fragment, and a -domain never spans repositories -([0063](../../docs/adr/model/0063-intent-authored-per-domain.md)). The namespace is -derived (`-system`), so no step below names one. - -1. Add the Service to its domain file, whose shape is - [chapter 10](10-service-intent.md#two-artefacts). The file header carries - `domain` and `owner`, the only field raised to the domain; the Service - carries `id`, `alertClass`, `secrets[]` and its Workloads; each Workload - carries its `image`, the ports it `provides`, and its `placement`. Workload - names are unique within the domain (`E_DUPLICATE_WORKLOAD_NAME`), because the - ServiceAccount and the Vault role are the Workload name alone - ([0024](../../docs/adr/model/0024-identity-per-workload.md)). Two Workloads that - must switch together belong to one Service: a Service is the unit of atomic - release ([0062](../../docs/adr/model/0062-service-is-the-release-unit.md)), and +An Application is added **to a project file**, not to a repository of its own. One +file per project holds many Applications, that file is one Intent Fragment, and a +project never spans repositories +([0063](../../docs/adr/model/0063-intent-authored-per-project.md)). The namespace is +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 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 + ([0024](../../docs/adr/model/0024-identity-per-process.md)). Two Processes that + must switch together belong to one Application: an Application is the unit of atomic + release ([0062](../../docs/adr/model/0062-application-is-the-release-unit.md)), and there is no field that couples two of them. -2. Write `platform/env//base.env` **per Workload** (never one file per - Service), plus a cluster overlay only where something differs. +2. Write `platform/env//base.env` **per Process** (never one file per + Application), plus a cluster overlay only where something differs. 3. Declare `secrets[]` at the level they are shared, each entry carrying `path`, - `keys`, `access`, `delivery` and `rotation`. They stay on the Service and are - never raised to the domain header, which would hand every Service in the file + `keys`, `access`, `delivery` and `rotation`. They stay on the Application and are + never raised to the project header, which would hand every Application in the file a reader slot on a path it may not need ([0009](../../docs/adr/model/0009-vault-read-is-per-path.md)). Reference env-delivered values as `${secret:#}`, where the path - **byte-matches** a granted path, and cross-Service values as + **byte-matches** a granted path, and cross-Application values as `${dependency:…}`. The declaration and the env file check each other in both directions. -4. Declare `placement` on **every** Workload: `memory` and `cpu` are required ( +4. Declare `placement` on **every** Process: `memory` and `cpu` are required ( this field exists to end BestEffort as the estate's standing QoS class), and `arch`, `site`, `disk`, `gpu` and `capabilities` are declared only where they are true. Every declared dimension is hard, a list is a set of equally acceptable values, and a dimension no node can satisfy is `E_PLACEMENT_UNSATISFIABLE` at build ([0061](../../docs/adr/model/0061-placement-is-hard-dimensions.md)). -5. Declare the rest of the runtime intent only this Service knows: any +5. Declare the rest of the runtime intent only this Application knows: any `writablePaths` the process needs against the `restricted` default; `durability` per volume: `reconstructible`, `recoverable` or `irreplaceable`; and `probes.readiness` / `probes.liveness`, each with its own `path` + `port` or `tcp`, or `probes: none` stated explicitly where there is nothing to probe. 6. Add `.github/workflows/publish-fragment.yml` - ([example](examples/workflows/service-publish-fragment.yml)). + ([example](examples/workflows/project-publish-fragment.yml)). 7. Register the repository in `participants.yml` with its staleness bound: `maxAge` defaults to **7 days** ([0038](../../docs/adr/model/0038-participants-list-staleness.md)). @@ -368,14 +368,14 @@ derived (`-system`), so no step below names one. and the resulting `resolved.yml` projection is published back to the repository ([0033](../../docs/adr/model/0033-assignments-published-back.md)). -Step 7 is the one a Service cannot do for itself, and it fails loudly rather +Step 7 is the one an Application cannot do for itself, and it fails loudly rather than silently: an unregistered participant is invisible to composition, so its -Services simply are not in the union. Any additional onboarding the delivery +Applications simply are not in the union. Any additional onboarding the delivery definition requires is that definition's, not this list's. -## Adopting a live Service +## Adopting a live Application -Adoption is a **source swap**: the objects a Service already has stop being +Adoption is a **source swap**: the objects an Application already has stop being hand-written and start being rendered. The model half of that is checkable before anything is applied, and it is the half worth doing carefully. @@ -392,11 +392,11 @@ before anything is applied, and it is the half worth doing carefully. 4. **Swap the source** once the render reproduces the live objects. Two differences are expected at step 2 and are not adapter gaps. The namespace -is one the Service already runs in: `-system` reproduces all ten live -namespaces and renames nothing. The resource block is not: a Workload that runs +is one the Application already runs in: `-system` reproduces all ten live +namespaces and renames nothing. The resource block is not: a Process that runs BestEffort today renders with a `memory` request equal to its limit and a `cpu` request with no limit, from the `placement` numbers someone has to choose: the -first honest reading of what these Services actually need, and the one part of +first honest reading of what these Applications actually need, and the one part of adoption that is authoring rather than transcription. Step 4 is delivery, and it is where adoption is dangerous: a source that prunes @@ -414,18 +414,18 @@ adopting an estate that was hand-written first. ## Adoption order across the estate -Adopt one domain file at a time, in dependency order, providers before +Adopt one project file at a time, in dependency order, providers before consumers, so a consumer is never rendered against a provider that has published no fragment: ``` 1. node facts + the Platform document nothing depends on them; everything reads them -2. the platform domains edge, secrets, observability -- the foundation, +2. the platform projects edge, secrets, observability -- the foundation, declared; every tenant depends on it -3. data postgres, valkey, rabbitmq -- 8 Services depend on them +3. data postgres, valkey, rabbitmq -- 8 Applications depend on them 4. auth every forward-auth route and OIDC consumer 5. knowledge depends on data -6. agents depends on knowledge and the secrets domain +6. agents depends on knowledge and the secrets project 7. media the largest set, the sparsest edges, the lowest blast radius 8. mail, notes, app, the remainder automation, utility @@ -435,11 +435,11 @@ no fragment: estate, so it is where the derivation is proven: inbound CORS origins and forward-auth middleware. It is also where the release rule shows its teeth. The estate's clearest lockstep pair, `auth-api` and `auth-ui`, is not a pair of -Services to couple: under -[0062](../../docs/adr/model/0062-service-is-the-release-unit.md) it is one Service, -`auth`, holding two Workloads that switch together or not at all. Their images +Applications to couple: under +[0062](../../docs/adr/model/0062-application-is-the-release-unit.md) it is one Application, +`auth`, holding two Processes that switch together or not at all. Their images still build wherever they build; what moves into one file is the intent, and -with it the atomicity claim, which is now checkable by reading a single Service. +with it the atomicity claim, which is now checkable by reading a single Application. `media` late is also deliberate: sparse edges mean a clean render says less, so it should run on machinery already trusted. @@ -462,7 +462,7 @@ owns it. One item is blocked rather than open, and says so. node file rather than in the contract, the two 4096Mi Pis first, where the reserve is a large fraction of the machine. Owner: joris. Blocks: the first placement-gated apply: `memory` and `cpu` are required on every - Workload and nothing can be matched against a contract that does not + Process and nothing can be matched against a contract that does not publish allocatable. - [ ] **Platform facts are recorded and validate.** Datastore kind, server count, k3s version, server flag set, CNI, `secretsEncryption`. Ticked by: @@ -482,9 +482,9 @@ owns it. One item is blocked rather than open, and says so. those, since `delivery: self` persists nothing. - [ ] **The bootstrap set is applied and recorded, and the foundation renders.** Ticked by: the four bootstrap entries present in the Platform document - with versions, and the platform domains rendering Vault, VSO, Traefik and + with versions, and the platform projects rendering Vault, VSO, Traefik and the metrics stack with no pack file and no raw manifest anywhere in the - tree. Owner: joris. Blocks: every tenant domain, which renders against the + tree. Owner: joris. Blocks: every tenant project, which renders against the foundation. - [ ] **The ClusterState collector exists and its digest is in the lock.** Ticked by: two captures ten minutes apart against an idle cluster @@ -501,7 +501,7 @@ owns it. One item is blocked rather than open, and says so. - [ ] **One negative fixture exists per invariant, and composition runs them.** Ticked by: [`compose.yml`](examples/workflows/compose.yml) proving each gate can fail (`E_PLACEMENT_UNSATISFIABLE` and - `E_DUPLICATE_WORKLOAD_NAME` included, since both are new), because an + `E_DUPLICATE_PROCESS_NAME` included, since both are new), because an assertion that stopped running looks identical to one that passes. Owner: the toolkit maintainer. Blocks: relying on any estate-wide invariant as evidence. @@ -509,7 +509,7 @@ owns it. One item is blocked rather than open, and says so. `n8n-hooks` (499 lines of JavaScript) and the `garage` bootstrap leave their ConfigMaps and become first-party images, retiring the `alpine:3.21` plus ConfigMap pattern; `postgres-init-script` needs no image because it - becomes derived. Ticked by: no executable Asset remaining in any Service + becomes derived. Ticked by: no executable Asset remaining in any Project Intent ([0012](../../docs/adr/model/0012-assets-not-code.md)). Owners: the owners of `hermes`, `garage` and `n8n`. Blocks: rendering the current cluster from intent. @@ -541,10 +541,10 @@ flowchart TB A["1. node facts
one YAML per node, site, allocatable cpu and memory,
structured gpus and disks, capabilities;
contract generated, nix imports the labels"] B["2. the bootstrap set applied
k3s, the Flux source, Vault unsealed,
the CRDs, recorded in the Platform document"] C["3. Platform document published
substrate facts, tiers, policies, engines,
providers, an Intent Fragment by digest"] - D["4. platform domains published
edge, secrets, observability,
the foundation, as Services"] + D["4. platform projects published
edge, secrets, observability,
the foundation, as Applications"] E["5. ClusterState collector
snapshot plus clusterStateDigest"] - F["6. participants.yml
the platform and every domain, plus maxAge"] - G["7. one tenant domain publishes
one Intent Fragment, holding its Services"] + F["6. participants.yml
the platform and every project, plus maxAge"] + G["7. one tenant project publishes
one Intent Fragment, holding its Applications"] H["8. composition runs
estate-wide invariants over the union"] I["9. render
six adapters, into the tree the Flux source pulls"] diff --git a/spec/v1/diagrams/00-overview-meta-model.drawio.svg b/spec/v1/diagrams/00-overview-meta-model.drawio.svg index d00d492..a061275 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 domain file they sit beside.Service Intent, in each owning repositoryPlatform Intent, in the platform'sdomains/<domain>.ymlservices, workloads, placement,writable paths, durability, probes,exposure, observability, secretsenv/<workload>/*.envone set per Workloadassetsdeclarative,never executableplatform.ymlsubstrate facts, bootstrap set,tiers, durability policy, hardening,cadences, engines, providersplatform domain filesthe foundation,declared as ServicesIntent 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 Servicelayer 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'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 diff --git a/spec/v1/diagrams/10-project-intent-model.drawio.svg b/spec/v1/diagrams/10-project-intent-model.drawio.svg new file mode 100644 index 0000000..ff93018 --- /dev/null +++ b/spec/v1/diagrams/10-project-intent-model.drawio.svg @@ -0,0 +1,4 @@ + + + +Asset+ Path from+ Path mountAt+ map substituteCapacity+ int count+ string reasonDependencyEdge+ ApplicationId application+ string surface+ bool requiredDiskRequest+ Media[] mediaProject+ ProjectName project+ 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 process+ string surface+ Audience audience+ Path redirectToScrape+ string process+ string surface+ Path pathService+ ApplicationId idSidecar+ string name+ ImageAlias image+ Quantity memory+ Quantity cpuSurface+ string name+ int portVolume+ string claim+ Path mountAt+ Quantity size+ DurabilityClass durabilityProcess+ string name+ Lifecycle lifecycle+ ImageAlias image+ Runtime runtime+ Engine engine+ Duration startupBudget+ Cutover cutover+ bool stateful+ Path[] writablePaths1..* applications1..* processes0..* provides0..* sidecars0..* dependsOn0..1 readiness0..1 liveness0..* assets0..* volumes1 placement0..1 observability1 scrape0..1 replicas0..1 disk0..1 gpu0..* exposure1..* routes1..* env per process0..* resolves0..* secrets0..1 rotation0..* secrets«resolves by name» \ No newline at end of file diff --git a/spec/v1/diagrams/10-service-intent-model.drawio.svg b/spec/v1/diagrams/10-service-intent-model.drawio.svg deleted file mode 100644 index 590cfd6..0000000 --- a/spec/v1/diagrams/10-service-intent-model.drawio.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -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 diff --git a/spec/v1/diagrams/16-derivation-map-assignments.drawio.svg b/spec/v1/diagrams/16-derivation-map-assignments.drawio.svg index d285ce9..cdc0945 100644 --- a/spec/v1/diagrams/16-derivation-map-assignments.drawio.svg +++ b/spec/v1/diagrams/16-derivation-map-assignments.drawio.svg @@ -1,4 +1,4 @@ -namespace · <domain>-systemroute tier · + middleware chainroute priorityReconcile Unit + DAGrelease gate · members + deadlineidentity name · the workload nameSecret Store path grant · + deriv…image digestrunAsUser, runAsGroup, · fsGroupreplicasrequests + limitssecurityContextrollout strategy · + surgeprobe cadence · + startup targetstartupDeadlineautomountTokenwritable emptyDirs · + sizeLimitnodeSelector + affinityrecorded PV bindingbackup terms · window, retention,…the label setthe path plandomainowneridobservability · alertClass + scrapeworkload nameprovides · surface: portdependsOnimageruntimeengineenv filessecrets · path, access, deliveryassetsexposure · name, host, audience, · …probes · readiness + livenessstartupBudgetcutoverlifecyclestatefulvolumes · + size + durabilitywritablePathsplacement · memory, cpu, arch, site, …replicas: count, reasonPlatform Intent · tiers, policies, en…node contract · allocatable, gpus, di…ClusterState snapshotimages lock · digest + uid + gid403020111102022310041225121212312221112321232332outA mark is one derivation. Read a row to the right: the declared field or pinned fact, and everyassignment it decides. Read a column down: the assignment, and every input it rests on.Totality is the in row: no column reads zero, so no assignment is invented by a renderer.A zero in the out column is not a fault here. Those fields reach a Deliverable with no assignmentin between, which is the second drawing; a field that reaches nothing in either is a deaddeclaration and fails the build.Blue rows are declared in Service Intent. Grey rows are the pinned input set of chapter 20,carried by digest. Every declared row reaches at least one assignment or one Deliverable; a rowthat reaches neither is a dead declaration and fails the build.2 \ No newline at end of file +namespace · <project>-systemroute tier · + middleware chainroute priorityReconcile Unit + DAGrelease gate · members + deadlineidentity name · the process nameSecret Store path grant · + deriv…image digestrunAsUser, runAsGroup, · fsGroupreplicasrequests + limitssecurityContextrollout strategy · + surgeprobe cadence · + startup targetstartupDeadlineautomountTokenwritable emptyDirs · + sizeLimitnodeSelector + affinityrecorded PV bindingbackup terms · window, retention,…the label setthe path planprojectowneridobservability · alertClass + scrapeprocess nameprovides · surface: portdependsOnimageruntimeengineenv filessecrets · path, access, deliveryassetsexposure · name, host, audience, · …probes · readiness + livenessstartupBudgetcutoverlifecyclestatefulvolumes · + size + durabilitywritablePathsplacement · memory, cpu, arch, site, …replicas: count, reasonPlatform Intent · tiers, policies, en…node contract · allocatable, gpus, di…ClusterState snapshotimages lock · digest + uid + gid403020111102022310041225121212312221112321232332outA mark is one derivation. Read a row to the right: the declared field or pinned fact, and everyassignment it decides. Read a column down: the assignment, and every input it rests on.Totality is the in row: no column reads zero, so no assignment is invented by a renderer.A zero in the out column is not a fault here. Those fields reach a Deliverable with no assignmentin between, which is the second drawing; a field that reaches nothing in either is a deaddeclaration and fails the build.Blue rows are declared in Project Intent. Grey rows are the pinned input set of chapter 20,carried by digest. Every declared row reaches at least one assignment or one Deliverable; a rowthat reaches neither is a dead declaration and fails the build.2 \ No newline at end of file diff --git a/spec/v1/diagrams/16-derivation-map-deliverables.drawio.svg b/spec/v1/diagrams/16-derivation-map-deliverables.drawio.svg index ce4fa77..3864fcc 100644 --- a/spec/v1/diagrams/16-derivation-map-deliverables.drawio.svg +++ b/spec/v1/diagrams/16-derivation-map-deliverables.drawio.svg @@ -1,4 +1,4 @@ -Deployment / StatefulSet / · Job …ServiceServiceAccountConfigMap · + derived catalogsPersistentVolumeClaimPodDisruptionBudgetVaultStaticSecret / · VaultDynami…Vault policy + auth roleIngressRouteMiddlewareNetworkPolicybackup CronJob + sweepkustomizationresolved.yml · the projectiondomainowneridobservability · alertClass + scrapeworkload nameprovides · surface: portdependsOnimageruntimeengineenv filessecrets · path, access, deliveryassetsexposure · name, host, audience, · …probes · readiness + livenessstartupBudgetcutoverlifecyclestatefulvolumes · + size + durabilitywritablePathsplacement · memory, cpu, arch, site, …replicas: count, reasonnamespace · <domain>-systemroute tier · + middleware chainroute priorityReconcile Unit + DAGrelease gate · members + deadlineidentity name · the workload nameSecret Store path grant · + derived r…image digestrunAsUser, runAsGroup, · fsGroupreplicasrequests + limitssecurityContextrollout strategy · + surgeprobe cadence · + startup targetstartupDeadlineautomountTokenwritable emptyDirs · + sizeLimitnodeSelector + affinityrecorded PV bindingbackup terms · window, retention, met…the label setthe path plan010212200022230001110003210132212111111112121173132122315127outThe same reading. A blue mark is a declared value that reaches the object with no assignment inbetween; an amber mark comes from a layer-2 assignment.In-degree at least one is the in row: nothing is rendered that no declaration or assignment produced.A zero in the out column means only that this row reaches no object directly. A declared fieldwith no mark in either drawing is a dead declaration, and an assignment with no mark in this oneis consumed by ordering rather than serialised; the Reconcile Unit is the one such row.ServiceMonitor / PodMonitor1 \ No newline at end of file +Deployment / StatefulSet / · Job …ServiceServiceAccountConfigMap · + derived catalogsPersistentVolumeClaimPodDisruptionBudgetVaultStaticSecret / · VaultDynami…Vault policy + auth roleIngressRouteMiddlewareNetworkPolicybackup CronJob + sweepkustomizationresolved.yml · the projectionprojectowneridobservability · alertClass + scrapeprocess nameprovides · surface: portdependsOnimageruntimeengineenv filessecrets · path, access, deliveryassetsexposure · name, host, audience, · …probes · readiness + livenessstartupBudgetcutoverlifecyclestatefulvolumes · + size + durabilitywritablePathsplacement · memory, cpu, arch, site, …replicas: count, reasonnamespace · <project>-systemroute tier · + middleware chainroute priorityReconcile Unit + DAGrelease gate · members + deadlineidentity name · the process nameSecret Store path grant · + derived r…image digestrunAsUser, runAsGroup, · fsGroupreplicasrequests + limitssecurityContextrollout strategy · + surgeprobe cadence · + startup targetstartupDeadlineautomountTokenwritable emptyDirs · + sizeLimitnodeSelector + affinityrecorded PV bindingbackup terms · window, retention, met…the label setthe path plan010212200022230001110003210132212111111112121173132122315127outThe same reading. A blue mark is a declared value that reaches the object with no assignment inbetween; an amber mark comes from a layer-2 assignment.In-degree at least one is the in row: nothing is rendered that no declaration or assignment produced.A zero in the out column means only that this row reaches no object directly. A declared fieldwith no mark in either drawing is a dead declaration, and an assignment with no mark in this oneis consumed by ordering rather than serialised; the Reconcile Unit is the one such row.ServiceMonitor / PodMonitor1 \ No newline at end of file diff --git a/spec/v1/diagrams/16-edge-derives.drawio.svg b/spec/v1/diagrams/16-edge-derives.drawio.svg index 8e31aa5..85814b0 100644 --- a/spec/v1/diagrams/16-edge-derives.drawio.svg +++ b/spec/v1/diagrams/16-edge-derives.drawio.svg @@ -1,4 +1,4 @@ -dependsOn{service, surface, required}Reconcile Unit orderingapps-knowledge after apps-datadependency coordinates${dependency:platform-postgres.host}NetworkPolicy egressallow postgres:5432allow rule only:no ordering, no startup gate+ baselineUDP/53 to the cluster DNS servicedefault-deny posturerequired: falseonly when the edge set is completeA dashed border means the edge derives nothing here. \ No newline at end of file +dependsOn{application, surface, required}Reconcile Unit orderingapps-knowledge after apps-datadependency coordinates${dependency:platform-postgres.host}NetworkPolicy egressallow postgres:5432allow rule only:no ordering, no startup gate+ baselineUDP/53 to the cluster DNS servicedefault-deny posturerequired: falseonly when the edge set is completeA dashed border means the edge derives nothing here. \ No newline at end of file diff --git a/spec/v1/diagrams/16-exposure-trace.drawio.svg b/spec/v1/diagrams/16-exposure-trace.drawio.svg index faaf5f3..63e8f49 100644 --- a/spec/v1/diagrams/16-exposure-trace.drawio.svg +++ b/spec/v1/diagrams/16-exposure-trace.drawio.svg @@ -1,4 +1,4 @@ -exposure - on the Servicename: kbhost: kb.jorisjonkers.devaudience: authenticatedroutes: 5host, carried throughkb.jorisjonkers.devtier public-frankfurt+ forward-auth middlewarederived from audience + tierreachability channel entryedge catalog - an Asset of the Traefik Serviceedge route catalog - the sameGatus endpoint - an Asset of the gatus ServiceIngressRoute - the host routeIngressRoute - the mcp routesTwo authored values - a host and an audience - reach six Deliverables and one tier assignment.The two routes are the only Deliverables the tier touches, so they sit at the bottom of the stack. \ No newline at end of file +exposure - on the Applicationname: kbhost: kb.jorisjonkers.devaudience: authenticatedroutes: 5host, carried throughkb.jorisjonkers.devtier public-frankfurt+ forward-auth middlewarederived from audience + tierreachability channel entryedge catalog - an Asset of the Traefik Applicationedge route catalog - the sameGatus endpoint - an Asset of the gatus ApplicationIngressRoute - the host routeIngressRoute - the mcp routesTwo authored values - a host and an audience - reach six Deliverables and one tier assignment.The two routes are the only Deliverables the tier touches, so they sit at the bottom of the stack. \ No newline at end of file diff --git a/spec/v1/diagrams/20-resolved-deployment-io.drawio.svg b/spec/v1/diagrams/20-resolved-deployment-io.drawio.svg index 3ccfe88..90fefed 100644 --- a/spec/v1/diagrams/20-resolved-deployment-io.drawio.svg +++ b/spec/v1/diagrams/20-resolved-deployment-io.drawio.svg @@ -1,4 +1,4 @@ -Pinned inputs - each carried by digestOutputsIntent Fragmentthis domain's file + env/Intent Fragmentsof every other domainPlatform Intenttiers, policies, engines, providersnode contractsite, allocatable, gpus, disksimages lockdigests, uid, gidClusterState snapshotPV bindings, current placementsResolvedDeploymentone document, the whole estateDeliverable Setlayer 3, per adapterResolvedServicethe per-Service projectionrenderHash + inputDigestspublished back as a pull requestIdentical inputs, identical output, always. \ No newline at end of file +Pinned inputs - each carried by digestOutputsIntent Fragmentthis project's file + env/Intent Fragmentsof every other projectPlatform Intenttiers, policies, engines, providersnode contractsite, allocatable, gpus, disksimages lockdigests, uid, gidClusterState snapshotPV bindings, current placementsResolvedDeploymentone document, the whole estateDeliverable Setlayer 3, per adapterResolvedServicethe per-Application projectionrenderHash + inputDigestspublished back as a pull requestIdentical inputs, identical output, always. \ No newline at end of file diff --git a/spec/v1/diagrams/30-render-pipeline.drawio.svg b/spec/v1/diagrams/30-render-pipeline.drawio.svg index 7896d19..cb54287 100644 --- a/spec/v1/diagrams/30-render-pipeline.drawio.svg +++ b/spec/v1/diagrams/30-render-pipeline.drawio.svg @@ -1,4 +1,4 @@ -every repository - publishcentral render - once, over the ComposedIntentIntent Fragmenta domain file, or the Platform documentpushed by digestResolved Deployment+ path plan + clusterStateDigestkubernetesnetworkingprometheustraefikvault-policyvsoDeliverables{path, content, adapter}E_PATH_COLLISION on the planone owner per pathrenderHashover the pinned inputsDeliverable Setthe file treecoverage assertionvs the bootstrap set + ledgersdeliverydefined separatelyNo adapter runs at publish time. Everyderivation happens once, centrally, over thecomposed union.Each adapter owns one subsystem, and thepath plan gives every file one owner beforeany of them runs. \ No newline at end of file +every repository - publishcentral render - once, over the ComposedIntentIntent Fragmenta project file, or the Platform documentpushed by digestResolved Deployment+ path plan + clusterStateDigestkubernetesnetworkingprometheustraefikvault-policyvsoDeliverables{path, content, adapter}E_PATH_COLLISION on the planone owner per pathrenderHashover the pinned inputsDeliverable Setthe file treecoverage assertionvs the bootstrap set + ledgersdeliverydefined separatelyNo adapter runs at publish time. Everyderivation happens once, centrally, over thecomposed union.Each adapter owns one subsystem, and thepath plan gives every file one owner beforeany of them runs. \ No newline at end of file diff --git a/spec/v1/diagrams/40-composition-run.drawio.svg b/spec/v1/diagrams/40-composition-run.drawio.svg index 8719944..da30ea7 100644 --- a/spec/v1/diagrams/40-composition-run.drawio.svg +++ b/spec/v1/diagrams/40-composition-run.drawio.svg @@ -1,4 +1,4 @@ -1. resolve and verify2. union3. assert estate-wide invariants4. recordpull each fragment by tagoras resolve -> digestverify MANIFEST.sha256 per fileadmit schemaVersion:same major, minor <= toolkitmerge the authored documents:Services, Workloads, Subtrees,providers, unmanaged surfacesmaterialise the required-edge DAGand the node allocatable tableidentityreferencesplacementsecretscompletenesscomposition lock:every resolved digest, the exactfragment and toolkit versionsComposedIntent - input to layer 2participants.ymlevery expected publishermaxAge 7d unless overriddenno ComposedIntent.Nothing renders.any failureComposition merges nothing and runs on anypublish. A failure at stage 3 produces noComposedIntent at all, there is no partialunion. \ No newline at end of file +1. resolve and verify2. union3. assert estate-wide invariants4. recordpull each fragment by tagoras resolve -> digestverify MANIFEST.sha256 per fileadmit schemaVersion:same major, minor <= toolkitmerge the authored documents:Applications, Processes, Subtrees,providers, unmanaged surfacesmaterialise the required-edge DAGand the node allocatable tableidentityreferencesplacementsecretscompletenesscomposition lock:every resolved digest, the exactfragment and toolkit versionsComposedIntent - input to layer 2participants.ymlevery expected publishermaxAge 7d unless overriddenno ComposedIntent.Nothing renders.any failureComposition merges nothing and runs on anypublish. A failure at stage 3 produces noComposedIntent at all, there is no partialunion. \ No newline at end of file diff --git a/spec/v1/diagrams/50-change-end-to-end.drawio.svg b/spec/v1/diagrams/50-change-end-to-end.drawio.svg index 90bc841..3547daa 100644 --- a/spec/v1/diagrams/50-change-end-to-end.drawio.svg +++ b/spec/v1/diagrams/50-change-end-to-end.drawio.svg @@ -1,4 +1,4 @@ -an Intent change mergesin one Service repositoryIntent Fragment republishedOCI, by digestPlatform document republishedtiers, policies, providersClusterState snapshot changesa PV rebinds, a node joins or leavescompositionunion + estate-wide invariantsno lock.Nothing renders.a new lockfragments + node contract +images + clusterStateDigestrendersix adapters, one renderHashmore than oneWorkload?all-or-nothing switchovergated on every Workload readydelivery and co-testingdefined separatelydocs/adr/deferred/yesnoE_CONTRACT_TOO_EARLY \ No newline at end of file +an Intent change mergesin one Project repositoryIntent Fragment republishedOCI, by digestPlatform document republishedtiers, policies, providersClusterState snapshot changesa PV rebinds, a node joins or leavescompositionunion + estate-wide invariantsno lock.Nothing renders.a new lockfragments + node contract +images + clusterStateDigestrendersix adapters, one renderHashmore than oneProcess?all-or-nothing switchovergated on every Process readydelivery and co-testingdefined separatelydocs/adr/deferred/yesnoE_CONTRACT_TOO_EARLY \ No newline at end of file diff --git a/spec/v1/diagrams/50-release-unit-switchover.drawio.svg b/spec/v1/diagrams/50-release-unit-switchover.drawio.svg index edf8b1c..c4b08ce 100644 --- a/spec/v1/diagrams/50-release-unit-switchover.drawio.svg +++ b/spec/v1/diagrams/50-release-unit-switchover.drawio.svg @@ -1,4 +1,4 @@ -a new lock rendersevery Workload of the Serviceauth-apinew version startsauth-uinew version startsreadinesswithin budget?readinesswithin budget?every Workloadready?switch all Workloads togetherhold the Servicethe old versions keep servingfix forward, or revert the Serviceto the previous lockyesyesyesnonoNo member's new version receives traffic until everymember's new version is healthy. Held, not partial. \ No newline at end of file +a new lock rendersevery Process of the Applicationauth-apinew version startsauth-uinew version startsreadinesswithin budget?readinesswithin budget?every Processready?switch all Processes togetherhold the Applicationthe old versions keep servingfix forward, or revert the Applicationto the previous lockyesyesyesnonoNo member's new version receives traffic until everymember's new version is healthy. Held, not partial. \ No newline at end of file diff --git a/spec/v1/diagrams/60-bootstrap-order.drawio.svg b/spec/v1/diagrams/60-bootstrap-order.drawio.svg index 80c7b74..e2f3033 100644 --- a/spec/v1/diagrams/60-bootstrap-order.drawio.svg +++ b/spec/v1/diagrams/60-bootstrap-order.drawio.svg @@ -1,4 +1,4 @@ -1. node factsone YAML per node: site, allocatable cpu andmemory, structured gpus and disks, capabilities;the contract is generated and nix imports the labels2. the bootstrap set appliedk3s, the Flux source, Vault unsealed, the CRDs,recorded in the Platform document3. Platform document publishedsubstrate facts, tiers, policies, engines, providers,an Intent Fragment, by digest4. platform domains publishededge, secrets, observability,the foundation, declared as Services5. ClusterState collectora snapshot plus its clusterStateDigest6. participants.ymlthe platform and every domain, plus maxAge7. one tenant domain publishesone Intent Fragment, holding its Services8. composition runsestate-wide invariants over the union9. rendersix adapters, into the tree the Flux source pullsABCDENothing here is optional and the order matters: each step's checksdepend on the previous step's output existing.Step 2 is the whole of what is applied by hand.Everything after it is rendered, and the CRDs itpins are the only cluster-scoped schema the modeldoes not own.What each step also readsA step 1 -> step 3the capability list validates against the node contractB step 2 -> step 9the CRDs must exist before any object of their kindC step 3 -> step 8the gate reads secretsEncryptionD step 1 -> step 8every placement dimension is matched againstallocatable, gpus, disks and siteE step 5 -> step 9existing PV bindings and the disk dimension read thesnapshot \ No newline at end of file +1. node factsone YAML per node: site, allocatable cpu andmemory, structured gpus and disks, capabilities;the contract is generated and nix imports the labels2. the bootstrap set appliedk3s, the Flux source, Vault unsealed, the CRDs,recorded in the Platform document3. Platform document publishedsubstrate facts, tiers, policies, engines, providers,an Intent Fragment, by digest4. platform projects publishededge, secrets, observability,the foundation, declared as Applications5. ClusterState collectora snapshot plus its clusterStateDigest6. participants.ymlthe platform and every project, plus maxAge7. one tenant project publishesone Intent Fragment, holding its Applications8. composition runsestate-wide invariants over the union9. rendersix adapters, into the tree the Flux source pullsABCDENothing here is optional and the order matters: each step's checksdepend on the previous step's output existing.Step 2 is the whole of what is applied by hand.Everything after it is rendered, and the CRDs itpins are the only cluster-scoped schema the modeldoes not own.What each step also readsA step 1 -> step 3the capability list validates against the node contractB step 2 -> step 9the CRDs must exist before any object of their kindC step 3 -> step 8the gate reads secretsEncryptionD step 1 -> step 8every placement dimension is matched againstallocatable, gpus, disks and siteE step 5 -> step 9existing PV bindings and the disk dimension read thesnapshot \ No newline at end of file diff --git a/spec/v1/diagrams/README.md b/spec/v1/diagrams/README.md index 68d983e..dc3a94e 100644 --- a/spec/v1/diagrams/README.md +++ b/spec/v1/diagrams/README.md @@ -16,7 +16,7 @@ Colour carries the layer, so a reader can place a box without a legend. | fill | stroke | means | |---|---|---| -| `#dbeafe` | `#1e40af` | Service Intent: hand-authored by a Service's owner ([chapter 10](../10-service-intent.md)) | +| `#dbeafe` | `#1e40af` | Project Intent: hand-authored by an Application's owner ([chapter 10](../10-project-intent.md)) | | `#e0e7ff` | `#4338ca` | Platform Intent: hand-authored by the platform ([chapter 14](../14-platform-intent.md)) | | `#e2e8f0` | `#475569` | a pinned input, a lock, or a recorded fact | | `#fef3c7` | `#b45309` | layer 2: a decision the platform made ([chapter 20](../20-resolved-deployment.md)) | @@ -39,7 +39,7 @@ Conventions that hold across every drawing: target**, which is what lets the run be shared: the drops are at distinct x, so the names never pile up. Two relations that mean the same thing are named the same thing, even where they come from different sources; `secrets` is - `secrets` whether it hangs off the Service or off a Workload. + `secrets` whether it hangs off the Application or off a Process. **Two edges to the same box share their exit and their run**, and part only on the way down, so `readiness` and `liveness` read as one relation with two @@ -53,7 +53,7 @@ Conventions that hold across every drawing: by making a reader chase a name across the drawing and the second by adding a line per vocabulary. An attribute's type already names its vocabulary, so the values live in a table in the chapter instead - ([chapter 10](../10-service-intent.md#the-closed-vocabularies)). + ([chapter 10](../10-project-intent.md#the-closed-vocabularies)). A relation that spans two or more layers is **not drawn at all**; the chapter states it in prose. A line that long is what made this drawing unreadable diff --git a/spec/v1/examples/RENDER-GAPS.md b/spec/v1/examples/RENDER-GAPS.md index 21c912e..12ce506 100644 --- a/spec/v1/examples/RENDER-GAPS.md +++ b/spec/v1/examples/RENDER-GAPS.md @@ -1,6 +1,6 @@ # What the render surface says the model still owes -Rendered by hand from the three worked domains, against the sixteen registered +Rendered by hand from the three worked projects, against the sixteen registered adapters. Every row is something a renderer must produce and cannot produce from intent as declared today. Read `auth/rendered/README.md`, `knowledge/rendered/README.md` and `data/rendered/README.md` for the per-file @@ -22,12 +22,12 @@ decisions owe the example set. | # | gap | seen in | |---|---|---| -| R1 | **Atomic switchover is not expressible in plain Kubernetes objects.** Two Deployments in one Service each enter their own Endpoints when their own readiness passes. auth-ui (30s) serves a new bundle for up to the 600s auth-api is allowed to start, a new UI against an old API, both healthy. Nothing links them: the shared label is consumed by no controller, kustomize groups without gating, Flux health checks run after apply. **No rendered object has the identity "the Service".** **Reclassified** by [0071](../../../docs/adr/model/0071-release-gate-inputs-are-layer-2.md): the mechanism stays with the delivery definition and the model owes the gate's inputs, which layer 2 now derives: member list, readiness references, and a deadline of max member progressDeadlineSeconds. No longer blocks the render. | auth | -| R2 | **The Vault policy and Kubernetes auth role have no producer.** `vso` emits VaultConnection / VaultAuth / VaultStaticSecret / VaultDynamicSecret, none is a policy or a role. Reached from `delivery: self` in auth and from `delivery: env` in data, so it is not a delivery-mode edge case. **Closed** by [0073](../../../docs/adr/model/0073-vault-policy-is-a-deliverable.md): a vault-policy adapter emits one JSON policy and one Kubernetes auth role per Workload identity; writing them into Vault is delivery, and the auth mount is a platform fixture. | auth, data | -| R3 | **No `rbac` adapter and no `networking` adapter.** Every NetworkPolicy in these trees is what a future `networking` adapter must emit, and the only implementation lives in the generation being deleted. Three Services share `data-system`; the only thing stopping valkey's ServiceAccount reading postgres' Secret is that no Role grants it, an absence, not a boundary. **Closed** by [0074](../../../docs/adr/model/0074-networking-adapter-emits-policy.md) (a networking adapter owns every NetworkPolicy) and [0075](../../../docs/adr/model/0075-no-workload-rbac-in-v1.md) (no workload RBAC is rendered; the absence becomes E_WORKLOAD_RBAC_GRANT, a composition-time refusal). | data | +| R1 | **Atomic switchover is not expressible in plain Kubernetes objects.** Two Deployments in one Application each enter their own Endpoints when their own readiness passes. auth-ui (30s) serves a new bundle for up to the 600s auth-api is allowed to start, a new UI against an old API, both healthy. Nothing links them: the shared label is consumed by no controller, kustomize groups without gating, Flux health checks run after apply. **No rendered object has the identity "the Application".** **Reclassified** by [0071](../../../docs/adr/model/0071-release-gate-inputs-are-layer-2.md): the mechanism stays with the delivery definition and the model owes the gate's inputs, which layer 2 now derives: member list, readiness references, and a deadline of max member progressDeadlineSeconds. No longer blocks the render. | auth | +| R2 | **The Vault policy and Kubernetes auth role have no producer.** `vso` emits VaultConnection / VaultAuth / VaultStaticSecret / VaultDynamicSecret, none is a policy or a role. Reached from `delivery: self` in auth and from `delivery: env` in data, so it is not a delivery-mode edge case. **Closed** by [0073](../../../docs/adr/model/0073-vault-policy-is-a-deliverable.md): a vault-policy adapter emits one JSON policy and one Kubernetes auth role per Process identity; writing them into Vault is delivery, and the auth mount is a platform fixture. | auth, data | +| R3 | **No `rbac` adapter and no `networking` adapter.** Every NetworkPolicy in these trees is what a future `networking` adapter must emit, and the only implementation lives in the generation being deleted. Three Applications share `data-system`; the only thing stopping valkey's ServiceAccount reading postgres' Secret is that no Role grants it, an absence, not a boundary. **Closed** by [0074](../../../docs/adr/model/0074-networking-adapter-emits-policy.md) (a networking adapter owns every NetworkPolicy) and [0075](../../../docs/adr/model/0075-no-process-rbac-in-v1.md) (no process RBAC is rendered; the absence becomes E_PROCESS_RBAC_GRANT, a composition-time refusal). | data | | R4 | **The forward-auth `Middleware` has no producer.** `traefik-public` emits IngressRoutes *with middleware references*, references only. Every `audience: authenticated` route in the estate resolves against a Middleware nothing renders. **Closed** by [0076](../../../docs/adr/model/0076-middleware-has-one-producer.md): a traefik-middleware adapter owns every Middleware, and a tier serving `authenticated` names the endpoint that authenticates for it. | auth, data | -| R5 | **Durability renders nothing.** `irreplaceable` should derive a backup job, a retention sweep and an off-cluster copy; `recoverable` a job and a sweep; `reconstructible` nothing. No adapter reads the field, and schedule, retention window and destination have no declaring site in layer 1. PVC-level snapshots are impossible here (no VolumeSnapshot CRDs, no CSI snapshot support on `local-path`), so an application-level job is the *only* mechanism. **Closed** by [0077](../../../docs/adr/model/0077-durability-derives-a-backup.md): the Platform document carries one policy per class (window, retention, destination), the method is keyed by the Workload's new `engine` field ([0078](../../../docs/adr/model/0078-engine-is-workload-vocabulary.md)), and the kubernetes adapter emits the CronJob and sweep. The destination credential is a derived grant. | data, knowledge | -| R6 | **`alertClass` derives nothing.** Three Services declare three different values and all three produce zero objects. No adapter renders a PrometheusRule in either generation. `platform-postgres` declares `page` and has no monitoring object at all, because Gatus derives from `exposure` and a datastore is correctly not exposed. **Closed** by [0079](../../../docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md): the class derives nothing in this model and is published as a resolved fact for the monitoring stack to read, while the declared `scrape` surface derives the ServiceMonitor. A declared class with no signal is E_ALERT_CLASS_WITHOUT_SIGNAL. `platform-postgres` clears it on its exporter sidecar's surface rather than on its exposure; `platform-valkey`, which has neither, declares no `observability` block at all. | data | +| R5 | **Durability renders nothing.** `irreplaceable` should derive a backup job, a retention sweep and an off-cluster copy; `recoverable` a job and a sweep; `reconstructible` nothing. No adapter reads the field, and schedule, retention window and destination have no declaring site in layer 1. PVC-level snapshots are impossible here (no VolumeSnapshot CRDs, no CSI snapshot support on `local-path`), so an application-level job is the *only* mechanism. **Closed** by [0077](../../../docs/adr/model/0077-durability-derives-a-backup.md): the Platform document carries one policy per class (window, retention, destination), the method is keyed by the Process's new `engine` field ([0078](../../../docs/adr/model/0078-engine-is-process-vocabulary.md)), and the kubernetes adapter emits the CronJob and sweep. The destination credential is a derived grant. | data, knowledge | +| R6 | **`alertClass` derives nothing.** Three Applications declare three different values and all three produce zero objects. No adapter renders a PrometheusRule in either generation. `platform-postgres` declares `page` and has no monitoring object at all, because Gatus derives from `exposure` and a datastore is correctly not exposed. **Closed** by [0079](../../../docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md): the class derives nothing in this model and is published as a resolved fact for the monitoring stack to read, while the declared `scrape` surface derives the ServiceMonitor. A declared class with no signal is E_ALERT_CLASS_WITHOUT_SIGNAL. `platform-postgres` clears it on its exporter sidecar's surface rather than on its exposure; `platform-valkey`, which has neither, declares no `observability` block at all. | data | | R7 | **`init-databases.sh` is a derived catalog with no producer.** One database and one owning user per consumer, which the inbound edge set already knows. Chapter 16 calls it an inbound derivation; chapter 10 refuses it as an Asset because an Asset may not be executable. So it is a Deliverable, and nothing produces it. **Decided** by [0080](../../../docs/adr/model/0080-database-catalog-is-derived-data.md): the render emits a declarative catalog (one entry per consumer: database, owning user, Vault role) and the platform's engine catalog applies it; credentials are issued by Vault's database engine, not stored. Blocked on R20 for a grant that can name `database/creds/`. | data | ## Blocking: the object cannot be applied as rendered @@ -43,21 +43,21 @@ decisions owe the example set. | # | gap | seen in | |---|---|---| -| R12 | **Which Service directory owns a per-domain object.** `namespace.yaml` and the namespace-wide default-deny are one object per domain; the adapter emits one directory per Service. auth has one Service so nothing collides; data has three, three identical Namespace objects at three paths, `E_PATH_COLLISION` waiting for a second writer. **Closed** by [0070](../../../docs/adr/model/0070-path-authority-is-layer-2.md): layer 2 assigns the path, so the object has one owner and the collision is decidable at plan assembly. | data, auth | +| R12 | **Which Application directory owns a per-project object.** `namespace.yaml` and the namespace-wide default-deny are one object per project; the adapter emits one directory per Application. auth has one Application so nothing collides; data has three, three identical Namespace objects at three paths, `E_PATH_COLLISION` waiting for a second writer. **Closed** by [0070](../../../docs/adr/model/0070-path-authority-is-layer-2.md): layer 2 assigns the path, so the object has one owner and the collision is decidable at plan assembly. | data, auth | | R13 | **`automountServiceAccountToken` is underivable, and the obvious rule is wrong.** "No grant → no token" gets postgres backwards: it holds a grant and needs no token, because under `delivery: env` the operator performs the read and the pod never authenticates. The derivation must read `delivery`; no chapter states it. **Closed** by [0087](../../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md): the token is mounted only where a grant carries `delivery: self`, because that is the one case the pod authenticates; agents-api, which calls the Kubernetes API, restates it with a reason. | auth, data | | R14 | **Probe derivation is partial.** `startupBudget` gives period × threshold and a progress deadline, but which endpoint the startup probe uses is unstated (chosen during serialisation, which chapter 30 forbids), and readiness/liveness `periodSeconds`, `failureThreshold` and `initialDelaySeconds` have no derivation. **Closed** by [0088](../../../docs/adr/model/0088-startup-probe-targets-liveness.md): the startup probe targets the liveness declaration, because exceeding its threshold kills the container; cadence comes from a Platform document probe policy and `initialDelaySeconds` is 0. | auth | | R15 | **No writable-path vocabulary.** A read-only root filesystem needs `/tmp` for the JVM; that is prose in the intent, not a field. No `sizeLimit` is derivable, and a second writable path can only be had by relaxing the whole control. **Closed** by [0092](../../../docs/adr/model/0092-writable-paths-are-declared.md): `writablePaths` is authored and each derives an `emptyDir`, `sizeLimit` comes from a Platform document default, the hardening control stays intact, and auth-ui's exception retires. | auth | | R16 | **`replicas` and `minAvailable` are derived separately and never compared.** auth-api's eligible node set is one node, so live's two replicas is not reproducible; PDB `minAvailable: 1` against `replicas: 1` permits zero voluntary evictions, so draining that node (also the control plane) blocks forever. **Closed** by [0089](../../../docs/adr/model/0089-replicas-derived-no-minavailable.md): `minAvailable` is deleted, `replicas` derives as 1 with more than one a `replicas: {count, reason}` declaration (the sole local capacity exception since the generic override hatch was deleted), and a PDB is emitted only above one replica as `maxUnavailable: 1`. | auth | | R17 | **No exposure name and no hostname label.** Two anonymous exposures with no `paths` render two IngressRoutes with an identical match; Traefik breaks the tie by rule length then name. The live `/api` versus `/` split is expressible and is not declared. **Closed**: chapter 10 now requires `name` per exposure and `path` plus `match` per route, [0072](../../../docs/adr/model/0072-the-label-set-is-fixed.md) settled the label set with no hostname label, and [0093](../../../docs/adr/model/0093-route-precedence-is-derived.md) derives precedence explicitly (`exact` before `prefix`, longer prefix first), with `E_DUPLICATE_ROUTE` refusing an identical pair. | auth | -| R18 | **A cross-domain edge outside the fragment set narrows the allow set silently.** `{service: stalwart, surface: smtp}` does not resolve, so the coordinates and the egress rule are absent rather than wrong, a valid policy with a missing rule, seen on-call as a timeout, not an error code. **Closed** by [0090](../../../docs/adr/model/0090-edges-resolve-against-the-register.md): an edge resolves against the union or the 0019 register, the register carries an address and ports per surface, and an entry without them is `E_UNMANAGED_SURFACE_WITHOUT_COORDINATES`. | auth | +| R18 | **A cross-project edge outside the fragment set narrows the allow set silently.** `{application: stalwart, surface: smtp}` does not resolve, so the coordinates and the egress rule are absent rather than wrong, a valid policy with a missing rule, seen on-call as a timeout, not an error code. **Closed** by [0090](../../../docs/adr/model/0090-edges-resolve-against-the-register.md): an edge resolves against the union or the 0019 register, the register carries an address and ports per surface, and an entry without them is `E_UNMANAGED_SURFACE_WITHOUT_COORDINATES`. | auth | | R19 | **`self-roll` derives a capability that cannot perform the roll.** `patch` on a transit key permits neither `transit/keys//rotate` nor `transit/sign/`. The access × path derivation needs a non-KV branch. **Closed** by [0085](../../../docs/adr/model/0085-a-grant-is-a-union-on-engine.md): a `transit` grant declares a key and a closed `operations` set (sign, verify, encrypt, decrypt, rotate), each mapping to one Vault path; the access tiers are KV intents only. | auth | | R20 | **A grant path is not the path the credential is read from.** The dynamic database credential is granted at a KV-v2 path while the engine lives at `database/creds/`, which no grant declares and no policy covers. **Closed** by [0085](../../../docs/adr/model/0085-a-grant-is-a-union-on-engine.md): every grant derives a read path, a `database` grant's is `database/creds/`, and 0027's join key becomes that derived path, identical to the declared one for `kv`. | auth | | R21 | **Byte-matched grant paths cannot reach their KV-v2 `metadata` sibling.** No transform is permitted, so version listing and soft-delete are denied to every reader in the estate. **Closed** by [0086](../../../docs/adr/model/0086-kv-read-covers-its-metadata-sibling.md): one `kv` declaration derives both `secret/data/` and `secret/metadata/`, since KV-v2 splitting one document across two API paths is an engine detail. Soft-delete stays out: it is a write. | auth | -| R22 | **`runtime: jvm` is asked to imply Spring Boot.** Four spring-cloud-vault spellings derive from `delivery: self`, and no field distinguishes a Spring JVM from any other. **Closed** by [0091](../../../docs/adr/model/0091-identity-placeholders-not-framework-wiring.md): nothing derives framework wiring (it stays in the Workload's env file), and the one derived value it references comes through a closed `${identity:…}` placeholder source. | auth | +| R22 | **`runtime: jvm` is asked to imply Spring Boot.** Four spring-cloud-vault spellings derive from `delivery: self`, and no field distinguishes a Spring JVM from any other. **Closed** by [0091](../../../docs/adr/model/0091-identity-placeholders-not-framework-wiring.md): nothing derives framework wiring (it stays in the Process's env file), and the one derived value it references comes through a closed `${identity:…}` placeholder source. | auth | | R23 | **An Asset's change-propagation mechanism is unstated.** `onChange: restart` needs either a content-hashed ConfigMap name or a checksum annotation; no chapter picks one. `onChange` has two values and postgres supports `pg_ctl reload`, so under `Recreate` a one-line config edit is a full outage. **Closed** by [0094](../../../docs/adr/model/0094-asset-change-restarts-unconditionally.md): the object name is content-hashed unconditionally, `reload` is deleted along with the `onChange` field, and the outage is stated in the chapter as what an Asset edit costs on this substrate. | data | -| R24 | **The label set is not fixed anywhere.** `name` + `instance` are the Deployment selector and therefore immutable, changing the convention later is delete-and-recreate on every workload in the estate. **Closed** by [0072](../../../docs/adr/model/0072-the-label-set-is-fixed.md): the five labels are named, part-of carries the Service, and name plus instance are named as immutable selectors. | auth | +| R24 | **The label set is not fixed anywhere.** `name` + `instance` are the Deployment selector and therefore immutable, changing the convention later is delete-and-recreate on every process in the estate. **Closed** by [0072](../../../docs/adr/model/0072-the-label-set-is-fixed.md): the five labels are named, part-of carries the Application, and name plus instance are named as immutable selectors. | auth | | R25 | **No scrape `interval` or `scrapeTimeout` is derivable**, so omitting them silently takes the metrics stack's global default, decided outside the model. **Closed** by [0079](../../../docs/adr/model/0079-alert-class-derives-from-a-rule-catalog.md): the Platform document states one `monitors: {interval, timeout}` for the estate and every emitted monitor names it, so the metrics stack's global default stops being an input nobody declared. | auth | -| R26 | **Estate-scoped Deliverables land in another domain's namespace.** The Gatus endpoints ConfigMap is one object in `utility-system`; `E_FOREIGN_NAMESPACE` is satisfied only because the adapter owns the path rather than the Service. **Closed** by [0070](../../../docs/adr/model/0070-path-authority-is-layer-2.md): an estate-scoped Deliverable is assigned its path and its owner rather than inheriting the emitting adapter's. | auth | +| R26 | **Estate-scoped Deliverables land in another project's namespace.** The Gatus endpoints ConfigMap is one object in `utility-system`; `E_FOREIGN_NAMESPACE` is satisfied only because the adapter owns the path rather than the Application. **Closed** by [0070](../../../docs/adr/model/0070-path-authority-is-layer-2.md): an estate-scoped Deliverable is assigned its path and its owner rather than inheriting the emitting adapter's. | auth | ## Missing inputs, not missing derivations @@ -76,7 +76,7 @@ certResolver; `VAULT_ADDR`; `DEPLOYMENT_ENVIRONMENT`; the remaining `OTEL_*` and rule selects pods the traffic never reaches. Also missing from the example set itself: auth-ui has no env file, so its -container renders with no env at all: the model requires one per Workload and +container renders with no env at all: the model requires one per Process and the set carries one of two. ## What the closed rows owed the example set @@ -89,23 +89,23 @@ per-tree summary of what changed is in each `rendered/README.md`. Two items are deliberately still owed, because they are not the example set's to answer: the node contract is a separate pinned input and is not reproduced here ([0056](../../../docs/adr/model/0056-node-facts-single-source.md)), and -`auth-ui` still has no env file of its own: the model requires one per Workload +`auth-ui` still has no env file of its own: the model requires one per Process and the set carries one of two. ### The list, as it stood Every **Closed** row above decided something the worked examples predate, so the -three domain files and the Platform document inputs are behind the model. This is +three project files and the Platform document inputs are behind the model. This is the list, and it is discharged in one pass rather than row by row: the trees are also being stripped of commentary, and both edits touch the same files. | owed | from | where | |---|---|---| -| `engine` on every datastore Workload | [0078](../../../docs/adr/model/0078-engine-is-workload-vocabulary.md) | `data`, `knowledge` | +| `engine` on every datastore Process | [0078](../../../docs/adr/model/0078-engine-is-process-vocabulary.md) | `data`, `knowledge` | | `minAvailable` removed, and `auth-api`'s second replica declared as `replicas: {count, reason}` with its capacity reason | [0089](../../../docs/adr/model/0089-replicas-derived-no-minavailable.md) | `auth`, and any rendered PDB over a single replica | | the probe policy, and every rendered probe naming its cadence | [0088](../../../docs/adr/model/0088-startup-probe-targets-liveness.md) | Platform document, all three rendered trees | | `automountServiceAccountToken` on every pod template | [0087](../../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md) | all three rendered trees | -| `writablePaths` on every non-static Workload, and auth-ui's `writableRootFilesystem` exception deleted | [0092](../../../docs/adr/model/0092-writable-paths-are-declared.md) | `auth`, `knowledge` | +| `writablePaths` on every non-static Process, and auth-ui's `writableRootFilesystem` exception deleted | [0092](../../../docs/adr/model/0092-writable-paths-are-declared.md) | `auth`, `knowledge` | | the `${identity:…}` placeholder in auth-api's env file, replacing the literal role name | [0091](../../../docs/adr/model/0091-identity-placeholders-not-framework-wiring.md) | `auth` env files | | `onChange` removed from every Asset | [0094](../../../docs/adr/model/0094-asset-change-restarts-unconditionally.md) | `data` | | `size` on every volume | [0081](../../../docs/adr/model/0081-volume-size-is-a-hard-dimension.md) | `data`, `knowledge` | diff --git a/spec/v1/examples/auth/auth.domain.yml b/spec/v1/examples/auth/auth.project.yml similarity index 77% rename from spec/v1/examples/auth/auth.domain.yml rename to spec/v1/examples/auth/auth.project.yml index 857973c..6980a58 100644 --- a/spec/v1/examples/auth/auth.domain.yml +++ b/spec/v1/examples/auth/auth.project.yml @@ -1,22 +1,22 @@ -# Worked example: the auth domain's Intent Fragment +# Worked example: the auth project's Intent Fragment # -# services/auth/platform/auth.yml +# applications/auth/platform/auth.yml # -# Intent is authored ONE FILE PER DOMAIN, holding every Service that domain +# Intent is authored ONE FILE PER PROJECT, holding every Application that project # owns, and one file is one Intent Fragment -# (docs/adr/model/0063-intent-authored-per-domain.md). The namespace derives from the +# (docs/adr/model/0063-intent-authored-per-project.md). The namespace derives from the # header -- auth-system -- and no field exists that could divert it. # -# Exercises: two Workloads that switch together because they are one Service, +# Exercises: two Processes that switch together because they are one Application, # ONE authored hostname fronting both of them through two routes -- the case -# that forced `exposure` from the Workload to the Service -- placement as hard +# that forced `exposure` from the Process to the Application -- placement as hard # dimensions carrying an arch set and a capability, three `delivery: self` # grants including a Transit key that takes no placeholder, four dependencies, # writable paths that retired a hardening exception, and config that is almost entirely # derived. # -# Companion env files, one per WORKLOAD -# (docs/adr/model/0011-configuration-env-files-per-workload.md): +# 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-ui/base.env @@ -27,30 +27,30 @@ # 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. -# `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 +# The header is three fields, and `owner` is the ONLY one raised to the project. +# `observability` stays per Application -- raising it makes a project page as loudly as +# its loudest member -- and so does `secrets`, because a project-level grant +# hands every Application in the file a reader slot on the whole path # (docs/adr/model/0009-vault-read-is-per-path.md, -# docs/adr/model/0063-intent-authored-per-domain.md). -domain: auth +# docs/adr/model/0063-intent-authored-per-project.md). +project: auth owner: joris -services: +applications: # ==================================================================== auth - # This Service was two Services -- `auth-api` and `auth-ui` -- coupled by a + # This Application was two Applications -- `auth-api` and `auth-ui` -- coupled by a # `releaseUnit: auth` name that each declared in its own repository. That - # field is deleted. A Service is itself the unit of atomic release - # (docs/adr/model/0062-service-is-the-release-unit.md): its Workloads switch - # together or none switches. No Workload's new version receives traffic until - # every Workload's new version is healthy by its own declared readiness, and + # field is deleted. An Application is itself the unit of atomic release + # (docs/adr/model/0062-application-is-the-release-unit.md): its Processes switch + # together or none switches. No Process's new version receives traffic until + # every Process's new version is healthy by its own declared readiness, and # if any misses its `startupBudget`, none of them switch and the old versions - # keep serving. Rollback is Service-scoped for the same reason. + # keep serving. Rollback is Application-scoped for the same reason. # # The requirement is unchanged; only the mechanism is. A new UI against an old # API is a broken product even though both pods report healthy, and that is # now carried by the boundary drawn here rather than by a name two files had - # to agree on. The two Workloads below are adjacent, so a reader sees the + # to agree on. The two Processes below are adjacent, so a reader sees the # coupling without searching the composed union. # # The merge costs no reference: auth's estate-wide role is the forward-auth @@ -65,49 +65,49 @@ services: - id: auth # Whole or absent. The class states urgency and the scrape names the surface - # that carries the signal, both on the Service, because both are facts about - # the Service rather than about one of its two containers. auth-api + # that carries the signal, both on the Application, because both are facts about + # the Application rather than about one of its two containers. auth-api # publishes for the pair; auth-ui exports nothing and needs to say nothing # (docs/adr/model/0021-observability-scrape-and-alert-class.md). observability: alertClass: page # every forward-auth protected route depends on it scrape: - workload: auth-api + process: auth-api surface: http # the name declared in auth-api's `provides` path: /api/actuator/prometheus - # No Service-level `secrets`. The three grants below are auth-api's alone, - # so they sit on that Workload: a Service-level grant is held by EVERY - # Workload, including ones added later - # (docs/adr/model/0022-grants-live-on-the-service.md), and leaving them here would + # No Application-level `secrets`. The three grants below are auth-api's alone, + # so they sit on that Process: an Application-level grant is held by EVERY + # Process, including ones added later + # (docs/adr/model/0022-grants-live-on-the-application.md), and leaving them here would # hand the nginx container a Vault identity holding `read` on the database - # credentials. Folding the pair into one Service is what moved them down a + # credentials. Folding the pair into one Application is what moved them down a # level, and the level is an access boundary only because identity is per - # Workload (docs/adr/model/0024-identity-per-workload.md). + # Process (docs/adr/model/0024-identity-per-process.md). - # ONE HOSTNAME, TWO WORKLOADS -- the case that forced `exposure` up from the - # Workload to the Service. `auth.jorisjonkers.dev/api` is auth-api and `/` - # is auth-ui, and at the Workload level that is unsayable: two Workloads + # ONE HOSTNAME, TWO PROCESSES -- the case that forced `exposure` up from the + # Process to the Application. `auth.jorisjonkers.dev/api` is auth-api and `/` + # is auth-ui, and at the Process level that is unsayable: two Processes # each declaring a port and an audience produce two routes claiming the # whole host, which is exactly what this example's rendered tree used to - # show. `provides` stays on the Workload, because the two are different + # show. `provides` stays on the Process, because the two are different # facts: `provides` is *this process listens on this port*, `exposure` is # *this hostname routes here* (review/EXPOSURE-MANIFEST.md, # docs/adr/model/0018-exposure-by-audience.md). # - # `host` is the full FQDN, AUTHORED. No zone mapping, no `.` + # `host` is the full FQDN, AUTHORED. No zone mapping, no `.` # rule, no apex flag -- an apex host is written `host: jorisjonkers.dev`. - # That it matches the Service id here is a coincidence and does not + # That it matches the Application id here is a coincidence and does not # generalise: `platform-rabbitmq` serves rabbitmq, `headlamp` serves - # dashboard, and `status` belongs to no Service at all. The host is unique + # dashboard, and `status` belongs to no Application at all. The host is unique # across the estate -- E_DUPLICATE_HOST at composition, evaluated over the # composed union together with the Registered Unmanaged Surfaces. exposure: - - name: public # required, unique within the Service: what + - name: public # required, unique within the Application: what # ${exposure:auth.public#url} addresses and # what E_DUPLICATE_EXPOSURE_NAME checks host: auth.jorisjonkers.dev - audience: anonymous # both surfaces. This Service is what performs + audience: anonymous # both surfaces. This Application is what performs # authentication, so it cannot sit behind it; # the applications enforce their own # authorization and this describes the edge @@ -120,21 +120,21 @@ services: # name, header block, timeout or rate limit is # authorable anywhere in layer 1 routes: - # A route names {path, match, workload, surface}, `match` being - # `prefix` or `exact`. The surface must be one the named Workload + # A route names {path, match, process, surface}, `match` being + # `prefix` or `exact`. The surface must be one the named Process # declares in `provides` (E_UNKNOWN_SURFACE otherwise), and two routes # on one exposure may not share a path + match pair # (E_DUPLICATE_ROUTE_MATCH). - - {path: /api, match: prefix, workload: auth-api, surface: http} - - {path: /, match: prefix, workload: auth-ui, surface: http} + - {path: /api, match: prefix, process: auth-api, surface: http} + - {path: /, match: prefix, process: auth-ui, surface: http} - workloads: + processes: # ------------------------------------------------------------ auth-api - name: auth-api - # The ServiceAccount and the Vault role are the WORKLOAD NAME, unique - # within the domain: `auth-system.auth-api`, never - # `auth-system.auth-auth-api` (docs/adr/model/0024-identity-per-workload.md). - lifecycle: service + # The ServiceAccount and the Vault role are the PROCESS NAME, unique + # within the project: `auth-system.auth-api`, never + # `auth-system.auth-auth-api` (docs/adr/model/0024-identity-per-process.md). + lifecycle: application # An ALIAS resolved to a digest through the images lock -- never a tag # and never a digest here. That is what keeps every rendered Deliverable @@ -142,13 +142,13 @@ services: image: auth-api runtime: jvm - # A port is a property of a process, so `provides` sits on the Workload - # (chapter 10). `dependsOn` still targets `{service, surface}`: surface - # names are unique within a Service, and a consumer names the Service, - # never a Workload. + # A port is a property of a process, so `provides` sits on the Process + # (chapter 10). `dependsOn` still targets `{application, surface}`: surface + # names are unique within an Application, and a consumer names the Application, + # never a Process. provides: http: 8080 # not 80: a port below 1024 cannot be bound by a - # non-root process without CAP_NET_BIND_SERVICE, + # non-root process without CAP_NET_BIND_APPLICATION, # which `restricted` drops # (docs/adr/model/0083-privileged-port-needs-the-capability.md). # A route names a surface, not a number, so the @@ -164,17 +164,17 @@ services: # satisfies is E_PLACEMENT_UNSATISFIABLE, a build error, raised before # any manifest exists. placement: - # `memory` and `cpu` are required on every Workload, as raw + # `memory` and `cpu` are required on every Process, as raw # quantities, and are matched against node ALLOCATABLE from the pinned # node contract -- total minus the reserve declared in the node file, # never a live read (docs/adr/model/0056-node-facts-single-source.md, # docs/adr/model/0006-pinned-inputs.md). This is ELIGIBILITY, not - # bin-packing: three Workloads asking 2Gi each all pass against a + # bin-packing: three Processes asking 2Gi each all pass against a # 4096Mi node, and the scheduler refuses the third at apply. # # Contention has not moved. Memory and cpu are contended values, and # contention decides who ARBITRATES, not who AUTHORS - # (docs/adr/model/0004-contention-decides-authority.md): this Service states + # (docs/adr/model/0004-contention-decides-authority.md): this Application states # its requirement, and the platform decides whether it fits and where. memory: 768Mi # request == limit, derived: memory is # incompressible, and a pod that cannot exceed @@ -193,10 +193,10 @@ services: capabilities: [public-ingress] # frankfurt-contabo-1 is the only node advertising public-ingress, so # this replaces `nodeSelector: {site: frankfurt}` without naming a - # site or a label. Labels are not the Service's to name: the node + # site or a label. Labels are not the Application's to name: the node # contract emits 110 of them for 7 nodes, 55 under a prefix named # after an archived repository that rejects pushes, and retiring that - # prefix must not be an edit in every service repository + # prefix must not be an edit in every project repository # (docs/adr/model/0056-node-facts-single-source.md). # # `tailscale` is not written here and is no longer vocabulary: 7 of 7 @@ -217,13 +217,13 @@ services: writablePaths: [/tmp] dependsOn: - - {service: platform-postgres, surface: postgres} - - {service: platform-valkey, surface: redis} - - {service: platform-rabbitmq, surface: amqp} - - {service: stalwart, surface: smtp} + - {application: platform-postgres, surface: postgres} + - {application: platform-valkey, surface: redis} + - {application: platform-rabbitmq, surface: amqp} + - {application: stalwart, surface: smtp} - # No `exposure` here, and none is missing: it sits on the Service - # above, where one hostname can front both Workloads. What stays is + # No `exposure` here, and none is missing: it sits on the Application + # above, where one hostname can front both Processes. What stays is # `provides` (http: 8081) -- the port this process listens on, and # `http` is the surface the `/api` route names. @@ -242,7 +242,7 @@ services: # REQUIRED, and there is no default. `rolling` asks the platform to keep # serving capacity throughout the cutover; `recreate` accepts a - # stop-then-start. This Workload holds no volume, so a rolling cutover + # stop-then-start. This Process holds no volume, so a rolling cutover # is honourable and is what its owner asks for. The Kubernetes spellings # -- RollingUpdate, maxSurge, maxUnavailable -- are derived by the # adapter and appear nowhere here. @@ -268,7 +268,7 @@ services: # All three grants use `delivery: self` -- the application fetches them # through spring-cloud-vault at runtime, which is what it already does # in production via SPRING_CONFIG_IMPORT: vault:// and VAULT_DB_ENABLED: - # true. Consequently this Workload's env file contains no ${secret:...} + # true. Consequently this Process's env file contains no ${secret:...} # placeholder at all, and the dead-grant check correctly does not fire. # # The grant unit is the PATH, never the key @@ -283,7 +283,7 @@ services: # build error. # # SPLIT PATH. `secret/data/platform/postgres` used to be one document - # holding every consumer's credentials, which made this Workload's + # holding every consumer's credentials, which made this Process's # password readable by knowledge's pods. One path per reader set is # the layout rule; this path's reader set is auth-api alone, and # knowledge reads .../postgres/kb. @@ -298,9 +298,9 @@ services: # auth-api's own document: the static credentials for the three # dependencies whose credentials are not minted by an engine. They sit # here rather than under the platform Subtrees because the reader set - # is this Workload alone -- 0023 asks for one path per reader set, not + # is this Process alone -- 0023 asks for one path per reader set, not # one path per consumer. Only the Postgres pair above is dynamic, - # which is why it must live under the data domain's mount. + # which is why it must live under the data project's mount. # # Every key is enumerated. A wildcard key list -- `keys` naming a # single star, which this grant used to carry -- left the vocabulary: @@ -318,11 +318,11 @@ services: delivery: self rotation: {tolerates: reload} - # JWT signing through Vault Transit. This Workload rotates the key + # JWT signing through Vault Transit. This Process rotates the key # itself, so `self-roll` rather than `read` -- which derives the # `patch` capability on the named key rather than `update`, granting # no read access to anything else in the mount. No rollAffectsReaders - # acknowledgement is needed: composition shows no other Service reads + # acknowledgement is needed: composition shows no other Application reads # it, and verifiers follow JWKS rather than caching the key. # # A non-KV mount takes NO placeholder and `delivery: self` is its only @@ -337,12 +337,12 @@ services: rotation: {tolerates: reload} # ------------------------------------------------------------- auth-ui - # The second half of the product. It is here, in this Service, because it + # The second half of the product. It is here, in this Application, because it # must cut over with auth-api and for no other reason - # (docs/adr/model/0062-service-is-the-release-unit.md). Its identity is its own: + # (docs/adr/model/0062-application-is-the-release-unit.md). Its identity is its own: # `auth-system.auth-ui`, holding none of the grants above. - name: auth-ui - lifecycle: service + lifecycle: application image: auth-ui # an alias, resolved through the images lock runtime: static # nginx serving a built bundle: no OTEL_* or # PYROSCOPE_* wiring to inject, and saying @@ -360,7 +360,7 @@ services: # dimension would exclude nodes for nothing: the image is multi-arch, # it needs no node capability and it holds no volume, so all 7 nodes # are eligible. auth-api's public-ingress filter is deliberately NOT - # repeated -- the two Workloads may land on different nodes and still + # repeated -- the two Processes may land on different nodes and still # switch together, because atomic release is not co-location. memory: 64Mi # the estate measures its nginx pods at # "~10-20Mi RAM each"; this is the headroom @@ -372,7 +372,7 @@ services: # pid file under /var/run; both are declared writable paths, mounted as # emptyDirs, and readOnlyRootFilesystem stays true # (docs/adr/model/0092-writable-paths-are-declared.md). The exception - # this Workload used to carry retired with that decision, without the + # this Process used to carry retired with that decision, without the # rebuilt image its own reason was waiting for. writablePaths: [/var/cache/nginx, /var/run] @@ -382,8 +382,8 @@ services: # (docs/adr/model/0035-network-policy-default-deny.md). A dependency edge # would claim a connection this pod never opens. - # No `exposure` here either. The Service's `public` exposure routes - # `/` to this Workload's `http` surface -- the login page is what + # No `exposure` here either. The Application's `public` exposure routes + # `/` to this Process's `http` surface -- the login page is what # unauthenticated users are sent to, so it cannot sit behind the # authentication it starts. @@ -402,23 +402,23 @@ services: startupBudget: 30s # nginx serves within a second of starting; the # budget is the gate auth-api's 600s dominates - # REQUIRED on every Workload. Same value as auth-api, and stated again: - # this Workload holds no volume either, so a rolling cutover is - # honourable here too. The two Workloads switch together because they - # are one Service, not because they share a strategy. + # REQUIRED on every Process. Same value as auth-api, and stated again: + # this Process holds no volume either, so a rolling cutover is + # honourable here too. The two Processes switch together because they + # are one Application, not because they share a strategy. cutover: rolling # No `replicas` block: one is the derived count, and one is what this - # Workload wants. The block exists for a count above one with a reason, + # Process wants. The block exists for a count above one with a reason, # not to restate the default. # Nothing observability-shaped here, and nothing to omit either: the - # whole block sits on the Service above and names auth-api's surface. + # whole block sits on the Application above and names auth-api's surface. # This image exports no metrics, and a scrape path the platform guessed # would collect nothing and report success # (docs/adr/model/0021-observability-scrape-and-alert-class.md). - # No `secrets`. Everything this Workload needs is baked into the bundle + # No `secrets`. Everything this Process needs is baked into the bundle # at build time or reached through the browser, so no Vault role is # derived for it: `auth-system.auth-ui` is a ServiceAccount with no # policy bound to it, which is exactly what moving the three grants down diff --git a/spec/v1/examples/auth/env/auth-api.base.env b/spec/v1/examples/auth/env/auth-api.base.env index 337c95b..f7bfd65 100644 --- a/spec/v1/examples/auth/env/auth-api.base.env +++ b/spec/v1/examples/auth/env/auth-api.base.env @@ -1,15 +1,15 @@ -# services/auth/platform/env/auth-api/base.env +# applications/auth/platform/env/auth-api/base.env # -# Env files are per WORKLOAD, never per Service -# (docs/adr/model/0011-configuration-env-files-per-workload.md). Service `auth` holds -# two Workloads and therefore two files: this one, and auth-ui's beside it at +# Env files are per PROCESS, never per Application +# (docs/adr/model/0011-configuration-env-files-per-process.md). Application `auth` holds +# two Processes and therefore two files: this one, and auth-ui's beside it at # platform/env/auth-ui/base.env. The grants they may reference differ, because # the identity reading them differs. # -# The Service is declared in the auth domain's Intent Fragment, -# platform/auth.yml -- one file per domain -# (docs/adr/model/0063-intent-authored-per-domain.md), reproduced as -# examples/domains/auth.yml. +# The Application is declared in the auth project's Intent Fragment, +# platform/auth.yml -- one file per project +# (docs/adr/model/0063-intent-authored-per-project.md), reproduced as +# examples/projects/auth.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 @@ -29,11 +29,11 @@ SPRING_RABBITMQ_PORT=${dependency:platform-rabbitmq.port} MAIL_HOST=${dependency:stalwart.host} MAIL_PORT=${dependency:stalwart.port} -# --- the Service's own hostname, addressed by exposure NAME -# ${exposure:.#} is the third placeholder source, +# --- the Application's own hostname, addressed by exposure NAME +# ${exposure:.#} is the third placeholder source, # beside ${dependency:...} and ${secret:...}, and `field` is one of `url`, -# `host`, `scheme`. `auth.public` is the exposure declared on Service auth -# in the domain file; a name no exposure carries is a build error, and so is +# `host`, `scheme`. `auth.public` is the exposure declared on Application auth +# in the project file; a name no exposure carries is a build error, and so is # writing https://auth.jorisjonkers.dev here as a literal. # # The PATH IS WRITTEN OUTSIDE the placeholder. That keeps substitution a @@ -42,7 +42,7 @@ MAIL_PORT=${dependency:stalwart.port} # property that makes ${secret:...} byte-matchable against a granted path. # # Three hand-written URLs collapse to one declaration: the host moves once, -# in the domain file, and these three follow it. +# in the project file, and these three follow it. AUTH_ISSUER=${exposure:auth.public#url} AUTH_LOGIN_URL=${exposure:auth.public#url}/login CONFIRMATION_URL=${exposure:auth.public#url}/confirm @@ -55,8 +55,8 @@ CONFIRMATION_URL=${exposure:auth.public#url}/confirm # rabbitmq, status. See chapter 16 -- the exact predicate is open. # # MAIL_USERNAME (auth@jorisjonkers.dev) -# -> from the cluster public domain plus this Service's id, which is now -# `auth` (docs/adr/model/0062-service-is-the-release-unit.md) +# -> from the cluster public domain plus this Application's id, which is now +# `auth` (docs/adr/model/0062-application-is-the-release-unit.md) # # VAULT_ADDR, DEPLOYMENT_ENVIRONMENT -> pinned Platform document facts. # @@ -65,8 +65,8 @@ CONFIRMATION_URL=${exposure:auth.public#url}/confirm # VAULT_AUTHENTICATION and VAULT_ENABLED are written below like any other # literal (docs/adr/model/0091-identity-placeholders-not-framework-wiring.md). # The one value that must not drift is a placeholder: VAULT_KUBERNETES_ROLE is -# the WORKLOAD's derived identity -- `auth-api`, never `auth-auth-api` -# (docs/adr/model/0024-identity-per-workload.md) -- so it is written as +# the PROCESS's derived identity -- `auth-api`, never `auth-auth-api` +# (docs/adr/model/0024-identity-per-process.md) -- so it is written as # ${identity:vaultRole} and cannot disagree with the role the platform derived. # # AUTH_TRANSIT_ENABLED, AUTH_TRANSIT_KEY_NAME diff --git a/spec/v1/examples/auth/rendered/README.md b/spec/v1/examples/auth/rendered/README.md index e894f49..00be491 100644 --- a/spec/v1/examples/auth/rendered/README.md +++ b/spec/v1/examples/auth/rendered/README.md @@ -1,11 +1,11 @@ -# Rendered output: the `auth` domain +# Rendered output: the `auth` project What a renderer must produce from -[`../auth.domain.yml`](../auth.domain.yml) and +[`../auth.project.yml`](../auth.project.yml) and [`../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 +two amendments in `review/PLACEMENT-PROJECT-MANIFEST.md` and `review/EXPOSURE-MANIFEST.md`. It is the GOAL STATE, not today's output. Today's renderer emits no @@ -14,9 +14,9 @@ decisions require. Where a value cannot be derived from the intent as declared, it is marked in the file rather than invented, and the reason is a row in [Gaps](#gaps). -One Service (`auth`), two Workloads (`auth-api`, `auth-ui`), namespace -`auth-system`. Both Workloads' objects sit in one Service directory, because a -Service is the unit of atomic release, and that grouping is the whole of what +One Application (`auth`), two Processes (`auth-api`, `auth-ui`), namespace +`auth-system`. Both Processes' objects sit in one Application directory, because a +Application is the unit of atomic release, and that grouping is the whole of what the rendered tree says about atomicity. See [G-01](#g-01), which is the most important entry here. @@ -39,7 +39,7 @@ made three derivations explicit that a renderer had been choosing: | change | decided in | |---|---| -| the fixed label set, `instance` now the Workload and `component` the runtime | [0072](../../../../../docs/adr/model/0072-the-label-set-is-fixed.md) | +| the fixed label set, `instance` now the Process and `component` the runtime | [0072](../../../../../docs/adr/model/0072-the-label-set-is-fixed.md) | | `automountServiceAccountToken`, `false` wherever the pod does not authenticate | [0087](../../../../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md) | | `runAsUser`, `runAsGroup`, and `fsGroup` where a volume is held | [0082](../../../../../docs/adr/model/0082-images-lock-carries-uid-and-gid.md) | | a startup probe pointed at the **liveness** endpoint, and one probe cadence | [0088](../../../../../docs/adr/model/0088-startup-probe-targets-liveness.md) | @@ -54,41 +54,41 @@ those rows were decided on 2026-09-07 and the row-by-row status lives in ## Estate-scoped objects are not in this tree Two files this tree used to carry, `edge/middlewares.yaml` and -`observability/gatus-endpoints.yaml`, render in the **platform domains** now: +`observability/gatus-endpoints.yaml`, render in the **platform projects** now: the Middleware set is emitted per tier by the `traefik` adapter into the edge -domain, and the Gatus endpoint list is an inbound derivation rendered as the -declared `gatus` Service's own Asset in the observability domain +project, and the Gatus endpoint list is an inbound derivation rendered as the +declared `gatus` Application's own Asset in the observability project ([0096](../../../../../docs/adr/model/0096-the-foundation-is-declared.md), -[0098](../../../../../docs/adr/model/0098-one-publication-path.md)). This domain +[0098](../../../../../docs/adr/model/0098-one-publication-path.md)). This project contributes routes and exposures to both; it owns neither object. ## The files | file | adapter | derives from (layer 1) | cannot derive today | |---|---|---|---| -| `namespace.yaml` | `kubernetes` | `domain: auth` → `auth-system` | Nothing. It is the one derivation with a single input. The adapter emits namespace.yaml *per Service directory*, so a second Service in this domain would emit a second identical object ([G-14](#g-14)) | -| `kustomization.yaml` | `kubernetes` | the Service list | Nothing it needs. It groups; it does not gate ([G-01](#g-01)) | -| `apps/auth/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `provides`, `placement`, `hardening`, `probes`, `startupBudget`, `cutover`, `minAvailable`, env files, `runtime`, `dependsOn`, and the Service's `exposure`: the three `AUTH_*` URLs resolve from `${exposure:auth.public#url}`, whose host is authored on that block | the smtp coordinate ([G-04](#g-04)); `AUTH_CORS_ALLOWED_ORIGINS` ([G-05](#g-05)); the replica count ([G-06](#g-06)); the startup probe's endpoint and the unstated probe timings ([G-08](#g-08)); which paths need to be writable ([G-09](#g-09)); that this JVM is Spring ([G-10](#g-10)); 9 `OTEL_*`, 6 `PYROSCOPE_*`, `DEPLOYMENT_ENVIRONMENT`, `VAULT_ADDR` ([G-11](#g-11)); auth-ui's env entirely ([G-12](#g-12)); the label set ([G-13](#g-13)); a UID for `runAsNonRoot` ([G-26](#g-26)); the pod/container split of the hardening class ([G-28](#g-28)); which label carries `arch` ([G-29](#g-29)); the image digests ([G-32](#g-32)) | -| `apps/auth/serviceaccount.yaml` | `kubernetes` | workload `name` (the identity is the Workload name alone), `domain` | `automountServiceAccountToken` ([G-15](#g-15)); any Role/RoleBinding the identity model implies ([G-31](#g-31)) | +| `namespace.yaml` | `kubernetes` | `project: auth` → `auth-system` | Nothing. It is the one derivation with a single input. The adapter emits namespace.yaml *per Application directory*, so a second Application in this project would emit a second identical object ([G-14](#g-14)) | +| `kustomization.yaml` | `kubernetes` | the Application list | Nothing it needs. It groups; it does not gate ([G-01](#g-01)) | +| `apps/auth/workload.yaml` | `kubernetes` | `lifecycle`, `image`, `provides`, `placement`, `hardening`, `probes`, `startupBudget`, `cutover`, `minAvailable`, env files, `runtime`, `dependsOn`, and the Application's `exposure`: the three `AUTH_*` URLs resolve from `${exposure:auth.public#url}`, whose host is authored on that block | the smtp coordinate ([G-04](#g-04)); `AUTH_CORS_ALLOWED_ORIGINS` ([G-05](#g-05)); the replica count ([G-06](#g-06)); the startup probe's endpoint and the unstated probe timings ([G-08](#g-08)); which paths need to be writable ([G-09](#g-09)); that this JVM is Spring ([G-10](#g-10)); 9 `OTEL_*`, 6 `PYROSCOPE_*`, `DEPLOYMENT_ENVIRONMENT`, `VAULT_ADDR` ([G-11](#g-11)); auth-ui's env entirely ([G-12](#g-12)); the label set ([G-13](#g-13)); a UID for `runAsNonRoot` ([G-26](#g-26)); the pod/container split of the hardening class ([G-28](#g-28)); which label carries `arch` ([G-29](#g-29)); the image digests ([G-32](#g-32)) | +| `apps/auth/serviceaccount.yaml` | `kubernetes` | process `name` (the identity is the Process name alone), `project` | `automountServiceAccountToken` ([G-15](#g-15)); any Role/RoleBinding the identity model implies ([G-31](#g-31)) | | `apps/auth/pdb.yaml` | `kubernetes` | `minAvailable: 1` on auth-api | auth-ui's default, which is ungraded; and nothing compares the budget against the derived replica count ([G-07](#g-07)) | -| `apps/auth/servicemonitor.yaml` | `prometheus` | `observability.scrape {workload, surface, path}`, `provides` (the `http` surface) | cadence from the Platform document ([G-17](#g-17) closed); the operator's `release` selector label ([G-11](#g-11)) | -| `apps/auth/networkpolicy.yaml` | **none**: `networkpolicy` is not a registered adapter ([G-30](#g-30)) | `dependsOn` (egress), the Service's `exposure` routes (ingress, per routed Workload), `scrape` (ingress), the effective grant set (egress to the Secret Store), plus the non-authorable baseline | the four platform-component selectors ([G-11](#g-11)); the stalwart rule ([G-04](#g-04)); an ingress rule for the forward-auth caller ([G-18](#g-18)) | +| `apps/auth/servicemonitor.yaml` | `prometheus` | `observability.scrape {process, surface, path}`, `provides` (the `http` surface) | cadence from the Platform document ([G-17](#g-17) closed); the operator's `release` selector label ([G-11](#g-11)) | +| `apps/auth/networkpolicy.yaml` | **none**: `networkpolicy` is not a registered adapter ([G-30](#g-30)) | `dependsOn` (egress), the Application's `exposure` routes (ingress, per routed Process), `scrape` (ingress), the effective grant set (egress to the Secret Store), plus the non-authorable baseline | the four platform-component selectors ([G-11](#g-11)); the stalwart rule ([G-04](#g-04)); an ingress rule for the forward-auth caller ([G-18](#g-18)) | | `apps/auth/vault.yaml` | **none**: no adapter writes Vault policies or auth roles ([G-02](#g-02)) | metadata paths ([G-19](#g-19)); the database engine path the wiring actually reads ([G-20](#g-20)); a capability that can roll or sign the transit key ([G-21](#g-21)); token TTLs ([G-22](#g-22)) | -| `apps/auth/kustomization.yaml` | `kubernetes` | the file set of the Service | - | -| `edge/ingressroutes.yaml` | `traefik`, for the tier each route's audience selects | the Service's `exposure`: `host` (authored, copied verbatim), each route's `path` + `match` → the rule, `workload` + `surface` → the backend, `audience: anonymous` → no forward-auth, `contentPolicy: strict` → the security-headers middleware reference | entryPoint and TLS policy ([G-11](#g-11)); the `Middleware` objects both references resolve to: forward-auth elsewhere and security-headers here ([G-23](#g-23)) | -| `apps/vso-secrets/policies/auth-api.policy.json` | `vault-policy` | the Workload's grants and their access tiers, per engine: KV read plus its `metadata` sibling, `transit/sign` and `transit/keys/.../rotate` for the JWT key | none (0073, 0085, 0086) | -| `apps/vso-secrets/policies/auth-api.role.json` | `vault-policy` | the Workload's ServiceAccount and namespace, bound to that one policy | - | +| `apps/auth/kustomization.yaml` | `kubernetes` | the file set of the Application | - | +| `edge/ingressroutes.yaml` | `traefik`, for the tier each route's audience selects | the Application's `exposure`: `host` (authored, copied verbatim), each route's `path` + `match` → the rule, `process` + `surface` → the backend, `audience: anonymous` → no forward-auth, `contentPolicy: strict` → the security-headers middleware reference | entryPoint and TLS policy ([G-11](#g-11)); the `Middleware` objects both references resolve to: forward-auth elsewhere and security-headers here ([G-23](#g-23)) | +| `apps/vso-secrets/policies/auth-api.policy.json` | `vault-policy` | the Process's grants and their access tiers, per engine: KV read plus its `metadata` sibling, `transit/sign` and `transit/keys/.../rotate` for the JWT key | none (0073, 0085, 0086) | +| `apps/vso-secrets/policies/auth-api.role.json` | `vault-policy` | the Process's ServiceAccount and namespace, bound to that one policy | - | ### Not emitted, with the reason | file | why | |---|---| -| `configmap.yaml` | No Workload declares `assets`, and env-file entries render as inline `env:` on the container (chapter 10 partitions the file; only `${secret:…}` keys become `envFrom`). auth-ui's env file is not reproduced in this example set ([G-12](#g-12)) | -| `pvc.yaml` | Neither Workload declares `volumes`. No storage, therefore no `storageClassName`: nothing here renders `local-path`, and nothing renders Longhorn | -| `podmonitor.yaml` | The only scrape surface is fronted by a Service | +| `configmap.yaml` | No Process declares `assets`, and env-file entries render as inline `env:` on the container (chapter 10 partitions the file; only `${secret:…}` keys become `envFrom`). auth-ui's env file is not reproduced in this example set ([G-12](#g-12)) | +| `pvc.yaml` | Neither Process declares `volumes`. No storage, therefore no `storageClassName`: nothing here renders `local-path`, and nothing renders Longhorn | +| `podmonitor.yaml` | The only scrape surface is fronted by an Application | | `vso.yaml` | All three grants are `delivery: self`. No `VaultStaticSecret`, no `Secret`, no `envFrom`: the pod fetches at runtime. The secrets-at-rest gate does not apply, and the dead-grant check correctly does not fire on three grants with zero `${secret:…}` placeholders | | `HorizontalPodAutoscaler` | The registered `kubernetes` adapter can emit one; nothing in layer 1 declares autoscaling, so nothing derives one. Autoscaling is not in this model | -| `PrometheusRule`, anywhere | Not the model's to render. `alertClass` is published as a resolved fact and the monitoring stack that owns PromQL, severity and receivers reads it from the projection ([chapter 10](../../../10-service-intent.md#observability)) | +| `PrometheusRule`, anywhere | Not the model's to render. `alertClass` is published as a resolved fact and the monitoring stack that owns PromQL, severity and receivers reads it from the projection ([chapter 10](../../../10-project-intent.md#observability)) | ## G-01: the all-or-nothing switch is not expressible in these objects @@ -96,14 +96,14 @@ This is the single most important gap in the example set, and it is not a missing field: it is a demand the model makes on the delivery definition that no Kubernetes object satisfies. -**What the model requires** (0062, chapter 10): the Workloads of one Service -switch together or none switches. No Workload's new version receives traffic -until *every* Workload's new version is healthy by its own declared readiness. +**What the model requires** (0062, chapter 10): the Processes of one Application +switch together or none switches. No Process's new version receives traffic +until *every* Process's new version is healthy by its own declared readiness. If any member misses its `startupBudget`, none of them switch and the old -versions keep serving. Rollback is Service-scoped. +versions keep serving. Rollback is Application-scoped. **What the rendered objects do.** `workload.yaml` holds two Deployments. Each -one's new ReplicaSet is admitted to its own Service's `Endpoints` the moment its +one's new ReplicaSet is admitted to its own Application's `Endpoints` the moment its own readiness probe passes; the endpoints controller consults nothing else. `maxUnavailable: 0` makes each roll individually safe and does nothing across the pair. So on a two-image release: @@ -125,16 +125,16 @@ the file set and applies it; it has no notion of a gate. The Flux `Kustomization `apps-auth` (owned by `flux-root`, outside this tree) can carry health checks, but they are evaluated *after* apply (after traffic has already moved), and its health timeout class contradicts the budget anyway ([G-27](#g-27)). There is no -object in the rendered tree whose identity is "the Service". +object in the rendered tree whose identity is "the Application". **What would close it**: the delivery definition must pick one, and each costs something the model does not currently carry: -1. **Hold traffic at the edge.** Both Workloads roll to new ReplicaSets while +1. **Hold traffic at the edge.** Both Processes roll to new ReplicaSets while the IngressRoute still points at the old ones; the route flips once both are - healthy. Needs a stable/preview Service pair per Workload and a controller + healthy. Needs a stable/preview Application pair per Process and a controller that flips them (Argo Rollouts, Flagger, or a bespoke one). No registered - adapter emits any of it, and the flip must be Service-scoped, not per route. + adapter emits any of it, and the flip must be Application-scoped, not per route. 2. **Paused ReplicaSets plus a selector flip.** Create both new ReplicaSets paused, wait for both to report ready, then move a stable label selector. Requires the Service selector to be independent of the pod-template hash and @@ -144,9 +144,9 @@ something the model does not currently carry: applier, not of the tree, and therefore invisible to anyone reading the tree. Whichever is chosen, the model needs one thing from it that is not in these -objects today: **a Service-scoped health gate with a deadline**. Chapter 20 lists -"the health-gate deadline the Service's switchover waits on" among the derived -values and no chapter says what it is when a Service's Workloads declare +objects today: **an Application-scoped health gate with a deadline**. Chapter 20 lists +"the health-gate deadline the Application's switchover waits on" among the derived +values and no chapter says what it is when an Application's Processes declare different budgets: auth is 600 s and 30 s. `max` is the obvious reading and it is not written down. @@ -160,7 +160,7 @@ Every row is a thing the renderer work must decide or a field the model must grow. Ordered by how much they cost. **G-03 is retired.** The hostname is authored: `host: auth.jorisjonkers.dev` on -Service `auth`'s `public` exposure, with two named routes under it. The +Application `auth`'s `public` exposure, with two named routes under it. The IngressRoute host, the Gatus URLs and `AUTH_ISSUER` / `AUTH_LOGIN_URL` / `CONFIRMATION_URL` all trace to that one declaration, and the two identically matching routes are gone with it. The id is not reused and nothing is @@ -168,53 +168,53 @@ renumbered: every other reference in this file keeps pointing where it did. | id | gap | |---|---| -| [G-01](#g-01) | **The atomic switch is not expressible in plain Kubernetes objects.** Two Deployments roll independently; nothing in the tree gates one on the other. Detailed above. The delivery definition must close it, and the model must derive a Service-scoped health-gate deadline it does not currently define | -| G-02 | **`delivery: self` renders zero objects, and its policy and role have no producer.** Chapter 10 says `self` renders "a Vault policy, a Kubernetes auth role, and the application's own client wiring". The wiring is rendered (the `VAULT_*` env block); the policy and the role are in `apps/auth/vault.yaml`, which is not a Kubernetes object and which no registered adapter emits: `vso` emits `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret` and an operator ServiceAccount, none of which is a policy or a role. Every one of auth's three grants is `delivery: self`, so the entire secret surface of this domain has no producer | -| G-04 | **An edge into a domain outside the fragment set silently narrows the allow set.** `{service: stalwart, surface: smtp}` does not resolve here (the mail domain publishes no fragment in this example set), so `MAIL_HOST` / `MAIL_PORT` and the corresponding egress rule are absent rather than wrong. Over the composed union it resolves; the failure mode to design against is chapter 16's: a typo'd surface renders a valid policy with a missing rule, and the on-call sees a timeout, not an error code | +| [G-01](#g-01) | **The atomic switch is not expressible in plain Kubernetes objects.** Two Deployments roll independently; nothing in the tree gates one on the other. Detailed above. The delivery definition must close it, and the model must derive an Application-scoped health-gate deadline it does not currently define | +| G-02 | **`delivery: self` renders zero objects, and its policy and role have no producer.** Chapter 10 says `self` renders "a Vault policy, a Kubernetes auth role, and the application's own client wiring". The wiring is rendered (the `VAULT_*` env block); the policy and the role are in `apps/auth/vault.yaml`, which is not a Kubernetes object and which no registered adapter emits: `vso` emits `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret` and an operator ServiceAccount, none of which is a policy or a role. Every one of auth's three grants is `delivery: self`, so the entire secret surface of this project has no producer | +| G-04 | **An edge into a project outside the fragment set silently narrows the allow set.** `{application: stalwart, surface: smtp}` does not resolve here (the mail project publishes no fragment in this example set), so `MAIL_HOST` / `MAIL_PORT` and the corresponding egress rule are absent rather than wrong. Over the composed union it resolves; the failure mode to design against is chapter 16's: a typo'd surface renders a valid policy with a missing rule, and the on-call sees a timeout, not an error code | | G-05 | **`AUTH_CORS_ALLOWED_ORIGINS` has no predicate.** Nine hostnames by hand today; chapter 16's open item 1 says the derivation is probably "inbound edges declaring a browser surface", which no field declares. Not rendered | | G-06 | **`replicas` is bounded by the eligible node set, and that bound is 1 here.** auth-api's eligible set is `[frankfurt-contabo-1]`, the only `public-ingress` node. Live runs two replicas on that node as a capacity decision; the rule as written cannot reproduce it, and there is no anti-affinity vocabulary that would make a second replica mean anything. `minAvailable` is also still ungraded, and auth-ui declares none at all | | G-07 | **The PDB and the replica count come from two derivations that never meet.** `minAvailable: 1` against `replicas: 1` permits zero voluntary evictions, so draining `frankfurt-contabo-1`, which is also the control-plane node, blocks until someone deletes the PDB | | G-08 | **The probe derivation is partial.** `startupBudget` → period 5 s × threshold 120 and `progressDeadlineSeconds` = budget × 3 are stated. Which endpoint the startup probe uses is not (readiness is used here, which is a choice made during serialisation, the thing chapter 30 forbids), and neither are `periodSeconds`, `failureThreshold` or `initialDelaySeconds` for readiness and liveness. Only `timeoutSeconds: 5` has evidence behind it | -| G-09 | **Nothing declares which paths a read-only root filesystem needs writable.** The intent's prose says the render supplies `/tmp` as an emptyDir for the JVM; no field says so, no `sizeLimit` is derivable, and a Workload needing a second writable path has no way to say it short of a `writableRootFilesystem` exception that relaxes everything | -| G-10 | **`runtime: jvm` is asked to imply Spring Boot.** The env file expects `SPRING_CONFIG_IMPORT`, `VAULT_AUTHENTICATION`, `VAULT_KUBERNETES_ROLE` and `VAULT_DB_ENABLED` to be derived from `delivery: self`, but their spelling is spring-cloud-vault's. A `jvm` Workload that is not Spring gets keys it cannot read, and no field distinguishes the two | +| G-09 | **Nothing declares which paths a read-only root filesystem needs writable.** The intent's prose says the render supplies `/tmp` as an emptyDir for the JVM; no field says so, no `sizeLimit` is derivable, and a Process needing a second writable path has no way to say it short of a `writableRootFilesystem` exception that relaxes everything | +| G-10 | **`runtime: jvm` is asked to imply Spring Boot.** The env file expects `SPRING_CONFIG_IMPORT`, `VAULT_AUTHENTICATION`, `VAULT_KUBERNETES_ROLE` and `VAULT_DB_ENABLED` to be derived from `delivery: self`, but their spelling is spring-cloud-vault's. A `jvm` Process that is not Spring gets keys it cannot read, and no field distinguishes the two | | G-11 | **Every platform-component fact is a Platform document input this example set does not carry.** Marked `CONTEXT` in the files: cluster DNS, the edge, the metrics stack and the Secret Store selectors in `networkpolicy.yaml`; `release: metrics-stack` on the ServiceMonitor; `entryPoints` and `certResolver` on the IngressRoutes; `VAULT_ADDR`; `DEPLOYMENT_ENVIRONMENT`; and the nine remaining `OTEL_*` plus six `PYROSCOPE_*` values from the jvm Runtime Profile. The shapes are derived; the values must come from the pinned context. Note the coupling: if `VAULT_ADDR` resolves to the public hostname, the derived "egress to the Secret Store" rule selects pods the traffic never reaches | -| G-12 | **auth-ui's env file is not in the example set**, so its container renders with no `env` at all. The model requires one env file per Workload; the example set reproduces one of two | -| G-13 | **No chapter fixes the label set.** `app.kubernetes.io/{name,instance,part-of,managed-by}` here. `name` + `instance` are load-bearing (they are the selector, and a selector is immutable on a Deployment), so this is not cosmetic: changing the convention later is a delete-and-recreate on every workload in the estate | -| G-14 | **Per-Service directories versus per-domain objects.** `namespace.yaml` and the namespace-wide `default-deny` NetworkPolicy are one object per *domain*, while the adapter emits per *Service directory*. auth has one Service so nothing collides; the data domain has three, and three identical Namespace objects at three paths is `E_PATH_COLLISION` waiting for a second writer. Which Service directory owns a per-namespace object is undecided | +| G-12 | **auth-ui's env file is not in the example set**, so its container renders with no `env` at all. The model requires one env file per Process; the example set reproduces one of two | +| G-13 | **No chapter fixes the label set.** `app.kubernetes.io/{name,instance,part-of,managed-by}` here. `name` + `instance` are load-bearing (they are the selector, and a selector is immutable on a Deployment), so this is not cosmetic: changing the convention later is a delete-and-recreate on every process in the estate | +| G-14 | **Per-Application directories versus per-project objects.** `namespace.yaml` and the namespace-wide `default-deny` NetworkPolicy are one object per *project*, while the adapter emits per *Application directory*. auth has one Application so nothing collides; the data project has three, and three identical Namespace objects at three paths is `E_PATH_COLLISION` waiting for a second writer. Which Application directory owns a per-namespace object is undecided | | G-15 | **`automountServiceAccountToken` is underivable.** auth-api needs its projected token for Vault Kubernetes auth; auth-ui holds no grant and needs none. "No grant → no token" is a derivation nobody has written down, so the hardened default is not rendered | -| G-16 | **`alertClass` renders nothing here, and that is now correct.** `page` is declared on the Service every forward-auth protected route depends on, and what this tree renders from it is the `ServiceMonitor` above and nothing else. Rules, severity and receiver routing are the monitoring stack's, which reads the class from the published projection. The gap this records is historic: in the generation being replaced the class was supposed to derive objects inside the model and derived none, no registered adapter rendered a `PrometheusRule` (zero occurrences under `src/`, either generation), and the live Gatus ConfigMap had no `alerting` section at all. What the model still guarantees is that a declared class has a signal: `E_ALERT_CLASS_WITHOUT_SIGNAL` | +| G-16 | **`alertClass` renders nothing here, and that is now correct.** `page` is declared on the Application every forward-auth protected route depends on, and what this tree renders from it is the `ServiceMonitor` above and nothing else. Rules, severity and receiver routing are the monitoring stack's, which reads the class from the published projection. The gap this records is historic: in the generation being replaced the class was supposed to derive objects inside the model and derived none, no registered adapter rendered a `PrometheusRule` (zero occurrences under `src/`, either generation), and the live Gatus ConfigMap had no `alerting` section at all. What the model still guarantees is that a declared class has a signal: `E_ALERT_CLASS_WITHOUT_SIGNAL` | | G-17 | **No scrape `interval` or `scrapeTimeout`.** Omitted, which silently takes whatever the metrics stack's global default is, a value decided outside the model. **Closed**: one `monitors: {interval, timeout}` in the Platform document, named by every emitted monitor | -| G-18 | **The forward-auth caller produces no ingress rule.** auth's estate-wide role is derived from every *other* route's audience, not from a `dependsOn` edge, so the inbound edge set for `{service: auth}` is empty and no ingress rule admits the middleware. As rendered it is admitted only because the middleware runs in the same edge pod the exposure rule already allows, by luck of a shared peer, not by derivation | +| G-18 | **The forward-auth caller produces no ingress rule.** auth's estate-wide role is derived from every *other* route's audience, not from a `dependsOn` edge, so the inbound edge set for `{application: auth}` is empty and no ingress rule admits the middleware. As rendered it is admitted only because the middleware runs in the same edge pod the exposure rule already allows, by luck of a shared peer, not by derivation | | G-19 | **A byte-matched grant path cannot reach its KV-v2 metadata sibling.** 0027 forbids any transform on the path, so `secret/metadata/…` is outside every derived policy: version listing and soft-delete are denied to every reader in the estate | | G-20 | **The dynamic database credential is granted at a path it is not read from.** The grant declares `secret/data/platform/postgres/auth` (KV-v2) while the intent's prose and the derived `VAULT_DB_ENABLED=true` describe the database secrets engine, which lives at `database/creds/`. No grant declares that path, so the derived policy does not permit the read the wiring performs | -| G-21 | **`self-roll` derives a capability that cannot perform the roll.** The tier derives `patch` on the *granted path*, `transit/keys/auth-api-jwt`. Vault rotates a transit key at `transit/keys//rotate` and signs at `transit/sign/`, both requiring `update`. The grant that exists so this Workload can roll its own JWT key derives a policy that permits neither rotation nor signing. The `access` × path derivation needs a non-KV branch | +| G-21 | **`self-roll` derives a capability that cannot perform the roll.** The tier derives `patch` on the *granted path*, `transit/keys/auth-api-jwt`. Vault rotates a transit key at `transit/keys//rotate` and signs at `transit/sign/`, both requiring `update`. The grant that exists so this Process can roll its own JWT key derives a policy that permits neither rotation nor signing. The `access` × path derivation needs a non-KV branch | | G-22 | **No token TTLs.** `token_ttl`, `token_max_ttl` and `token_period` on the Kubernetes auth role have no field and no derivation; the mount default applies | -| G-23 | **The forward-auth `Middleware` object has no producer.** The registry says `traefik` emits IngressRoutes "with middleware references": references only. Every `audience: authenticated` route in every other domain resolves against a Middleware pointing at this Service, and nothing renders it | -| G-24 | **A domain's Deliverables land in another domain's namespace.** The Gatus endpoints ConfigMap is one estate-wide object in `utility-system`, contributed to by every domain. `E_FOREIGN_NAMESPACE` is satisfied only because the adapter owns the path rather than the Service, worth stating explicitly before someone tightens the rule | -| G-25 | **auth-ui cannot bind port 80 as rendered.** `provides: {http: 80}` with `runAsNonRoot: true` and `capabilities.drop: [ALL]`, and the only declared exception is `writableRootFilesystem`. Binding below 1024 needs `CAP_NET_BIND_SERVICE`, which the exception vocabulary can express (`capability:NET_BIND_SERVICE`) and this Workload does not declare. Nothing checks it: the model has every fact needed to refuse this at build time (an exposed or provided port < 1024, non-root, no capability exception), and no rule that does | +| G-23 | **The forward-auth `Middleware` object has no producer.** The registry says `traefik` emits IngressRoutes "with middleware references": references only. Every `audience: authenticated` route in every other project resolves against a Middleware pointing at this Application, and nothing renders it | +| G-24 | **A project's Deliverables land in another project's namespace.** The Gatus endpoints ConfigMap is one estate-wide object in `utility-system`, contributed to by every project. `E_FOREIGN_NAMESPACE` is satisfied only because the adapter owns the path rather than the Application, worth stating explicitly before someone tightens the rule | +| G-25 | **auth-ui cannot bind port 80 as rendered.** `provides: {http: 80}` with `runAsNonRoot: true` and `capabilities.drop: [ALL]`, and the only declared exception is `writableRootFilesystem`. Binding below 1024 needs `CAP_NET_BIND_APPLICATION`, which the exception vocabulary can express (`capability:NET_BIND_APPLICATION`) and this Process does not declare. Nothing checks it: the model has every fact needed to refuse this at build time (an exposed or provided port < 1024, non-root, no capability exception), and no rule that does | | G-26 | **`runAsNonRoot: true` with no UID.** Chapter 10 renders the control "with the UID from the image", and no pinned input carries a UID: the images lock carries digests. If the image's `USER` is a name rather than a number, the kubelet cannot verify non-root and the pod fails with `CreateContainerConfigError`. Either the lock grows a UID or the model grows a field | -| G-27 | **The Flux health timeout class contradicts the startup budget.** The class table gives `stateless: 5m`; auth-api's `startupBudget` is 600 s and its derived `progressDeadlineSeconds` is 1800. The Kustomization gives up at 5 minutes on a Workload the model says may legitimately take ten. Two derivations over the same declaration disagree | **Closed** by [0071](../../../../../docs/adr/model/0071-release-gate-inputs-are-layer-2.md): the class table is deleted and the Service-scoped number is the release-gate deadline, max over members of progressDeadlineSeconds. | -| G-28 | **The hardening class does not say where its controls land.** `runAsNonRoot` and `seccompProfile` are rendered at pod level, `readOnlyRootFilesystem` and `capabilities` at container level (the latter two have no pod-level form). The split is a serialisation choice, and it matters the moment `sidecars` is graded: a pod-level control covers a sidecar the Workload did not declare | +| G-27 | **The Flux health timeout class contradicts the startup budget.** The class table gives `stateless: 5m`; auth-api's `startupBudget` is 600 s and its derived `progressDeadlineSeconds` is 1800. The Kustomization gives up at 5 minutes on a Process the model says may legitimately take ten. Two derivations over the same declaration disagree | **Closed** by [0071](../../../../../docs/adr/model/0071-release-gate-inputs-are-layer-2.md): the class table is deleted and the Application-scoped number is the release-gate deadline, max over members of progressDeadlineSeconds. | +| G-28 | **The hardening class does not say where its controls land.** `runAsNonRoot` and `seccompProfile` are rendered at pod level, `readOnlyRootFilesystem` and `capabilities` at container level (the latter two have no pod-level form). The split is a serialisation choice, and it matters the moment `sidecars` is graded: a pod-level control covers a sidecar the Process did not declare | | G-29 | **Two label sources for `arch`.** `kubernetes.io/arch` is the kubelet's own; the node contract emits 110 labels for 7 nodes, 55 of them under a prefix named after an archived repository. Which one a selector uses is not fixed, and picking the archived prefix is the trap 0056 exists to retire. Capabilities have only one source (`platform.jorisjonkers.dev/capability-*`), so the ambiguity is `arch`-specific, and it is a single-authority (property 2) question, not a style one | | G-30 | **`NetworkPolicy` has no registered producer.** Chapter 30's open item 2: the only implementation is in the generation being deleted, so coverage for the kind goes from unregistered to absent. Everything in `networkpolicy.yaml` is what the future `networking` adapter must emit. The same holds for the RBAC gap, see G-31 | -| G-31 | **No `rbac` adapter.** Chapter 30's largest true gap (16 objects). auth's Workloads need no in-cluster RBAC of their own, so nothing is rendered here, but the identity model implies a Role/RoleBinding per Workload and nothing produces one | +| G-31 | **No `rbac` adapter.** Chapter 30's largest true gap (16 objects). auth's Processes need no in-cluster RBAC of their own, so nothing is rendered here, but the identity model implies a Role/RoleBinding per Process and nothing produces one | | G-32 | **The image digests here are illustrative.** The images lock is a pinned input that this example set does not reproduce, so the two `sha256:` values stand for lock entries rather than being read from one. The alias → repository path mapping (`auth-api` → `ghcr.io/jorisjonkers-dev/auth/auth-api`) is the lock's too: nothing in layer 1 names a registry | ## What this example is meant to prove -- Two Workloads, one Service, one file set, one kustomization, and past that +- Two Processes, one Application, one file set, one kustomization, and past that grouping, no atomicity ([G-01](#g-01)). - `placement` splitting cleanly in two: `arch` and `capabilities` become a selector and an affinity term; `memory` and `cpu` become `resources` under the two shape rules (memory request == limit, cpu request with no limit) and produce no selector at all, because eligibility was checked at build time against node allocatable. -- **One hostname fronting two Workloads**: the case that forced `exposure` up - to the Service. `/api` routes to auth-api and `/` to auth-ui from a single - authored `host`, which is unsayable while `exposure` sits on the Workload, and +- **One hostname fronting two Processes**: the case that forced `exposure` up + to the Application. `/api` routes to auth-api and `/` to auth-ui from a single + authored `host`, which is unsayable while `exposure` sits on the Process, and three env values resolve from it through `${exposure:auth.public#url}` rather than repeating the literal. -- Two Workloads meeting `restricted` identically, one of them by declaring the +- Two Processes meeting `restricted` identically, one of them by declaring the paths nginx writes rather than by relaxing a control. - `delivery: self` producing no `VaultStaticSecret`, no `Secret` and no `envFrom`, and as a consequence no Kubernetes object at all diff --git a/spec/v1/examples/data/data.domain.yml b/spec/v1/examples/data/data.project.yml similarity index 78% rename from spec/v1/examples/data/data.domain.yml rename to spec/v1/examples/data/data.project.yml index 5bf07f6..2467498 100644 --- a/spec/v1/examples/data/data.domain.yml +++ b/spec/v1/examples/data/data.project.yml @@ -1,65 +1,65 @@ -# Worked example: the data domain's Intent Fragment +# Worked example: the data project's Intent Fragment # # collections/data/platform/data.yml # -# THREE Services in one file. They are neighbours, not a unit: co-location in a -# domain file couples nothing, and each of the three releases on its own -# (docs/adr/model/0062-service-is-the-release-unit.md, -# docs/adr/model/0063-intent-authored-per-domain.md). "Every Service in a domain file +# 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 +# (docs/adr/model/0062-application-is-the-release-unit.md, +# docs/adr/model/0063-intent-authored-per-project.md). "Every Application in a project file # releases as one" was considered and rejected exactly because it would let # someone couple nine rollouts by editing an unrelated line. Atomicity stops at -# the Service boundary; the file is an authoring and publication unit. +# the Application boundary; the file is an authoring and publication unit. # -# None of the three has a repository of its own. The data domain ships from -# homelab-collections, and publication is repository-scoped, so the domain may +# None of the three has a repository of its own. The data project ships from +# homelab-collections, and publication is repository-scoped, so the project may # stay there or split out later -- composition behaves identically -# (docs/adr/model/0037-composition-oci-fragments.md). What may not happen is a domain -# spanning two repositories: this file is the whole domain. +# (docs/adr/model/0037-composition-oci-fragments.md). What may not happen is a project +# spanning two repositories: this file is the whole project. # -# Exercises: three independently released Services, a third-party image behind +# Exercises: three independently released Applications, a third-party image behind # an alias with `runtime: none`, declared writable paths, structured # `disk` placement, TCP probes, a static Asset, a sidecar, all three -# Durability Classes, `provides` consumed by eight Services, and one authored -# hostname that does not follow its Service id. +# Durability Classes, `provides` consumed by eight Applications, and one authored +# hostname that does not follow its Application id. # -# Companion env file (per Workload, like every env file): +# Companion env file (per Process, like every env file): # platform/env/postgres/base.env -- examples/platform-postgres.base.env # The data model's own semver, not the toolkit's # (docs/adr/model/0039-artifact-schema-versioning.md). schemaVersion: 1.0.0 -domain: data # namespace derives: data-system -owner: joris # one owner for every Service in the file; a Service - # needing a different owner needs its own domain +project: data # namespace derives: data-system +owner: joris # one owner for every Application in the file; an Application + # needing a different owner needs its own project -services: +applications: # ======================================================= platform-postgres - id: platform-postgres # The scrape surface belongs to the exporter sidecar inside the `postgres` - # Workload, and the block names it the same way a route does: by Workload + # Process, and the block names it the same way a route does: by Process # and surface, never by port. `metrics` is declared once, in `provides`. observability: - alertClass: page # eight Services depend on it + alertClass: page # eight Applications depend on it scrape: - workload: postgres + process: postgres surface: metrics path: /metrics - workloads: + processes: - name: postgres # `platform-postgres` is the repository/product name; `postgres` is what # the process is actually called. That is not a divergence needing a # field to record it -- it is the name - # (docs/adr/model/0063-intent-authored-per-domain.md). The identity derived + # (docs/adr/model/0063-intent-authored-per-project.md). The identity derived # from it is `data-system.postgres`. - lifecycle: service + lifecycle: application # An ALIAS, resolved to a digest through the images lock -- exactly like # a first-party image. Third-party is not a reason to exempt the most - # stateful workload in the estate: `pgvector/pgvector:pg17` is a mutable - # tag that moves on every upstream build, and this Workload is Recreate + # stateful process in the estate: `pgvector/pgvector:pg17` is a mutable + # tag that moves on every upstream build, and this Process is Recreate # on a local-path RWO volume, so any reschedule is a fresh pull. # Renovate raises the digest bump behind the ordering gate # (docs/adr/model/0040-renovate-ordering-gate.md), and every rendered @@ -73,14 +73,14 @@ services: # What this process IS, which the platform keys its backup method off. # Not `runtime`, which is instrumentation and is correctly `none` here - # (docs/adr/model/0078-engine-is-workload-vocabulary.md). Required - # because this Workload holds a volume whose class derives a backup. + # (docs/adr/model/0078-engine-is-process-vocabulary.md). Required + # because this Process holds a volume whose class derives a backup. engine: postgres - # Declared on the Workload that opens the sockets. Every consumer names - # the Service and the surface, never the port -- `dependsOn` targets - # `{service, surface}`, and surface names are unique within a Service. - # The eight consumers today: auth (its api Workload), agents-api, + # Declared on the Process that opens the sockets. Every consumer names + # the Application and the surface, never the port -- `dependsOn` targets + # `{application, surface}`, and surface names are unique within an Application. + # The eight consumers today: auth (its api Process), agents-api, # knowledge (api and ingest worker), lightrag, n8n, outline, and the # observability backup jobs. # @@ -92,7 +92,7 @@ services: metrics: 9187 placement: - # The datastore eight Services queue behind. Raw quantities, matched + # The datastore eight Applications queue behind. Raw quantities, matched # against node allocatable from the pinned node contract # (docs/adr/model/0061-placement-is-hard-dimensions.md, # docs/adr/model/0056-node-facts-single-source.md). @@ -104,7 +104,7 @@ services: # is acceptable, with no ordering between them. The three Pis carry a # 64G sdcard and nothing else, so `media` alone excludes them and the # 100Gi ask excludes them twice over -- which is the point. Under the - # deleted class model this Workload could sit beside a placement + # deleted class model this Process could sit beside a placement # admitting only the Pis, pass the build, and go Pending at apply. # # `disk` filters FIRST placement only. Once a PV is bound, the binding @@ -117,7 +117,7 @@ services: # decides which nodes are eligible to hold it. disk: media: [nvme, ssd] - # No `size`: it is derived as the sum of this Workload's volume + # No `size`: it is derived as the sum of this Process's volume # sizes, so the quantity has one declaring site # (docs/adr/model/0081-volume-size-is-a-hard-dimension.md) @@ -138,14 +138,14 @@ services: writablePaths: [/var/run/postgresql, /tmp] # postgres-exporter runs in the same pod and serves the metrics port - # this Workload declares in `provides`. stalwart (stalwart-apply) and + # this Process declares in `provides`. stalwart (stalwart-apply) and # agent-runner (the agent-gateway jar) have the same shape - # (docs/adr/model/0064-sidecars-are-workload-vocabulary.md). + # (docs/adr/model/0064-sidecars-are-process-vocabulary.md). # # A sidecar carries what a container carries: its own memory, cpu and - # hardening. The node dimensions stay on the Workload above, because + # hardening. The node dimensions stay on the Process above, because # nodeSelector is pod-level and the pod is one. Eligibility SUMS: this - # Workload needs a node with 2112Mi free, not 2Gi. + # Process needs a node with 2112Mi free, not 2Gi. sidecars: - name: postgres-exporter image: postgres-exporter @@ -165,7 +165,7 @@ services: startupBudget: 60s # REQUIRED, no default, and `recreate` is the only honourable answer: - # this Workload holds a ReadWriteOnce local-path volume, which cannot + # this Process holds a ReadWriteOnce local-path volume, which cannot # attach to two pods at once. Declaring `rolling` here is # E_CUTOVER_UNHONOURABLE rather than a silent downgrade to Recreate -- # the owner states the stop-then-start they already have. @@ -176,9 +176,9 @@ services: - claim: postgres-data mountAt: /var/lib/postgresql/data durability: irreplaceable - # The one fact only the owning Service knows + # The one fact only the owning Application knows # (docs/adr/model/0015-durability-class-per-volume.md); schedule, sweep - # and destination are derived. This is the database eight Services + # and destination are derived. This is the database eight Applications # queue behind, on a `local-path` volume in a cluster with no # VolumeSnapshot CRDs and no CSI snapshot support, so losing the # node loses every consumer's data at once. `irreplaceable` renders @@ -201,13 +201,13 @@ services: - from: config/postgresql.conf mountAt: /etc/postgresql/postgresql.conf # No `onChange`. A change to an Asset is content-hashed and restarts - # the Workload unconditionally; the field does not exist + # the Process unconditionally; the field does not exist # (docs/adr/model/0094-asset-change-restarts-unconditionally.md). # NOT declared: init-databases.sh. # # Its 98 lines create one database and one owning user per consuming - # Service -- auth_db, agents_db, knowledge_db, n8n_db -- which the + # Application -- auth_db, agents_db, knowledge_db, n8n_db -- which the # dependency graph already knows from inbound edges and grants. It # becomes a derived Deliverable (chapter 16). An executable is code, # and code is not configuration. @@ -223,16 +223,16 @@ services: # knowledge's database passwords too -- a KV-v2 read returns the whole # document. The subtree is now: # - # .../platform/postgres/auth -> auth's api Workload - # .../platform/postgres/kb -> knowledge (both Workloads) - # .../platform/postgres/exporter -> this Workload + # .../platform/postgres/auth -> auth's api Process + # .../platform/postgres/kb -> knowledge (both Processes) + # .../platform/postgres/exporter -> this Process # - # The data domain owns that Subtree and lists the readers of each path, + # The data project owns that Subtree and lists the readers of each path, # which is what makes "who breaks if I rotate this" answerable without # reading live Vault contents. postgres itself does not read its # consumers' credentials; each consumer declares its own grant. # - # `delivery: env`, so this Service is refused until the pinned Cluster + # `delivery: env`, so this Application is refused until the pinned Cluster # Context advertises `secretsEncryption: true` -- # E_SECRETS_AT_REST_REQUIRED (docs/adr/model/0028-secrets-at-rest-gate.md). secrets: @@ -244,34 +244,34 @@ services: # ======================================================= platform-rabbitmq # - # A second Service in the same file and the same namespace. It shares nothing + # A second Application in the same file and the same namespace. It shares nothing # with postgres: not a release, not an identity, not a grant. `data-system` - # holds three Services by construction, so it is NOT a trust boundary -- no + # holds three Applications by construction, so it is NOT a trust boundary -- no # isolation claim may rest on the namespace wall. Isolation here is the # derived default-deny edge set, evaluated per pod - # (docs/adr/model/0035-network-policy-default-deny.md), plus per-Workload identity - # (docs/adr/model/0024-identity-per-workload.md). + # (docs/adr/model/0035-network-policy-default-deny.md), plus per-Process identity + # (docs/adr/model/0024-identity-per-process.md). - id: platform-rabbitmq - # Its own urgency, not the domain's: raising the class to the header would + # Its own urgency, not the project's: raising the class to the header would # make this file page as loudly as postgres does. rabbitmq exports metrics - # itself, so no sidecar is involved and the surface is the Workload's own. + # itself, so no sidecar is involved and the surface is the Process's own. observability: alertClass: urgent scrape: - workload: rabbitmq + process: rabbitmq surface: metrics # the prometheus plugin's own listener path: /metrics - # THE HOST DOES NOT FOLLOW THE SERVICE ID, and this Service is the estate's - # evidence for authoring hostnames rather than deriving them: the Service is + # THE HOST DOES NOT FOLLOW THE APPLICATION ID, and this Application is the estate's + # evidence for authoring hostnames rather than deriving them: the Application is # `platform-rabbitmq` and the live host is `rabbitmq.jorisjonkers.dev`. Any - # `.` rule produces `platform-rabbitmq.jorisjonkers.dev`, + # `.` rule produces `platform-rabbitmq.jorisjonkers.dev`, # which resolves to nothing -- and it would be right for auth, so the # derivation would look correct until someone read this line. `host` is # therefore the full FQDN, authored, unique across the estate # (E_DUPLICATE_HOST at composition) (review/EXPOSURE-MANIFEST.md). exposure: - - name: management # unique within the Service; names what this + - name: management # unique within the Application; names what this # host is, which `public` would not host: rabbitmq.jorisjonkers.dev audience: authenticated # the management UI behind forward-auth. One @@ -279,7 +279,7 @@ services: # name are all derived and none is authorable # (docs/adr/model/0018-exposure-by-audience.md) routes: - - {path: /, match: prefix, workload: rabbitmq, surface: management} + - {path: /, match: prefix, process: rabbitmq, surface: management} # No `contentPolicy` and no `redirectTo`: this route takes the derived # header baseline and redirects nowhere. Those two fields are the whole # authored proxy vocabulary, and neither is needed here. @@ -289,13 +289,13 @@ services: # that list derives from edges like this one rather than being retyped # (chapter 16). The predicate is still open. - workloads: + processes: - name: rabbitmq - lifecycle: service + lifecycle: application image: rabbitmq # an alias, resolved through the images lock runtime: none # third-party image; no profile values to inject engine: rabbitmq # keys the backup method: a definitions export - # (docs/adr/model/0078-engine-is-workload-vocabulary.md) + # (docs/adr/model/0078-engine-is-process-vocabulary.md) provides: amqp: 5672 # named by auth and knowledge in `dependsOn` @@ -309,7 +309,7 @@ services: # No `hardening` exceptions: the upstream image runs non-root with a # read-only root filesystem once its mnesia directory is a volume. - # No `exposure` here: it sits on the Service above, and its one route + # No `exposure` here: it sits on the Application above, and its one route # names the `management` surface declared in `provides`. `amqp` and # `metrics` are provided and not routed -- providing a port is not # exposing it, and only the route decides which of the three the edge @@ -321,7 +321,7 @@ services: startupBudget: 120s - # `recreate`: this Workload holds an RWO local-path volume. `rolling` + # `recreate`: this Process holds an RWO local-path volume. `rolling` # would be E_CUTOVER_UNHONOURABLE, not a silent Recreate. cutover: recreate @@ -338,9 +338,9 @@ services: # undelivered messages and the definitions, not a consumer's data. # No `secrets`, and therefore no env file placeholder to bind. This - # Workload reads no Secret Store path: its clients hold the grants -- + # Process reads no Secret Store path: its clients hold the grants -- # knowledge reads `secret/data/platform/rabbitmq`, and auth's api - # Workload keeps its broker credentials in its own document. + # Process keeps its broker credentials in its own document. # ========================================================= platform-valkey - id: platform-valkey @@ -351,23 +351,23 @@ services: # omission says it: there is no `none` class to write, because a value # meaning "I wrote the field to say I did not want the field" is ceremony # (docs/adr/model/0021-observability-scrape-and-alert-class.md). Giving this - # Service a class would be E_ALERT_CLASS_WITHOUT_SIGNAL, and the repair is + # Application a class would be E_ALERT_CLASS_WITHOUT_SIGNAL, and the repair is # an exporter sidecar plus a scrape surface, as postgres has above. - workloads: + processes: - name: valkey - lifecycle: service + lifecycle: application image: valkey # an alias, resolved through the images lock runtime: none provides: - redis: 6379 # the surface auth's api Workload names; the wire + redis: 6379 # the surface auth's api Process names; the wire # protocol is the surface's name, not the product's placement: memory: 256Mi cpu: 50m - # No further dimensions. The image is multi-arch and this Workload + # No further dimensions. The image is multi-arch and this Process # needs no node capability, so every node is eligible -- which is what # a cache should be, and what a `tailscale` filter would have # pretended to narrow while excluding none of the 7. @@ -388,7 +388,7 @@ services: mountAt: /data size: 2Gi # No `engine`. `reconstructible` derives no backup, and an engine on - # a Workload that derives none is refused -- + # a Process that derives none is refused -- # E_ENGINE_WITHOUT_DURABILITY # (docs/adr/model/0077-durability-derives-a-backup.md) durability: reconstructible diff --git a/spec/v1/examples/data/env/platform-postgres.base.env b/spec/v1/examples/data/env/platform-postgres.base.env index 9aa1bf0..de91df3 100644 --- a/spec/v1/examples/data/env/platform-postgres.base.env +++ b/spec/v1/examples/data/env/platform-postgres.base.env @@ -1,22 +1,22 @@ # collections/data/platform/env/postgres/base.env # -# Per Workload, like every env file -# (docs/adr/model/0011-configuration-env-files-per-workload.md). A third-party image +# Per Process, like every env file +# (docs/adr/model/0011-configuration-env-files-per-process.md). A third-party image # with runtime: none receives no profile values at all, so this file is short. # The single secret placeholder is consumed by the postgres-exporter sidecar. # -# The Service this Workload belongs to is declared in the data domain's Intent +# The Application this Process belongs to is declared in the data project's Intent # Fragment, platform/data.yml, alongside platform-rabbitmq and platform-valkey -# (docs/adr/model/0063-intent-authored-per-domain.md) -- reproduced as -# examples/domains/data.yml. Sharing that file couples nothing: the three -# Services release independently -# (docs/adr/model/0062-service-is-the-release-unit.md). +# (docs/adr/model/0063-intent-authored-per-project.md) -- reproduced as +# examples/projects/data.yml. Sharing that file couples nothing: the three +# Applications release independently +# (docs/adr/model/0062-application-is-the-release-unit.md). POSTGRES_DB=postgres POSTGRES_USER=postgres # The exporter's connection string. The string between ${secret: and # BYTE- -# MATCHES the `path:` of the Workload-level grant in data.yml +# MATCHES the `path:` of the Process-level grant in data.yml # (docs/adr/model/0027-secret-reference-join-key.md) -- mount and KV `data/` segment # included, and pointing at the exporter's own split path rather than the old # shared document. Without that grant this line is an unauthorised reference; diff --git a/spec/v1/examples/data/rendered/README.md b/spec/v1/examples/data/rendered/README.md index d60b008..b781fb5 100644 --- a/spec/v1/examples/data/rendered/README.md +++ b/spec/v1/examples/data/rendered/README.md @@ -1,10 +1,10 @@ -# Rendered output: the `data` domain +# Rendered output: the `data` project -What a renderer must produce from [`../data.domain.yml`](../data.domain.yml) and +What a renderer must produce from [`../data.project.yml`](../data.project.yml) and [`../env/platform-postgres.base.env`](../env/platform-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` +attribution), and the two amendments in `review/PLACEMENT-PROJECT-MANIFEST.md` and `review/EXPOSURE-MANIFEST.md`. It is the **goal state**, not today's output. Today's renderer emits no @@ -17,14 +17,14 @@ is not in use. Where a value cannot be derived from the intent as declared it is marked in the file rather than invented, and the reason is a row in [Gaps](#gaps). -**Three Services (`platform-postgres`, `platform-rabbitmq`, `platform-valkey`), -in ONE namespace, `data-system`, each releasing on its own.** This domain is the +**Three Applications (`platform-postgres`, `platform-rabbitmq`, `platform-valkey`), +in ONE namespace, `data-system`, each releasing on its own.** This project is the proof that a namespace is not a trust boundary, and the tree is arranged so that claim is checkable rather than asserted: -- three Service directories, three `kustomization.yaml`, three file sets, and no +- three Application directories, three `kustomization.yaml`, three file sets, and no object in any of them naming an object in another; -- three ServiceAccounts, one per Workload, named for the Workload alone, which +- three ServiceAccounts, one per Process, named for the Process alone, which is the *entire* boundary between them, because Vault's Kubernetes auth binds a role to a name and a namespace and the namespace half is shared; - three NetworkPolicies, none of which admits the two pods sitting beside it. @@ -50,7 +50,7 @@ made three derivations explicit that a renderer had been choosing: | change | decided in | |---|---| -| the fixed label set, `instance` now the Workload and `component` the runtime | [0072](../../../../../docs/adr/model/0072-the-label-set-is-fixed.md) | +| the fixed label set, `instance` now the Process and `component` the runtime | [0072](../../../../../docs/adr/model/0072-the-label-set-is-fixed.md) | | `automountServiceAccountToken`, `false` wherever the pod does not authenticate | [0087](../../../../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md) | | `runAsUser`, `runAsGroup`, and `fsGroup` where a volume is held | [0082](../../../../../docs/adr/model/0082-images-lock-carries-uid-and-gid.md) | | a startup probe pointed at the **liveness** endpoint, and one probe cadence | [0088](../../../../../docs/adr/model/0088-startup-probe-targets-liveness.md) | @@ -65,99 +65,99 @@ those rows were decided on 2026-09-07 and the row-by-row status lives in ## Estate-scoped objects are not in this tree Two files this tree used to carry, `edge/middlewares.yaml` and -`observability/gatus-endpoints.yaml`, render in the **platform domains** now: +`observability/gatus-endpoints.yaml`, render in the **platform projects** now: the Middleware set is emitted per tier by the `traefik` adapter into the edge -domain, and the Gatus endpoint list is an inbound derivation rendered as the -declared `gatus` Service's own Asset in the observability domain +project, and the Gatus endpoint list is an inbound derivation rendered as the +declared `gatus` Application's own Asset in the observability project ([0096](../../../../../docs/adr/model/0096-the-foundation-is-declared.md), -[0098](../../../../../docs/adr/model/0098-one-publication-path.md)). This domain +[0098](../../../../../docs/adr/model/0098-one-publication-path.md)). This project contributes routes and exposures to both; it owns neither object. ## The files | file | adapter | derives from (layer 1) | cannot derive today | |---|---|---|---| -| `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)) | +| `namespace.yaml` | `kubernetes` | `project: data` → `data-system` | Nothing about the object. Which of the three Application 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 project | Which Application 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 Application list of the project | 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/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/serviceaccount.yaml` | `kubernetes` | process `name`, `project` | `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 Project 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)) | -| `apps/platform-postgres/servicemonitor.yaml` | `prometheus` | `observability.scrape {workload: postgres, surface: metrics, path}`, `provides` | cadence from the Platform document ([G-19](#g-19)); the operator's `release` selector ([G-21](#g-21)) | +| `apps/platform-postgres/servicemonitor.yaml` | `prometheus` | `observability.scrape {process: postgres, surface: metrics, path}`, `provides` | cadence from the Platform document ([G-19](#g-19)); the operator's `release` selector ([G-21](#g-21)) | | `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-postgres/vso.yaml` | `vso` | `secrets` (path, access, delivery, rotation), process `name` | the object-naming rule ([G-24](#g-24)); one VaultAuth per estate vs per Process ([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 Application'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/serviceaccount.yaml` | `kubernetes` | workload `name`, `domain` | `automountServiceAccountToken` ([G-14](#g-14)) | +| `apps/platform-rabbitmq/serviceaccount.yaml` | `kubernetes` | process `name`, `project` | `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/servicemonitor.yaml` | `prometheus` | `observability.scrape {process: 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/serviceaccount.yaml` | `kubernetes` | workload `name`, `domain` | `automountServiceAccountToken` ([G-14](#g-14)) | +| `apps/platform-valkey/serviceaccount.yaml` | `kubernetes` | process `name`, `project` | `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)) | | `apps/platform-valkey/kustomization.yaml` | `kubernetes` | the emitted file set | - | -| `edge/ingressroutes.yaml` | `traefik`, for the tier each route's audience selects | the `management` exposure: `host` (authored, and it does **not** follow the Service id), its one route → the rule and the backend surface, `audience: authenticated` → the forward-auth chain | the `Middleware` object the chain references ([G-31](#g-31)); entryPoint and TLS policy ([G-21](#g-21)); the CORS contribution this route makes to another domain ([G-32](#g-32)) | +| `edge/ingressroutes.yaml` | `traefik`, for the tier each route's audience selects | the `management` exposure: `host` (authored, and it does **not** follow the Application id), its one route → the rule and the backend surface, `audience: authenticated` → the forward-auth chain | the `Middleware` object the chain references ([G-31](#g-31)); entryPoint and TLS policy ([G-21](#g-21)); the CORS contribution this route makes to another project ([G-32](#g-32)) | | `apps/platform-postgres/backup.yaml` | `kubernetes` | `durability: irreplaceable` plus `engine: postgres`: the platform's per-class policy supplies the window and retention, the engine catalog the command | none (0077) | | `apps/platform-rabbitmq/backup.yaml` | `kubernetes` | `durability: recoverable` plus `engine: rabbitmq`: a backup and a sweep, no off-cluster copy | none (0077) | | `apps/vso-secrets/policies/postgres.policy.json` | `vault-policy` | the exporter grant and the derived off-cluster backup credential | none (0073, 0077) | -| `apps/vso-secrets/policies/postgres.role.json` | `vault-policy` | the Workload's ServiceAccount and namespace | - | +| `apps/vso-secrets/policies/postgres.role.json` | `vault-policy` | the Process's ServiceAccount and namespace | - | ### Not emitted, with the reason | file | why | |---|---| -| `pdb.yaml`, anywhere | No Workload in this domain declares `minAvailable`, and the field is ungraded. A renderer inventing one would be authoring an availability requirement only the Service knows. The consequence is worth stating: **the estate's datastore has no disruption budget**, so a node drain evicts it with nothing to object | -| `podmonitor.yaml` | Both scrape surfaces are fronted by a Service | +| `pdb.yaml`, anywhere | No Process in this project declares `minAvailable`, and the field is ungraded. A renderer inventing one would be authoring an availability requirement only the Application knows. The consequence is worth stating: **the estate's datastore has no disruption budget**, so a node drain evicts it with nothing to object | +| `podmonitor.yaml` | Both scrape surfaces are fronted by an Application | | `hpa.yaml` | The registered `kubernetes` adapter can emit one; nothing in layer 1 declares autoscaling | | `vso.yaml` for rabbitmq and valkey | Neither declares `secrets` at either level. Their clients hold the credentials, which is what a provider looks like | | `configmap.yaml` for rabbitmq and valkey | No `assets`, and their env files are not in this example set ([G-28](#g-28)) | -| `servicemonitor.yaml` for valkey | No `scrape`. No exporter runs beside it, so there is no metrics surface and none is invented, which is why the Service declares `alertClass: none` rather than a class it could not signal | -| a backup job for `valkey-data` | `durability: reconstructible` derives none. **The one absence in this domain that is a decision rather than a hole** | -| `PrometheusRule`, anywhere | Not the model's to render. `alertClass` is published as a resolved fact and the monitoring stack that owns PromQL, severity and receivers reads it from the projection ([chapter 10](../../../10-service-intent.md#observability)) | +| `servicemonitor.yaml` for valkey | No `scrape`. No exporter runs beside it, so there is no metrics surface and none is invented, which is why the Application declares `alertClass: none` rather than a class it could not signal | +| a backup job for `valkey-data` | `durability: reconstructible` derives none. **The one absence in this project that is a decision rather than a hole** | +| `PrometheusRule`, anywhere | Not the model's to render. `alertClass` is published as a resolved fact and the monitoring stack that owns PromQL, severity and receivers reads it from the projection ([chapter 10](../../../10-project-intent.md#observability)) | | `Role` / `RoleBinding` | No `rbac` adapter ([G-35](#g-35)) | | a backup job for `postgres-data` and `rabbitmq-data` | `irreplaceable` and `recoverable` both demand one; nothing reads `durability` ([G-12](#g-12)) | -## G-01: three Services release independently and reconcile as one +## G-01: three Applications release independently and reconcile as one -The domain file holds three Services because they are neighbours, not a unit. -Atomicity stops at the Service boundary: each of the three switches versions on -its own, and there is no mechanism to couple two Services: a pair that must -release together is one Service, and a surviving pair is evidence the boundary is +The project file holds three Applications because they are neighbours, not a unit. +Atomicity stops at the Application boundary: each of the three switches versions on +its own, and there is no mechanism to couple two Applications: a pair that must +release together is one Application, and a surviving pair is evidence the boundary is drawn wrong. The rendered tree carries that faithfully: three directories, three kustomizations, three independent file sets. **And then one Flux `Kustomization` named `apps-data` reconciles all three.** The -Reconcile Unit is derived as `apps-` (chapter 20), it is rendered by +Reconcile Unit is derived as `apps-` (chapter 20), it is rendered by `flux-root` outside this tree, and it does not distinguish its members. So: - a one-line edit to `platform-valkey` re-applies `platform-postgres` and `platform-rabbitmq`; -- a failure anywhere in the domain, a PVC that will not bind, a +- a failure anywhere in the project, a PVC that will not bind, a `secretsEncryption` gate refusing platform-postgres's VaultStaticSecret, takes the whole `apps-data` Kustomization not-Ready, and Flux reports one status for 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 +- the health timeout class is taken as *the strongest class across an Application's + Processes*, and no chapter says what happens when three **Applications** share one Kustomization. All three here are `stateful`, so 10m, and the disagreement does - not surface, and it will on the first domain that mixes classes. + not surface, and it will on the first project that mixes classes. Nothing in the rendered tree records which objects belong to which release. The only marker is `app.kubernetes.io/instance`, and no controller reads it. The **inverse** of auth's G-01 is worth stating beside it. There, the model -demanded atomicity across two Workloads and the objects could not express it. -Here, the model demands *independence* across three Services and the objects +demanded atomicity across two Processes and the objects could not express it. +Here, the model demands *independence* across three Applications and the objects express it perfectly, right up to the point where the reconcile unit, derived from a different rule for a different purpose, silently binds them back together. -Two derivations over one domain file disagree about what a unit is, and neither +Two derivations over one project file disagree about what a unit is, and neither knows about the other. ## G-07: `disk` places the pod once; the PV binding places it for ever -This is the domain's `E_DISK_BINDING_CONFLICT` territory, and it needs stating in +This is the project's `E_DISK_BINDING_CONFLICT` territory, and it needs stating in full because the rendered object is actively misleading about it. **What the intent declares.** `platform-postgres` declares @@ -173,7 +173,7 @@ against the pinned node contract, the eligible set is four nodes: The three Pis fail three separate ways: `arm64` against `arch`, `sdcard` against `media`, and 64G against a 100Gi ask. Under the deleted resource-class model this -Workload could have sat beside a placement admitting only the Pis, passed the +Process could have sat beside a placement admitting only the Pis, passed the build, and gone `Pending` at apply. That is what the dimension bought. **What happens next.** Storage is `local-path`. The claim is `ReadWriteOnce` and @@ -224,22 +224,22 @@ 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 + trap: a stateful Process 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. - **`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 ( + claim to the Process'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. + are declared by nothing in layer 1 and consumed by nothing in this project, + which addresses its provider by the Process's Application 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. The same reasoning renders `platform-rabbitmq` and `platform-valkey` as Deployments. The cost is uniform and stated: **every roll of the estate's -datastore is a zero-pod window**, and eight Services queue behind it. The estate +datastore is a zero-pod window**, and eight Applications queue behind it. The estate already paid for the other side of this, *"under `Recreate` every image roll opened a zero-pod window, so a slow cold start or a flaky ghcr image pull took the MCP fully down (503)"*, and with an RWO volume there is no third option. @@ -253,20 +253,20 @@ See [G-02](#g-02). ## G-22: a provider's ingress is only as complete as the union `platform-postgres` declares zero `dependsOn` edges and receives five ingress -rules. Every one of them is read from **another domain's file**, because -`dependsOn` is written by the consumer and no Service knows its own consumers. +rules. Every one of them is read from **another project's file**, because +`dependsOn` is written by the consumer and no Application knows its own consumers. That derivation is computable only over the composed union (chapter 40), which is -this domain's hard dependency on composition. +this project's hard dependency on composition. The intent names **eight** consumers of the `postgres` surface: auth's api -Workload, `agents-api`, knowledge's api and ingest worker, `lightrag`, `n8n`, +Process, `agents-api`, knowledge's api and ingest worker, `lightrag`, `n8n`, `outline`, and the observability backup jobs. **Three resolve here** (`auth` and -`knowledge` are the only other domains publishing an Intent Fragment in this +`knowledge` are the only other projects publishing an Intent Fragment in this example set), so the rendered policy carries three consumer rules and omits five. As rendered, applying that policy **cuts five live consumers off**. That is not a rendering bug: it is the correct output for the fragment set it was given, and it -is exactly why chapter 40 requires a domain that silently fails to publish to +is exactly why chapter 40 requires a project that silently fails to publish to surface as a **stale participant** rather than as a quietly smaller render. The same shape, one rule instead of three, appears in `platform-valkey`'s policy: if auth's fragment were missing, that policy would render with **no ingress rules at @@ -284,62 +284,62 @@ grow. Ordered by how much they cost. **G-30 is retired.** The hostname is authored: `host: rabbitmq.jorisjonkers.dev` on `platform-rabbitmq`'s `management` exposure, with one named route under it. -This domain is the estate's evidence for authoring rather than deriving (the -host does not follow the Service id), and the IngressRoute and the Gatus URL now +This project is the estate's evidence for authoring rather than deriving (the +host does not follow the Application id), and the IngressRoute and the Gatus URL now both trace to that declaration. The id is not reused and nothing is renumbered. | id | gap | |---|---| -| [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-01](#g-01) | **Three Applications release independently and reconcile as one unit.** Detailed above. Two derivations over one project 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-process-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-process.md) puts the ServiceAccount on the Process, 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 Process is a decision rather than a gap: a failing exporter must not hold its Process out of application | +| 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 Process 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-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-05 | **`replicas` has no input in this project.** The rule is "from `minAvailable`, bounded by the size of the eligible node set". No Process 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 project | | 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 | -| G-08 | **An Asset's change-propagation mechanism is unstated, and its hash has no input here.** `onChange: restart` must make the pod template change when the file does, and there are two mechanisms, a content-hashed ConfigMap name or a checksum annotation. No chapter picks one; rendering both would be two records of one fact. This render uses the hashed name and cannot compute it: `config/postgresql.conf` lives in the Service repository, which the example set does not reproduce, so the suffix is a marker rather than a hash. `onChange` also has exactly two values and postgres supports `pg_ctl reload`, so an Asset whose change needs a reload has no way to say so, under `Recreate` a one-line config edit is a full outage | -| G-09 | **One env file, two containers, no partition rule.** Env files are per **Workload** and this Workload has two containers. `POSTGRES_DB` / `POSTGRES_USER` belong to the database; `DATA_SOURCE_NAME` belongs to the exporter. Nothing partitions them, so the whole file reaches both, and the database container holds the exporter's connection string in its environment, which is a real widening inside the pod and the mirror image of the split-path work that produced the grant | +| G-08 | **An Asset's change-propagation mechanism is unstated, and its hash has no input here.** `onChange: restart` must make the pod template change when the file does, and there are two mechanisms, a content-hashed ConfigMap name or a checksum annotation. No chapter picks one; rendering both would be two records of one fact. This render uses the hashed name and cannot compute it: `config/postgresql.conf` lives in the Project repository, which the example set does not reproduce, so the suffix is a marker rather than a hash. `onChange` also has exactly two values and postgres supports `pg_ctl reload`, so an Asset whose change needs a reload has no way to say so, under `Recreate` a one-line config edit is a full outage | +| G-09 | **One env file, two containers, no partition rule.** Env files are per **Process** and this Process has two containers. `POSTGRES_DB` / `POSTGRES_USER` belong to the database; `DATA_SOURCE_NAME` belongs to the exporter. Nothing partitions them, so the whole file reaches both, and the database container holds the exporter's connection string in its environment, which is a real widening inside the pod and the mirror image of the split-path work that produced the grant | | G-10 | **Env literals: inline `env:` or a ConfigMap?** Chapter 10 says "literal keys become plain env entries, and `${secret:…}` keys become `envFrom` secretRef entries"; the registered `kubernetes` adapter emits a `configmap.yaml`. The `auth` render in this example set takes the first reading and emits no ConfigMap; the `knowledge` render takes the second and puts every literal in one. This render follows chapter 10 and keeps `configmap.yaml` for the Asset alone. Three renders, two answers, and a decision taken during serialisation is exactly what chapter 30 forbids | | G-11 | **`init-databases.sh` is a derived catalog with no producer.** 98 lines creating one database and one owning user per consumer, `auth_db`, `agents_db`, `knowledge_db`, `n8n_db`, which the inbound edge set already knows. Chapter 16 lists it as an inbound derivation; chapter 10 refuses it as an Asset because an Asset may not be executable. So it is a Deliverable, and nothing registered produces it. It also reads `/run/secrets/`, a Docker Compose convention that does not exist in Kubernetes, then falls back to env vars | -| G-12 | **Three Durability Classes, three backup shapes, no producer.** `irreplaceable` on `postgres-data` derives a backup job, a retention sweep **and** an off-cluster copy, plus a restore rehearsed before the first production apply. `recoverable` on `rabbitmq-data` derives a job and a sweep and no copy. `reconstructible` on `valkey-data` derives nothing, correctly. No adapter reads `durability`, and even with one, three values have no declaring site anywhere in layer 1: the schedule, the retention window and the off-cluster destination. Not rendered rather than invented. This is the largest hole in the domain, and the estate's own backup-coverage record says PVC-level snapshots are impossible here (no VolumeSnapshot CRDs, no CSI snapshot support on `local-path`), so an application-level job is the *only* mechanism, and it has no producer | -| G-13 | **No chapter fixes the label set.** Four labels here, and `app.kubernetes.io/instance` carries more weight in this domain than anywhere else, for it is the only thing on an object saying which of three Services in one namespace it belongs to. `name` + `instance` are the selector, and a selector is immutable on a Deployment, so changing the convention later is a delete-and-recreate on every workload in the estate. The `auth` and `knowledge` renders in this set use four labels and three respectively | +| G-12 | **Three Durability Classes, three backup shapes, no producer.** `irreplaceable` on `postgres-data` derives a backup job, a retention sweep **and** an off-cluster copy, plus a restore rehearsed before the first production apply. `recoverable` on `rabbitmq-data` derives a job and a sweep and no copy. `reconstructible` on `valkey-data` derives nothing, correctly. No adapter reads `durability`, and even with one, three values have no declaring site anywhere in layer 1: the schedule, the retention window and the off-cluster destination. Not rendered rather than invented. This is the largest hole in the project, and the estate's own backup-coverage record says PVC-level snapshots are impossible here (no VolumeSnapshot CRDs, no CSI snapshot support on `local-path`), so an application-level job is the *only* mechanism, and it has no producer | +| G-13 | **No chapter fixes the label set.** Four labels here, and `app.kubernetes.io/instance` carries more weight in this project than anywhere else, for it is the only thing on an object saying which of three Applications in one namespace it belongs to. `name` + `instance` are the selector, and a selector is immutable on a Deployment, so changing the convention later is a delete-and-recreate on every process in the estate. The `auth` and `knowledge` renders in this set use four labels and three respectively | | G-14 | **`automountServiceAccountToken` is underivable, and `delivery: env` breaks the obvious rule.** "No grant → no token" would cover rabbitmq and valkey. It gets postgres wrong in the *other* direction: postgres holds a grant and still needs no token, because `delivery: env` means the Vault Secrets Operator performs the read and the pod never authenticates to Vault. The derivation needs to read `delivery`, and no chapter states it at all | -| G-15 | **`runAsNonRoot: true` with no UID, and no fsGroup, against three RWO volumes.** The class renders the control "with the UID from the image" and no pinned input carries a UID: the images lock carries digests. This bites harder here than anywhere else in the example set: all three Workloads mount a `local-path` volume that a non-root uid must be able to write, and a freshly provisioned local-path directory is root-owned. Without `fsGroup`, postgres cannot `initdb` into its own PV. The intent's prose says uid 999 and says the PV is already owned by 999; neither sentence is a field, and if the image's `USER` is a name rather than a number the kubelet cannot verify non-root and the pod fails `CreateContainerConfigError` | -| G-16 | **The hardening class does not say where its controls land.** `runAsNonRoot` and `seccompProfile` are rendered at pod level, `readOnlyRootFilesystem` and `capabilities` at container level (the latter two have no pod-level form). That split is a serialisation choice, and this domain is where it bites: the pod-level half covers `postgres-exporter`, a container the Workload did not declare hardening for, while the container-level exception applies only to the container it is written on. Which is arguably right, and is decided by nothing | +| G-15 | **`runAsNonRoot: true` with no UID, and no fsGroup, against three RWO volumes.** The class renders the control "with the UID from the image" and no pinned input carries a UID: the images lock carries digests. This bites harder here than anywhere else in the example set: all three Processes mount a `local-path` volume that a non-root uid must be able to write, and a freshly provisioned local-path directory is root-owned. Without `fsGroup`, postgres cannot `initdb` into its own PV. The intent's prose says uid 999 and says the PV is already owned by 999; neither sentence is a field, and if the image's `USER` is a name rather than a number the kubelet cannot verify non-root and the pod fails `CreateContainerConfigError` | +| G-16 | **The hardening class does not say where its controls land.** `runAsNonRoot` and `seccompProfile` are rendered at pod level, `readOnlyRootFilesystem` and `capabilities` at container level (the latter two have no pod-level form). That split is a serialisation choice, and this project is where it bites: the pod-level half covers `postgres-exporter`, a container the Process did not declare hardening for, while the container-level exception applies only to the container it is written on. Which is arguably right, and is decided by nothing | | G-17 | **PVC capacity cannot be derived, and all three claims are unappliable without it.** Capacity is platform-assigned and the author is forbidden to write it, yet no pinned input carries an assignment: the node contract publishes `disks[].usable_gib` and no rule allocates a number to a claim. For an existing claim the bound PV's capacity would come from the ClusterState snapshot, and there is no snapshot here. Rendered as `storage: null`, which parses and does not apply | | G-18 | **The durability annotation key is a convention this render chose.** `durability` must reach the object somehow, because the model's second demand on the separately-defined delivery work is that a destructive operation on a non-`reconstructible` claim is refused, and a delivery mechanism can only honour that by reading something on the object. No chapter names a key, a label-vs-annotation, or a value vocabulary | | G-19 | **No scrape `interval` or `scrapeTimeout`.** Omitted on both ServiceMonitors, which silently takes the metrics stack's global default, a value decided outside the model. **Closed**: one `monitors: {interval, timeout}` in the Platform document, named by every emitted monitor | -| G-20 | **Per-domain objects have no owning Service directory, and this domain forces the decision.** `namespace.yaml` and the namespace-wide `default-deny` NetworkPolicy are one object per **domain**; the `kubernetes` adapter emits per **Service directory**. `auth` has one Service so nothing collides. Here, three identical Namespace objects at three paths is `E_PATH_COLLISION` waiting for a second writer. Both are hoisted to the domain level in this render, which is a resolution, not a rule. Whichever Service directory were picked instead, that Service would own an object governing its two neighbours, and deleting it from the domain file would delete the namespace's default-deny posture as a side effect | +| G-20 | **Per-project objects have no owning Application directory, and this project forces the decision.** `namespace.yaml` and the namespace-wide `default-deny` NetworkPolicy are one object per **project**; the `kubernetes` adapter emits per **Application directory**. `auth` has one Application so nothing collides. Here, three identical Namespace objects at three paths is `E_PATH_COLLISION` waiting for a second writer. Both are hoisted to the project level in this render, which is a resolution, not a rule. Whichever Application directory were picked instead, that Application would own an object governing its two neighbours, and deleting it from the project file would delete the namespace's default-deny posture as a side effect | | G-21 | **Every platform-component fact is a Platform document input this example set does not carry.** Marked `CONTEXT` in the files: cluster DNS, the edge, the metrics stack and the Secret Store selectors in the NetworkPolicies; `release: metrics-stack` on both ServiceMonitors; `entryPoints` and `certResolver` on the IngressRoute; the Vault address and port; the Kubernetes auth mount name in `vso.yaml`. The shapes are derived; the values must come from the pinned context | | [G-22](#g-22) | **A provider's ingress is only as complete as the composed union.** Detailed above. Five of eight consumers are absent, and the rendered policy cuts them off, correctly, for the fragment set it was given | -| G-23 | **The Secret Store egress rule needs a branch on `delivery`.** Chapter 16 derives "egress to the Secret Store" from *any grant in the Workload's effective set*. The one grant in this domain is `delivery: env`: the VSO operator performs the read and writes a Secret, and the pod never opens a connection to Vault. So the rule as written permits a flow that does not happen, while the flow that does, the operator's pod reading this path, is governed by a policy in `vso-system` that no Service declares and nothing renders. Rendered anyway, because executing the model as written is the point | -| G-24 | **VSO object naming, and who applies the objects.** The Secret and VaultStaticSecret names are the granted path with the mount and the `data/` segment removed and `/` replaced by `-`, mechanical and collision-free, because one path has one reader set, and stated in no chapter. Separately: the `vso` adapter writes into its own estate-wide `apps/vso-secrets/` directory with its own kustomization, so the Service's `kustomization.yaml` must *not* list `vso.yaml`, and which kustomization applies a Service's VSO objects is undecided | -| G-25 | **One VaultAuth per estate, or one per Workload?** This render emits one per holding Workload, which is what makes the per-Workload 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-23 | **The Secret Store egress rule needs a branch on `delivery`.** Chapter 16 derives "egress to the Secret Store" from *any grant in the Process's effective set*. The one grant in this project is `delivery: env`: the VSO operator performs the read and writes a Secret, and the pod never opens a connection to Vault. So the rule as written permits a flow that does not happen, while the flow that does, the operator's pod reading this path, is governed by a policy in `vso-system` that no Application declares and nothing renders. Rendered anyway, because executing the model as written is the point | +| G-24 | **VSO object naming, and who applies the objects.** The Secret and VaultStaticSecret names are the granted path with the mount and the `data/` segment removed and `/` replaced by `-`, mechanical and collision-free, because one path has one reader set, and stated in no chapter. Separately: the `vso` adapter writes into its own estate-wide `apps/vso-secrets/` directory with its own kustomization, so the Application's `kustomization.yaml` must *not* list `vso.yaml`, and which kustomization applies an Application's VSO objects is undecided | +| 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 Workloads have no env file in the example set.** The model requires one per Workload; 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-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 domain on day one cuts five live consumers off the datastore ([G-22](#g-22)) | +| 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-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 domain 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 | -| G-33 | **`alertClass` renders no rule in this tree, and that is now correct.** Two Services here declare a class, `page` on the datastore eight others queue behind, `urgent` on the broker, and `platform-valkey` declares no `observability` block at all, which is how a Service says it wants none. What the class renders is nothing: it is published as a resolved fact and the monitoring stack that owns PromQL, severity and receiver routing reads it from the projection ([chapter 10](../../../10-service-intent.md#observability)). The `scrape` surface beside it is what renders the two `ServiceMonitor` objects above. That boundary answers a specific hole rather than a preference: in the generation being replaced the class was supposed to derive objects inside the model and derived none, no registered adapter rendered a `PrometheusRule` (zero occurrences under `src/`, either generation), the Gatus `alerts` block carried no mapping from an Alert Class to a receiver, threshold or send-on-resolved, and the notifier route from `alertClass` + `owner` had no producer at all. What the model still guarantees is the part that failed: a declared class must have a signal. `platform-postgres` clears that on its exporter sidecar’s `scrape` surface, because Gatus derives from `exposure` and a datastore is correctly not exposed; `platform-valkey`, which has neither, would be `E_ALERT_CLASS_WITHOUT_SIGNAL` and so declares no block | +| 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 | +| G-33 | **`alertClass` renders no rule in this tree, and that is now correct.** Two Applications here declare a class, `page` on the datastore eight others queue behind, `urgent` on the broker, and `platform-valkey` declares no `observability` block at all, which is how an Application says it wants none. What the class renders is nothing: it is published as a resolved fact and the monitoring stack that owns PromQL, severity and receiver routing reads it from the projection ([chapter 10](../../../10-project-intent.md#observability)). The `scrape` surface beside it is what renders the two `ServiceMonitor` objects above. That boundary answers a specific hole rather than a preference: in the generation being replaced the class was supposed to derive objects inside the model and derived none, no registered adapter rendered a `PrometheusRule` (zero occurrences under `src/`, either generation), the Gatus `alerts` block carried no mapping from an Alert Class to a receiver, threshold or send-on-resolved, and the notifier route from `alertClass` + `owner` had no producer at all. What the model still guarantees is the part that failed: a declared class must have a signal. `platform-postgres` clears that on its exporter sidecar’s `scrape` surface, because Gatus derives from `exposure` and a datastore is correctly not exposed; `platform-valkey`, which has neither, would be `E_ALERT_CLASS_WITHOUT_SIGNAL` and so declares no block | | G-34 | **The Gatus derivation does not compose for this exposure.** The rule is `exposure` + `probes.readiness`. `platform-rabbitmq` routes its `management` surface, 15672, an HTTP UI, and declares `probes.readiness: {tcp: 5672}` (an AMQP accept). Different port, different protocol: the route supplies the path and nothing supplies a status. And the exposure is `audience: authenticated`, so an unauthenticated prober is answered by forward-auth with a redirect, and a guessed `[STATUS] == 200` would be wrong by construction. The entry is rendered with **no `conditions`**, which is not valid Gatus configuration and will not load. That is the finding, not a formatting choice | -| G-35 | **No `rbac` adapter, and no `networking` adapter.** Chapter 30's two largest true gaps, and both land in this namespace. Every NetworkPolicy in this tree is what a future `networking` adapter must emit; the only implementation is in the generation being deleted, so coverage for the kind goes from unregistered to absent. RBAC matters more here than elsewhere: three Services' Secrets sit in one namespace, and the only thing keeping `valkey`'s ServiceAccount from reading `platform-postgres-exporter` is that **no Role grants `get secrets` in `data-system`**, an absence, not a boundary, and nothing renders a Role in either direction | +| G-35 | **No `rbac` adapter, and no `networking` adapter.** Chapter 30's two largest true gaps, and both land in this namespace. Every NetworkPolicy in this tree is what a future `networking` adapter must emit; the only implementation is in the generation being deleted, so coverage for the kind goes from unregistered to absent. RBAC matters more here than elsewhere: three Applications' Secrets sit in one namespace, and the only thing keeping `valkey`'s ServiceAccount from reading `platform-postgres-exporter` is that **no Role grants `get secrets` in `data-system`**, an absence, not a boundary, and nothing renders a Role in either direction | ## What this example is meant to prove - **A namespace is not a trust boundary, and the tree can be read to check it.** - Three Services, three identities, three policies, one namespace. Nothing is + Three Applications, three identities, three policies, one namespace. Nothing is isolated by the wall; everything is isolated by a declared edge set evaluated per pod, plus a ServiceAccount name. - **Independence is structural until the reconcile unit takes it back** ([G-01](#g-01)). - **`disk` becoming node selection, and then stopping mattering** ([G-07](#g-07)), the one dimension whose effect expires. -- **Two surfaces from one Workload**: `postgres` 5432 and `metrics` 9187 on one +- **Two surfaces from one Process**: `postgres` 5432 and `metrics` 9187 on one Service object, the second served by a sidecar, feeding a ServiceMonitor that names a port rather than an integer. - **`Recreate` derived from a volume rather than authored** ([G-04](#g-04)), and - what that costs on the Service eight others queue behind. + what that costs on the Application eight others queue behind. - **All three Durability Classes in one file**, deriving three different backup shapes, of which the only one that renders correctly is the one that renders nothing. @@ -349,11 +349,11 @@ both trace to that declaration. The id is not reused and nothing is renumbered. author for all three, checked by nothing until a pod crash-loops ([G-15](#g-15), [G-27](#g-27)). - **Inbound derivation is a composition property** ([G-22](#g-22)): the provider - declares nothing and receives five rules from two other domains, and would - receive five more from domains not in this fragment set. -- **A hostname that does not follow its Service id**: `platform-rabbitmq` + declares nothing and receives five rules from two other projects, and would + receive five more from projects not in this fragment set. +- **A hostname that does not follow its Application id**: `platform-rabbitmq` serving `rabbitmq.jorisjonkers.dev`, which is why `host` is authored and not derived, and why the failure a derivation would produce is one nobody checks. -- **Providing a port is not exposing it**: three surfaces on one Workload, one +- **Providing a port is not exposing it**: three surfaces on one Process, one named by a route and reachable from the edge, two reachable only by consumers that named a surface. diff --git a/spec/v1/examples/knowledge/env/knowledge-api.base.env b/spec/v1/examples/knowledge/env/knowledge-api.base.env index a5360d2..c3bbbfa 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 @@ -# services/knowledge/platform/env/knowledge-api/base.env +# applications/knowledge/platform/env/knowledge-api/base.env # -# One file per Workload (docs/adr/model/0011-configuration-env-files-per-workload.md), -# beside the domain file that grants these paths: platform/knowledge.yml -# (docs/adr/model/0063-intent-authored-per-domain.md), reproduced as -# examples/domains/knowledge.yml. +# 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 +# (docs/adr/model/0063-intent-authored-per-project.md), reproduced as +# examples/projects/knowledge.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. @@ -29,8 +29,8 @@ RABBITMQ_PORT=${dependency:platform-rabbitmq.port} # The `#` half selects which value fills the variable and confers # nothing: the grant unit is the path. # -# postgres and rabbitmq are Service-level grants, mcp-bearer is this -# Workload's own. These render as envFrom secretRef entries, never as +# postgres and rabbitmq are Application-level grants, mcp-bearer is this +# Process's own. These render as envFrom secretRef entries, never as # literal values. DB_USER=${secret:secret/data/platform/postgres/kb#user} DB_PASSWORD=${secret:secret/data/platform/postgres/kb#password} @@ -45,7 +45,7 @@ KNOWLEDGE_MCP_TOKENS_WORKSTATION=${secret:secret/data/knowledge-system/mcp-beare # platform/postgres split a mechanical change: one grep finds every reader. # NOT here, and a build error if they were: -# SERVER_PORT -> from `provides` on this Workload +# SERVER_PORT -> from `provides` on this Process # DEPLOYMENT_ENVIRONMENT -> from the Cluster Target # OTEL_* (10 keys) -> from runtime: jvm # PYROSCOPE_* (6 keys) -> 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 index 5e820ed..a2e899f 100644 --- a/spec/v1/examples/knowledge/env/knowledge-ingest-worker.base.env +++ b/spec/v1/examples/knowledge/env/knowledge-ingest-worker.base.env @@ -1,12 +1,12 @@ -# services/knowledge/platform/env/knowledge-ingest-worker/base.env +# applications/knowledge/platform/env/knowledge-ingest-worker/base.env # -# The second Workload of the same Service, and therefore the second file: -# Workloads of one Service do not share an environment -# (docs/adr/model/0011-configuration-env-files-per-workload.md). Both are declared in -# the same domain file, platform/knowledge.yml. Note what it shares +# 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 # and what it does not -- the Postgres and RabbitMQ credentials come from -# Service-level grants, so they are referenced here as well as in -# knowledge-api's file. The deploy key is a Workload-level grant with +# 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 # delivery: file, so it has no placeholder here at all -- it lands on disk at # 0400. @@ -15,14 +15,14 @@ INGEST_QUEUE=knowledge.ingest INGEST_PREFETCH=4 LOG_LEVEL=INFO -# --- coordinates, from this Workload's own dependsOn +# --- coordinates, from this Process's own dependsOn RABBITMQ_HOST=${dependency:platform-rabbitmq.host} RABBITMQ_PORT=${dependency:platform-rabbitmq.port} DB_HOST=${dependency:platform-postgres.host} DB_PORT=${dependency:platform-postgres.port} DB_NAME=${dependency:platform-postgres.database} -# --- secrets, both from Service-level grants, each byte-matching the granted +# --- secrets, both from Application-level grants, each byte-matching the granted # path (docs/adr/model/0027-secret-reference-join-key.md) RABBITMQ_USER=${secret:secret/data/platform/rabbitmq#rabbitmq.user} RABBITMQ_PASSWORD=${secret:secret/data/platform/rabbitmq#rabbitmq.password} diff --git a/spec/v1/examples/knowledge/knowledge.domain.yml b/spec/v1/examples/knowledge/knowledge.project.yml similarity index 72% rename from spec/v1/examples/knowledge/knowledge.domain.yml rename to spec/v1/examples/knowledge/knowledge.project.yml index 3fb5de6..c77e161 100644 --- a/spec/v1/examples/knowledge/knowledge.domain.yml +++ b/spec/v1/examples/knowledge/knowledge.project.yml @@ -1,20 +1,20 @@ -# Worked example: the knowledge domain's Intent Fragment +# Worked example: the knowledge project's Intent Fragment # -# services/knowledge/platform/knowledge.yml +# applications/knowledge/platform/knowledge.yml # -# One file per domain, one file one Intent Fragment -# (docs/adr/model/0063-intent-authored-per-domain.md). The knowledge domain owns one -# Service today; the namespace derives from the header as knowledge-system, +# One file per project, one file one Intent Fragment +# (docs/adr/model/0063-intent-authored-per-project.md). The knowledge project owns one +# Application today; the namespace derives from the header as knowledge-system, # which is the namespace it already runs in. # -# Exercises: two Workloads on two runtimes and therefore two identities, one +# Exercises: two Processes on two runtimes and therefore two identities, one # authored hostname whose five routes carry mixed audiences -- four anonymous -# overrides inside an authenticated host -- `probes: none` for a Workload with +# overrides inside an authenticated host -- `probes: none` for a Process with # no listener, grants at BOTH levels, a split Subtree path beside an unsplit # one, and an irreplaceable volume. # -# Companion env files, one per WORKLOAD -# (docs/adr/model/0011-configuration-env-files-per-workload.md): +# 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 # platform/env/knowledge-ingest-worker/base.env @@ -24,41 +24,41 @@ # (docs/adr/model/0039-artifact-schema-versioning.md). schemaVersion: 1.0.0 -domain: knowledge -owner: joris # the only field raised to the domain header +project: knowledge +owner: joris # the only field raised to the project header -services: +applications: # =============================================================== knowledge - id: knowledge - # Per Service; never raised to the domain. The api Workload publishes for - # the Service, and the ingest worker publishes nothing of its own. + # Per Application; never raised to the project. The api Process publishes for + # the Application, and the ingest worker publishes nothing of its own. observability: alertClass: business-hours scrape: - workload: knowledge-api + process: knowledge-api surface: http path: /api/actuator/prometheus - # The two Workloads below switch together, because they are one Service and - # a Service is the unit of atomic release - # (docs/adr/model/0062-service-is-the-release-unit.md). Nothing declares that: the + # The two Processes below switch together, because they are one Application and + # an Application is the unit of atomic release + # (docs/adr/model/0062-application-is-the-release-unit.md). Nothing declares that: the # boundary carries it. If the ingest worker misses its startup budget, the # API stays on its old version too, and a rollback takes both back. # - # A Service is still not a Reconcile Unit: `platform-postgres` before + # An Application is still not a Reconcile Unit: `platform-postgres` before # `knowledge` is ordering, derived from the edge set # (docs/adr/model/0032-reconcile-unit-derived.md), and the later unit waits rather # than being held. - # Shared grants. Both Workloads read the same Postgres and RabbitMQ + # Shared grants. Both Processes read the same Postgres and RabbitMQ # credentials, so declaring them once here is the difference between one - # edit and two when a key is added. A Service-level grant is held by every - # Workload, including ones added later; a grant that must NOT be shared + # edit and two when a key is added. An Application-level grant is held by every + # Process, including ones added later; a grant that must NOT be shared # moves down a level, and there is no removal syntax - # (docs/adr/model/0022-grants-live-on-the-service.md). + # (docs/adr/model/0022-grants-live-on-the-application.md). # - # `secrets` stays on the Service and is never raised to the domain header: a - # domain-level grant would hand every Service in the file a reader slot on + # `secrets` stays on the Application and is never raised to the project header: a + # project-level grant would hand every Application in the file a reader slot on # the whole path (docs/adr/model/0009-vault-read-is-per-path.md). # # `delivery: env` and `delivery: file` render a Kubernetes Secret, so both @@ -72,37 +72,37 @@ services: # whole document, so the only boundary the store can enforce is the path: # while every consumer's credentials shared secret/data/platform/postgres, # these pods could read auth-api's database password. The reader set here - # is knowledge's two Workloads; auth-api reads .../postgres/auth. + # is knowledge's two Processes; auth-api reads .../postgres/auth. - path: secret/data/platform/postgres/kb keys: [user, password] access: read delivery: env rotation: {tolerates: restart} - # NOT split. Its reader set is already exactly this Service's two - # Workloads, and the rule is one path per reader SET, not one path per - # consumer. It splits on the day a second Service is granted it. + # NOT split. Its reader set is already exactly this Application's two + # Processes, and the rule is one path per reader SET, not one path per + # consumer. It splits on the day a second Application is granted it. - path: secret/data/platform/rabbitmq keys: [rabbitmq.user, rabbitmq.password] access: read delivery: env rotation: {tolerates: restart} - # ONE hostname, ONE Workload behind it -- and it still belongs to the - # Service, because `exposure` says *this hostname routes here* while + # ONE hostname, ONE Process behind it -- and it still belongs to the + # Application, because `exposure` says *this hostname routes here* while # `provides` says *this process listens on this port* # (review/EXPOSURE-MANIFEST.md). knowledge-ingest-worker declares no # `provides`, so no route can name it and it appears in none below. # # `host` is the full FQDN, authored. Nothing derives it: the estate also # answers on kb.jorisjonkers.dev, `platform-rabbitmq` serves rabbitmq, and - # `status` belongs to no Service -- a `.` rule would be right + # `status` belongs to no Application -- a `.` rule would be right # here and silently wrong there, and the wrong ones are the ones nobody # checks. This block replaces the six places the hostname used to be # declared independently: both catalogs, both IngressRoutes, the # reachability channel and the Gatus endpoint. exposure: - - name: public # unique within the Service, and what + - name: public # unique within the Application, and what # ${exposure:knowledge.public#url} addresses host: knowledge.jorisjonkers.dev audience: authenticated # the exposure's audience is the default for @@ -112,7 +112,7 @@ services: # for: anonymous paths inside an authenticated host. /mcp answers a # workstation holding only a bearer token, and the two install # scripts are fetched by curl before any session exists. The `/` - # catch-all keeps the Service's audience, so everything else is + # catch-all keeps the Application's audience, so everything else is # behind forward-auth -- derived from the audience, never named here # (docs/adr/model/0018-exposure-by-audience.md). # @@ -121,40 +121,40 @@ services: # routes use. - path: /mcp match: exact - workload: knowledge-api + process: knowledge-api surface: http audience: anonymous # override - path: /mcp/ match: prefix - workload: knowledge-api + process: knowledge-api surface: http audience: anonymous # override - path: /install.sh match: exact - workload: knowledge-api + process: knowledge-api surface: http audience: anonymous # override - path: /install-agents.sh match: exact - workload: knowledge-api + process: knowledge-api surface: http audience: anonymous # override - path: / match: prefix - workload: knowledge-api - surface: http # no `audience`: the Service's stands - # No `contentPolicy`: this Service picks no CSP profile, so it takes the + process: knowledge-api + surface: http # no `audience`: the Application's stands + # No `contentPolicy`: this Application picks no CSP profile, so it takes the # baseline the tier derives. The field is optional and the vocabulary # stops there -- `contentPolicy` and `redirectTo` are the whole of what # an author may say about the proxy. - workloads: + processes: # ------------------------------------------------------ knowledge-api - name: knowledge-api - # Identity is the Workload name under the domain namespace: + # Identity is the Process name under the project namespace: # `knowledge-system.knowledge-api` - # (docs/adr/model/0024-identity-per-workload.md). - lifecycle: service + # (docs/adr/model/0024-identity-per-process.md). + lifecycle: application image: knowledge-api # alias -> digest through the images lock runtime: jvm # -> otel-jvm profile: 10 OTEL, 6 PYROSCOPE @@ -163,17 +163,17 @@ services: # (docs/adr/model/0092-writable-paths-are-declared.md). writablePaths: [/tmp] - # Surfaces are a property of the process, so they sit on the Workload. + # Surfaces are a property of the process, so they sit on the Process. # The rendered Kubernetes port name is the surface name, which is how - # 'http' survives. A consumer names the Service and the surface, never - # a port integer: `dependsOn: {service: knowledge, surface: http}`. + # 'http' survives. A consumer names the Application and the surface, never + # a port integer: `dependsOn: {application: knowledge, surface: http}`. provides: http: 8080 # Hard dimensions, all of which must match # (docs/adr/model/0061-placement-is-hard-dimensions.md). placement: - memory: 768Mi # a JVM service at its default heap. Raw, not a + memory: 768Mi # a JVM application at its default heap. Raw, not a # class: a class name cannot be compared to a # node's allocatable without carrying the table, # and the comparison is now arithmetic @@ -192,11 +192,11 @@ services: # (docs/adr/model/0016-pod-hardening.md). dependsOn: - - {service: platform-postgres, surface: postgres} - - {service: platform-rabbitmq, surface: amqp} + - {application: platform-postgres, surface: postgres} + - {application: platform-rabbitmq, surface: amqp} # No `exposure` here: the five routes and the hostname they hang off - # sit on the Service above, and each route names this Workload and its + # sit on the Application above, and each route names this Process and its # `http` surface. `provides` is what stays. probes: @@ -212,19 +212,19 @@ services: startupBudget: 600s # JVM cold start measured at ~250-300s - # REQUIRED, no default. This Workload holds no volume, and its owner + # REQUIRED, no default. This Process holds no volume, and its owner # requires continuity: the MCP endpoint 503s if a roll opens a gap. cutover: rolling # No `replicas` block: the derived count is one. Capacity is the only - # local exception to a derived value, and this Workload needs none. + # local exception to a derived value, and this Process needs none. - # Workload-level grant: the API serves /mcp, the worker does not. This - # is an access boundary only because identity is per Workload -- this + # Process-level grant: the API serves /mcp, the worker does not. This + # is an access boundary only because identity is per Process -- this # pod authenticates as `knowledge-api` and the worker as # `knowledge-ingest-worker`, each bound to exactly its own effective - # grant set, which is the Service-level list plus this one - # (docs/adr/model/0024-identity-per-workload.md). + # grant set, which is the Application-level list plus this one + # (docs/adr/model/0024-identity-per-process.md). secrets: - path: secret/data/knowledge-system/mcp-bearer keys: [workstation] @@ -234,7 +234,7 @@ services: # --------------------------------------------- knowledge-ingest-worker - name: knowledge-ingest-worker - lifecycle: service + lifecycle: application image: knowledge-ingest-worker runtime: python # a different profile: 5 OTEL keys plus # OTEL_PYTHON_LOG_CORRELATION, no JVM batch @@ -249,7 +249,7 @@ services: memory: 256Mi # a single-consumer queue worker, not a server cpu: 50m # Nothing else is declared, and the volume below is why nothing more - # is needed: a `local-path` PV pins this Workload to the node holding + # is needed: a `local-path` PV pins this Process to the node holding # it. That pinning is DERIVED -- read from the pinned ClusterState # snapshot (docs/adr/model/0034-cluster-state-pinned-input.md) -- rather # than restated here as a dimension that could disagree with the @@ -257,11 +257,11 @@ services: # Default `restricted` again, and nothing here fights it: the deploy key # arrives on a projected volume, not on the root filesystem, so - # readOnlyRootFilesystem costs this Workload nothing. + # readOnlyRootFilesystem costs this Process nothing. dependsOn: - - {service: platform-rabbitmq, surface: amqp} - - {service: platform-postgres, surface: postgres} + - {application: platform-rabbitmq, surface: amqp} + - {application: platform-postgres, surface: postgres} # A RabbitMQ consumer with no listener. Declared, not omitted, so the # absence is a decision rather than an oversight @@ -280,7 +280,7 @@ services: # A file-level copy is the backup method for a directory of files, and # `irreplaceable` derives one -- so the engine is required here # (docs/adr/model/0077-durability-derives-a-backup.md, - # docs/adr/model/0078-engine-is-workload-vocabulary.md). + # docs/adr/model/0078-engine-is-process-vocabulary.md). engine: files stateful: true @@ -289,7 +289,7 @@ services: mountAt: /var/lib/knowledge-vault size: 20Gi durability: irreplaceable - # The one fact only the owning Service knows + # The one fact only the owning Application knows # (docs/adr/model/0015-durability-class-per-volume.md); schedule, sweep # and destination are derived. `irreplaceable` renders a backup job # with retention plus an off-cluster copy, and its restore must be @@ -301,10 +301,10 @@ services: # which nodes are ELIGIBLE to hold a claim; it does not decide how # large the claim is. - # Workload-level, and `delivery: file` because an SSH private key cannot - # be an environment variable. This is the grant that makes per-Workload - # identity load-bearing: under one identity per Service the - # internet-facing API Workload would authenticate as a principal holding + # Process-level, and `delivery: file` because an SSH private key cannot + # be an environment variable. This is the grant that makes per-Process + # identity load-bearing: under one identity per Application the + # internet-facing API Process would authenticate as a principal holding # `read` on this key, and the store cannot narrow a read below the path # (docs/adr/model/0009-vault-read-is-per-path.md). secrets: diff --git a/spec/v1/examples/knowledge/rendered/README.md b/spec/v1/examples/knowledge/rendered/README.md index 652999c..2526795 100644 --- a/spec/v1/examples/knowledge/rendered/README.md +++ b/spec/v1/examples/knowledge/rendered/README.md @@ -1,8 +1,8 @@ -# Rendered output: the `knowledge` domain +# Rendered output: the `knowledge` project Hand-executed Deliverable Set for -[`knowledge.domain.yml`](../knowledge.domain.yml) and its two env files. One -domain, one Service, two Workloads, namespace `knowledge-system`. +[`knowledge.project.yml`](../knowledge.project.yml) and its two env files. One +project, one Application, two Processes, namespace `knowledge-system`. This is the **goal state**, not what the tree emits today. Every object carries `securityContext` from the hardening class and `resources` from `placement`; @@ -26,9 +26,9 @@ that had no producer and made explicit three things a renderer had been choosing | change | decided in | |---|---| -| the fixed label set, `instance` now the Workload and `component` the runtime | [0072](../../../../../docs/adr/model/0072-the-label-set-is-fixed.md) | +| the fixed label set, `instance` now the Process and `component` the runtime | [0072](../../../../../docs/adr/model/0072-the-label-set-is-fixed.md) | | `automountServiceAccountToken`, `false` wherever the pod does not authenticate | [0087](../../../../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md) | -| `runAsUser`, `runAsGroup`, and `fsGroup` on the Workload holding the clone | [0082](../../../../../docs/adr/model/0082-images-lock-carries-uid-and-gid.md) | +| `runAsUser`, `runAsGroup`, and `fsGroup` on the Process holding the clone | [0082](../../../../../docs/adr/model/0082-images-lock-carries-uid-and-gid.md) | | a startup probe pointed at the **liveness** endpoint, and one probe cadence | [0088](../../../../../docs/adr/model/0088-startup-probe-targets-liveness.md) | | an `emptyDir` for the JVM's `/tmp`, at the platform's ephemeral size | [0092](../../../../../docs/adr/model/0092-writable-paths-are-declared.md) | | explicit route `priority` on all five routes, rather than a rule-length sort | [0093](../../../../../docs/adr/model/0093-route-precedence-is-derived.md) | @@ -41,40 +41,40 @@ were decided on 2026-09-07 and their status lives in ## Estate-scoped objects are not in this tree Two files this tree used to carry, `edge/middlewares.yaml` and -`observability/gatus-endpoints.yaml`, render in the **platform domains** now: +`observability/gatus-endpoints.yaml`, render in the **platform projects** now: the Middleware set is emitted per tier by the `traefik` adapter into the edge -domain, and the Gatus endpoint list is an inbound derivation rendered as the -declared `gatus` Service's own Asset in the observability domain +project, and the Gatus endpoint list is an inbound derivation rendered as the +declared `gatus` Application's own Asset in the observability project ([0096](../../../../../docs/adr/model/0096-the-foundation-is-declared.md), -[0098](../../../../../docs/adr/model/0098-one-publication-path.md)). This domain +[0098](../../../../../docs/adr/model/0098-one-publication-path.md)). This project contributes routes and exposures to both; it owns neither object. ## Emitted | file | adapter | derives from | cannot derive today | |---|---|---|---| -| `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 | - | +| `namespace.yaml` | `kubernetes` | `project` | none (the adapter emits this per *Application* directory, not per project: **G-02**) | +| `kustomization.yaml` | `kubernetes` | the Application set of the project | - | | `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/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/serviceaccount.yaml` | `kubernetes` | process `name` × 2, `project` | none (the adapter names one account after the *Application*: **G-09**) | +| `apps/knowledge/configmap.yaml` | `kubernetes` | env files, `dependsOn`, the `provides` port, Cluster Target, process `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/servicemonitor.yaml` | `prometheus` | `observability.scrape {workload, surface, path}`, `provides` | cadence from the Platform document | +| `apps/knowledge/servicemonitor.yaml` | `prometheus` | `observability.scrape {process, 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 | +| `apps/knowledge/vso.yaml` | `vso` | `secrets` at both levels, `delivery`, `rotation`, process `name` | Secret/object naming (**G-21**); which identity reads a shared path (**G-23**); the Kubernetes auth mount name | | `apps/knowledge/kustomization.yaml` | `kubernetes` | the emitted file set | ownership of `vso.yaml` (**G-25**) | -| `edge/ingressroutes.yaml` | `traefik`, for the tier each route's audience selects | the Service's `exposure`: authored `host`, the exposure `audience` and five routes, four overriding it to `anonymous`, each naming `knowledge-api` and its `http` surface | - | +| `edge/ingressroutes.yaml` | `traefik`, for the tier each route's audience selects | the Application's `exposure`: authored `host`, the exposure `audience` and five routes, four overriding it to `anonymous`, each naming `knowledge-api` and its `http` surface | - | | `apps/knowledge/backup.yaml` | `kubernetes` | `durability: irreplaceable` plus `engine: files` on the vault clone | none (0077) | | `apps/vso-secrets/policies/knowledge-api.policy.json` | `vault-policy` | the three KV grants, each with its `metadata` sibling | none (0073, 0086) | -| `apps/vso-secrets/policies/knowledge-api.role.json` | `vault-policy` | the Workload's ServiceAccount and namespace | - | +| `apps/vso-secrets/policies/knowledge-api.role.json` | `vault-policy` | the Process's ServiceAccount and namespace | - | ## Deliberately absent, and correct | not emitted | why it is right | |---|---| | a Kubernetes `Service` for `knowledge-ingest-worker` | it declares no `provides`. A RabbitMQ consumer opens no listener, so there is no surface, nothing may `dependsOn` it, and there is no address to route to. | -| a `ServiceMonitor` or `PodMonitor` for `knowledge-ingest-worker` | no `scrape` is declared, and a ServiceMonitor selects a Service it does not have. | -| any probe on `knowledge-ingest-worker` | `probes: none` is **declared**, so the absence is a decision rather than a forgotten block, and a readiness gate on a Workload that can never report ready would stop the Service switching for ever. | +| a `ServiceMonitor` or `PodMonitor` for `knowledge-ingest-worker` | no `scrape` is declared, and a ServiceMonitor selects an Application it does not have. | +| any probe on `knowledge-ingest-worker` | `probes: none` is **declared**, so the absence is a decision rather than a forgotten block, and a readiness gate on a Process that can never report ready would stop the Application switching for ever. | | `podmonitor.yaml`, `hpa.yaml` | nothing declares a pod-level scrape; autoscaling is not in this model. | | `traefik` routes | no path rule carries the `lan` audience. | @@ -85,25 +85,25 @@ contributes routes and exposures to both; it owns neither object. | backup `CronJob` + retention sweep + off-cluster copy | `durability: irreplaceable` on `knowledge-vault-clone` | **none.** No adapter reads `durability`, and even with one, three values have no declaring site: the schedule, the retention window and the off-cluster destination. Not rendered rather than invented (**G-31**). | | `PodDisruptionBudget` | the `kubernetes` adapter builds one from an availability field | **not derivable.** `minAvailable` is ungraded and no `replicas` assignment exists; a PDB over a single-replica Deployment blocks every node drain (**G-32**). | | `PrometheusRule` | nothing in this model | **correctly none.** PromQL, severity and receivers belong to the monitoring stack, which reads `alertClass` from the projection. | -| `Role` / `RoleBinding` per Workload | per-Workload identity | **none.** No `rbac` adapter. It is also what keeps the two Secret boundaries apart in a shared namespace (**G-24**). | +| `Role` / `RoleBinding` per Process | per-Process identity | **none.** No `rbac` adapter. It is also what keeps the two Secret boundaries apart in a shared namespace (**G-24**). | | `resolved.yml` (the `ResolvedService` projection) | publish-back | central composition; not part of a Deliverable Set. | -Estate-scoped objects this domain contributes rows to but does not render: the -edge catalogs, now Assets of the declared Traefik Services in the platform edge -domain; the Gatus endpoint list, an Asset of the declared `gatus` Service; and -`vso`'s `VaultConnection` in `vso-system`. The per-Workload image digests that -were once an `image-metadata` document are in this Service's `resolved.yml` +Estate-scoped objects this project contributes rows to but does not render: the +edge catalogs, now Assets of the declared Traefik Applications in the platform edge +project; the Gatus endpoint list, an Asset of the declared `gatus` Application; and +`vso`'s `VaultConnection` in `vso-system`. The per-Process image digests that +were once an `image-metadata` document are in this Application's `resolved.yml` projection, and the Flux `Kustomization` for `apps-knowledge` is delivery's ([0098](../../../../../docs/adr/model/0098-one-publication-path.md)). ## Gaps -**G-01** The `kubernetes` adapter keys every object off the *Service* name and -emits one controller per Service directory. Two Workloads in one Service -overwrite each other. Nothing about the adapter is per-Workload. +**G-01** The `kubernetes` adapter keys every object off the *Application* name and +emits one controller per Application directory. Two Processes in one Application +overwrite each other. Nothing about the adapter is per-Process. -**G-02** The same adapter emits `namespace.yaml` inside each Service directory. -A domain with three Services emits three identical `Namespace` objects at three +**G-02** The same adapter emits `namespace.yaml` inside each Application directory. +A project with three Applications emits three identical `Namespace` objects at three paths. **G-03** `envFrom: secretRef` injects the Secret's **own key names**. The env @@ -129,7 +129,7 @@ unstated; here it only selects the 10m health timeout class. `from: clusterState` and the PV's node affinity cannot be rendered. Every statement about reproducibility depends on a collector that does not exist. -**G-07** `startupBudget: 120s` on a Workload with `probes: none` feeds only +**G-07** `startupBudget: 120s` on a Process with `probes: none` feeds only `progressDeadlineSeconds`. Its other stated consumer, the startup probe's period and threshold, has nothing to configure, so half the derivation is dead for this shape. @@ -138,9 +138,9 @@ for this shape. The container runs as a non-root UID from the image under `hardening: restricted`. As declared, the process cannot read its own deploy key. Repairing it needs `fsGroup` or a known UID, neither of which layer 1 can express, and the two -declarations that collide are in the same Workload block. +declarations that collide are in the same Process block. -**G-09** `serviceAccountName()` returns the Service name today, so both pods +**G-09** `applicationAccountName()` returns the Application name today, so both pods authenticate as one principal and receive the union of both policies. That gives the internet-facing API `read` on the ingest worker's SSH deploy key. The declaration and the identity must ship together. @@ -151,18 +151,18 @@ successfully and never reaches the running pod, the failure 16 of the estate's 18 ConfigMaps already have. **G-11** A `${dependency:…}` coordinate resolves to the provider's Kubernetes -`Service`, which is named for the provider's **Workload** (`postgres`), not for -the Service id the consumer wrote (`platform-postgres`). Chapter 16 promises a -provider may move a surface between its own Workloads "without a single consumer +`Service`, which is named for the provider's **Process** (`postgres`), not for +the Application id the consumer wrote (`platform-postgres`). Chapter 16 promises a +provider may move a surface between its own Processes "without a single consumer edit"; the resolved coordinate in the consumer's ConfigMap changes when it does. **G-12** `${dependency:platform-postgres.database}` resolves to `knowledge_db`. -The `_db` spelling is evidenced by `init-databases.sh` and specified in +The `_db` spelling is evidenced by `init-databases.sh` and specified in no chapter. The set of legal coordinate names (`host`, `port`, `database`, …) is not enumerated anywhere either. **G-13** `runtime: jvm` injects 10 `OTEL_*` and 6 `PYROSCOPE_*` keys; `runtime: -python` injects 6. Exactly one of them, `OTEL_SERVICE_NAME`, is a function of +python` injects 6. Exactly one of them, `OTEL_APPLICATION_NAME`, is a function of anything declared. The other fifteen are constants held in a **Runtime Profile**, and chapter 20's pinned input set does not include one, and it lists Intent Fragments, the Platform document and node contract, the images lock and the @@ -185,19 +185,19 @@ ever written is in the generation being deleted, so coverage for the kind goes from unregistered to absent while default-deny becomes normative. **G-17** Whether the derivation emits a namespace-scoped catch-all in addition to -per-Workload policies is unstated. It matters more than it looks: the namespace -holds every Service of the domain, so one domain's catch-all governs Services +per-Process policies is unstated. It matters more than it looks: the namespace +holds every Application of the project, so one project's catch-all governs Applications added to the file later and never reviewed against it. **G-18** `knowledge-api`'s ingress allow set contains no consumer rule, because nothing in the composed example set declares an edge to `knowledge`. In the live -estate the agents Services do. A provider's policy is therefore a function of -**which fragments are in the union**: a domain that silently fails to publish -narrows its *providers'* ingress, and the pod that breaks is not in the domain +estate the agents Applications do. A provider's policy is therefore a function of +**which fragments are in the union**: a project that silently fails to publish +narrows its *providers'* ingress, and the pod that breaks is not in the project that broke. Chapter 40's stale-participant check is a correctness gate here, not hygiene. -**G-19** "Egress to the Secret Store, from the Workloads holding the grant" is +**G-19** "Egress to the Secret Store, from the Processes holding the grant" is derived from any grant. All four grants here are `delivery: env` or `file`, where the VSO operator does the reading and the pod never opens a connection to Vault. The rule should be conditioned on `delivery: self`; as stated it grants Vault @@ -205,8 +205,8 @@ reachability to pods that never use it. **G-20** `knowledge-ingest-worker` holds an SSH deploy key at 0400 and a claim called `knowledge-vault-clone`, so it must reach a git host **outside the -cluster**. `dependsOn` can only name a Service Id in the composed union, so no -declaration can produce that egress rule. Under default-deny the Workload cannot +cluster**. `dependsOn` can only name an Application Id in the composed union, so no +declaration can produce that egress rule. Under default-deny the Process cannot do the job the grant exists for, and no field in layer 1 can say so. **G-21** The derived `Secret` / `VaultStaticSecret` name is load-bearing, for it is @@ -216,25 +216,25 @@ replaced by `-`. **G-22** The registered `vso` adapter emits one `VaultAuth`, in `vso-system`, on the operator's own ServiceAccount and role. Under it the operator reads every -path for everyone and the two derived per-Workload Vault roles do nothing for -`env` or `file` delivery. This render emits one `VaultAuth` per Workload +path for everyone and the two derived per-Process Vault roles do nothing for +`env` or `file` delivery. This render emits one `VaultAuth` per Process instead; that is the goal state, and it is a different object graph. -**G-23** A Service-level grant has two eligible identities. The model states no +**G-23** An Application-level grant has two eligible identities. The model states no tie-break for which one performs the read, and the choice is visible in the rendered object. -**G-24** With `delivery: env`/`file` the per-Workload Vault boundary is +**G-24** With `delivery: env`/`file` the per-Process Vault boundary is re-materialised as a namespace-scoped Kubernetes `Secret`. It holds only because nothing grants `get secrets` in `knowledge-system`, and there is no `rbac` adapter to render such a Role, nor to prove none exists. -**G-25** `vso.yaml` sits in the Service directory here but the registered adapter +**G-25** `vso.yaml` sits in the Application directory here but the registered adapter writes to `apps/vso-secrets/.yaml` with its own kustomization. Three central adapters already declare the same `platform/cluster/flux/apps` prefix and `E_PATH_COLLISION` has zero occurrences under `src/`. -**G-26 is retired.** The IngressRoutes need a hostname and the Service now +**G-26 is retired.** The IngressRoutes need a hostname and the Application now authors one: `host: knowledge.jorisjonkers.dev` on its `public` exposure, a full FQDN with five named routes under it. Nothing assembles it from a label, a tier policy and a cluster domain, the estate also answers on `kb.jorisjonkers.dev`, @@ -290,9 +290,9 @@ by no rule; this render omits them and takes Kubernetes' defaults. `memory: 512Mi`, `cpu: 100m` and `disk: {media: [nvme], size: 100Gi}`. The intent file declares `256Mi`, `50m` and no `disk` dimension. This render follows the intent and invents no dimension. The chapter's example needs correcting, or the -two disagree about what the same Workload asks for. +two disagree about what the same Process asks for. **G-36** `E_SECRETS_AT_REST_REQUIRED` blocks all four grants until the pinned -Platform document advertises `secretsEncryption: true`, so **none of this domain +Platform document advertises `secretsEncryption: true`, so **none of this project ships** on today's inputs. There is no Platform document in the example set to check against. diff --git a/spec/v1/examples/minimal/README.md b/spec/v1/examples/minimal/README.md index e788c13..638fc06 100644 --- a/spec/v1/examples/minimal/README.md +++ b/spec/v1/examples/minimal/README.md @@ -1,13 +1,13 @@ # The minimal example -The smallest complete thing the model renders: **one domain, one Service, one -Workload**, and no field that is not required. Read this before the three larger -worked domains (`auth`, `knowledge` and `data`), each of which exists to +The smallest complete thing the model renders: **one project, one Application, one +Process**, and no field that is not required. Read this before the three larger +worked projects (`auth`, `knowledge` and `data`), each of which exists to exercise a hard case. | | | |---|---| -| authored | [`notes.domain.yml`](notes.domain.yml): 26 lines of declaration, and [`env/notes-api/base.env`](env/notes-api/base.env): 2 keys | +| authored | [`notes.project.yml`](notes.project.yml): 26 lines of declaration, and [`env/notes-api/base.env`](env/notes-api/base.env): 2 keys | | rendered | [`rendered/`](rendered/): 9 files, 10 objects, 5 of the 6 adapters | | gaps | none. Every value in the tree derives from a declaration, a pinned fact, or a policy in [Platform Intent](../platform/platform.intent.yml) | @@ -15,7 +15,7 @@ It is also the **only** worked example that renders on today's pinned inputs. The other three all hold a `delivery: env` or `delivery: file` grant, and `secretsEncryption` is `false`, so every one of them fails `E_SECRETS_AT_REST_REQUIRED` ([0028](../../../../docs/adr/model/0028-secrets-at-rest-gate.md)) -until that flag lands. This Service holds no grant at all, so the gate has +until that flag lands. This Application holds no grant at all, so the gate has nothing to refuse. ## What the declaration buys @@ -25,17 +25,17 @@ written by anyone: | authored | derived | rendered | |---|---|---| -| `domain: notes` | namespace `notes-system`, the Reconcile Unit, the path plan | `Namespace`, both `kustomization.yaml` | -| `id`, workload `name`, `runtime` | the fixed label set ([0072](../../../../docs/adr/model/0072-the-label-set-is-fixed.md)) | every object's labels, and the `Deployment` selector | +| `project: notes` | namespace `notes-system`, the Reconcile Unit, the path plan | `Namespace`, both `kustomization.yaml` | +| `id`, process `name`, `runtime` | the fixed label set ([0072](../../../../docs/adr/model/0072-the-label-set-is-fixed.md)) | every object's labels, and the `Deployment` selector | | `image: notes-api` | the digest, `runAsUser`, `runAsGroup` from the images lock ([0082](../../../../docs/adr/model/0082-images-lock-carries-uid-and-gid.md)) | the container image and `securityContext` | | `placement` | requests and limits: memory request equals limit, cpu request with no limit | `resources` | | no `hardening` block | the `restricted` class: non-root, read-only root, all capabilities dropped, seccomp `RuntimeDefault` | `securityContext`, pod and container | | no grant | `automountServiceAccountToken: false` ([0087](../../../../docs/adr/model/0087-token-mounted-only-for-delivery-self.md)) | the pod spec | | `probes` + `startupBudget: 20s` | the probe cadence from Platform Intent, a startup probe on the **liveness** endpoint, `progressDeadlineSeconds: 60` ([0088](../../../../docs/adr/model/0088-startup-probe-targets-liveness.md)) | all three probes | | `cutover: rolling` | `RollingUpdate`, `maxSurge: 1`, `maxUnavailable: 0`, derived from the declared intent and the absence of volumes | the strategy | -| `provides: http: 8080` | the port name, the Service, the ingress rules | `Service`, `NetworkPolicy` | +| `provides: http: 8080` | the port name, the Application, the ingress rules | `Service`, `NetworkPolicy` | | `exposure` + `audience: anonymous` | the tier, its middleware chain, and the route priority ([0093](../../../../docs/adr/model/0093-route-precedence-is-derived.md)) | `IngressRoute` | -| `observability` | a `ServiceMonitor` naming the `http` surface and the Platform document's cadence ([chapter 10](../../10-service-intent.md#observability)) | a `PrometheusRule`: rules, severity and receivers are the monitoring stack's, which reads `alertClass` from the projection | +| `observability` | a `ServiceMonitor` naming the `http` surface and the Platform document's cadence ([chapter 10](../../10-project-intent.md#observability)) | a `PrometheusRule`: rules, severity and receivers are the monitoring stack's, which reads `alertClass` from the projection | | the env file's two literals | the Runtime Profile keys and `PORT`, which are a build error to author | the container's `env` | ## What is absent, and why @@ -48,9 +48,9 @@ Every absence below is a decision, not an omission: | `PersistentVolumeClaim`, backup `CronJob` | no `volumes`, so no Durability Class and nothing to back up ([0077](../../../../docs/adr/model/0077-durability-derives-a-backup.md)) | | `engine` | required only where a volume derives a backup, and refused otherwise | | Vault policy, auth role, `VaultStaticSecret` | no grant, so no identity holds privilege ([0073](../../../../docs/adr/model/0073-vault-policy-is-a-deliverable.md)) | -| `Role`, `RoleBinding` | never rendered for a Workload; the absence is checked instead ([0075](../../../../docs/adr/model/0075-no-workload-rbac-in-v1.md)) | +| `Role`, `RoleBinding` | never rendered for a Process; the absence is checked instead ([0075](../../../../docs/adr/model/0075-no-process-rbac-in-v1.md)) | | `writablePaths`, and any `emptyDir` | this image writes nothing, so the read-only root holds unrelaxed ([0092](../../../../docs/adr/model/0092-writable-paths-are-declared.md)) | -| `Middleware` | estate-scoped, and rendered once in the platform edge domain: this Service only *references* it ([0096](../../../../docs/adr/model/0096-the-foundation-is-declared.md)) | +| `Middleware` | estate-scoped, and rendered once in the platform edge project: this Application only *references* it ([0096](../../../../docs/adr/model/0096-the-foundation-is-declared.md)) | | a `dependsOn` edge | nothing to depend on, so the NetworkPolicy carries only the two baseline rules and the two ingress rules the exposure and the scrape imply | ## Reading it beside the diagrams @@ -58,8 +58,8 @@ Every absence below is a decision, not an omission: The pipeline this example walks through is [chapter 00's meta-model](../../diagrams/00-overview-meta-model.drawio.svg); the shape of the authored file is -[chapter 10's model](../../diagrams/10-service-intent-model.drawio.svg); and the +[chapter 10's model](../../diagrams/10-project-intent-model.drawio.svg); and the route from a declaration to an object is [chapter 16's derivation map](../../diagrams/16-derivation-map-deliverables.drawio.svg). -This Service uses one path through each of them, which is what makes it the +This Application uses one path through each of them, which is what makes it the example to start from. diff --git a/spec/v1/examples/minimal/env/notes-api/base.env b/spec/v1/examples/minimal/env/notes-api/base.env index 03614f9..4d795bc 100644 --- a/spec/v1/examples/minimal/env/notes-api/base.env +++ b/spec/v1/examples/minimal/env/notes-api/base.env @@ -1,7 +1,7 @@ -# One env file set per Workload. Literals are written literally; a derived value +# One env file set per Process. Literals are written literally; a derived value # is a named placeholder and writing one as a literal is a build error. # -# This Workload needs no placeholder at all: it has no dependency edge, no grant +# This Process needs no placeholder at all: it has no dependency edge, no grant # and no exposure reference of its own. That is what makes it the minimal case. NODE_ENV=production diff --git a/spec/v1/examples/minimal/notes.domain.yml b/spec/v1/examples/minimal/notes.project.yml similarity index 72% rename from spec/v1/examples/minimal/notes.domain.yml rename to spec/v1/examples/minimal/notes.project.yml index d11e129..db8d8c9 100644 --- a/spec/v1/examples/minimal/notes.domain.yml +++ b/spec/v1/examples/minimal/notes.project.yml @@ -1,31 +1,31 @@ -# The smallest complete Service Intent in the model. +# The smallest complete Project Intent in the model. # -# One domain, one Service, one Workload, and nothing that is not required. +# One project, one Application, one Process, and nothing that is not required. # Everything absent from this file is either derived (chapter 20) or refused # (chapter 10). It is the file to read first, and the file to copy when -# onboarding a Service that has no storage and no secrets. +# onboarding an Application that has no storage and no secrets. # # It is also the only worked example that renders on today's pinned inputs: # it holds no grant, so the secrets-at-rest gate # (docs/adr/model/0028-secrets-at-rest-gate.md) has nothing to refuse. apiVersion: intent.jorisjonkers.dev/v1 -kind: Domain +kind: Project schemaVersion: 1.0.0 -domain: notes # the namespace derives: notes-system -owner: joris # the only field raised to the domain header +project: notes # the namespace derives: notes-system +owner: joris # the only field raised to the project header -services: +applications: - id: notes - # Whole or absent. The class states urgency; the scrape names the Workload + # Whole or absent. The class states urgency; the scrape names the Process # and the surface that carries the signal, so the port is declared once, in - # `provides`. Omitting the block entirely is how a Service says it wants no + # `provides`. Omitting the block entirely is how an Application says it wants no # monitoring -- there is no `none` to write. observability: alertClass: business-hours scrape: - workload: notes-api + process: notes-api surface: http path: /metrics @@ -35,16 +35,16 @@ services: audience: anonymous # selects the tier that carries it contentPolicy: strict routes: - - { path: /, match: prefix, workload: notes-api, surface: http } + - { path: /, match: prefix, process: notes-api, surface: http } - workloads: + processes: - name: notes-api - lifecycle: service + lifecycle: application image: notes-api # an alias; the lock resolves digest, uid and gid runtime: node provides: - http: 8080 # above 1024: restricted drops CAP_NET_BIND_SERVICE + http: 8080 # above 1024: restricted drops CAP_NET_BIND_APPLICATION # Two dimensions are required. Both are matched against what the node # contract publishes, never against free capacity @@ -63,4 +63,4 @@ services: cutover: rolling # No `replicas` block: the derived count is one, and one is what this - # Workload runs. The block is for a count above one, with a reason. + # Process runs. The block is for a count above one, with a reason. diff --git a/spec/v1/examples/negative/duplicate-service-id/README.md b/spec/v1/examples/negative/duplicate-application-id/README.md similarity index 63% rename from spec/v1/examples/negative/duplicate-service-id/README.md rename to spec/v1/examples/negative/duplicate-application-id/README.md index 940718e..8033efa 100644 --- a/spec/v1/examples/negative/duplicate-service-id/README.md +++ b/spec/v1/examples/negative/duplicate-application-id/README.md @@ -1,25 +1,25 @@ -# Negative fixture: `E_DUPLICATE_SERVICE_ID` +# Negative fixture: `E_DUPLICATE_APPLICATION_ID` -Two Intent Fragments declaring the same Service Id. Composition must reject +Two Intent Fragments declaring the same Application Id. Composition must reject this union, **with this error code**. -Each fragment is one domain file (`intent-a/knowledge.yml` and -`intent-b/agents.yml`), because Intent is authored one file per domain and one +Each fragment is one project file (`intent-a/knowledge.yml` and +`intent-b/agents.yml`), because Intent is authored one file per project and one file is one Intent Fragment -([0063](../../../../../docs/adr/model/0063-intent-authored-per-domain.md)). The two -declare different domains and the same `id`, which is the case the invariant -exists for: the namespace derives from `domain`, so nothing would collide at -apply, while every `dependsOn: {service: knowledge, …}` edge in the estate +([0063](../../../../../docs/adr/model/0063-intent-authored-per-project.md)). The two +declare different projects and the same `id`, which is the case the invariant +exists for: the namespace derives from `project`, so nothing would collide at +apply, while every `dependsOn: {application: knowledge, …}` edge in the estate becomes ambiguous. Identity is flat and estate-unique -([0010](../../../../../docs/adr/model/0010-flat-service-identity.md)); the domain +([0010](../../../../../docs/adr/model/0010-flat-application-identity.md)); the project header does not namespace it. Each fragment is otherwise valid and schema-complete, so the union reaches the identity check rather than failing earlier for an unrelated reason. That is load-bearing: a fixture rejected by `E_SCHEMA_VERSION_MISMATCH` on the way in proves the version check can fail and says nothing about identity. It is why -both Workloads carry a complete `placement` block, where `memory` and `cpu` are -required on every Workload +both Processes carry a complete `placement` block, where `memory` and `cpu` are +required on every Process ([0061](../../../../../docs/adr/model/0061-placement-is-hard-dimensions.md)), so a fixture missing them would trip schema validation first. @@ -36,8 +36,8 @@ The estate agent contract puts it as *"verify the value, not the command"*, an exit code is not evidence that a consumer saw what you intended. An assertion that silently stopped running looks identical to one that passes, which is how `E_ROUTE_AUTH_MODE_NOT_IN_TIER` came to be implemented, error-coded, and -vacuous for three of four routed services. +vacuous for three of four routed applications. 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. +is [`../duplicate-process-name/`](../duplicate-process-name/), which asserts +`E_DUPLICATE_PROCESS_NAME` over a single project file. diff --git a/spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml b/spec/v1/examples/negative/duplicate-application-id/intent-a/knowledge.yml similarity index 71% rename from spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml rename to spec/v1/examples/negative/duplicate-application-id/intent-a/knowledge.yml index 1f1b4b4..3b86ae3 100644 --- a/spec/v1/examples/negative/duplicate-service-id/intent-a/knowledge.yml +++ b/spec/v1/examples/negative/duplicate-application-id/intent-a/knowledge.yml @@ -1,7 +1,7 @@ -# Fixture A: the knowledge domain, one Intent Fragment. +# Fixture A: the knowledge project, one Intent Fragment. # # Valid on its own: the union with intent-b is what must fail, and it must fail -# with E_DUPLICATE_SERVICE_ID rather than for any other reason. +# with E_DUPLICATE_APPLICATION_ID rather than for any other reason. # A literal, and legitimately so. schemaVersion is the DATA MODEL's own semver # (docs/adr/model/0039-artifact-schema-versioning.md), not the toolkit package's, and @@ -12,18 +12,18 @@ # non-zero exit, still printed success. schemaVersion: 1.0.0 -# One file per domain, one file one Intent Fragment -# (docs/adr/model/0063-intent-authored-per-domain.md). `owner` is the only field the +# One file per project, one file one Intent Fragment +# (docs/adr/model/0063-intent-authored-per-project.md). `owner` is the only field the # header raises. -domain: knowledge +project: knowledge owner: joris -services: +applications: - id: knowledge alertClass: business-hours - workloads: + processes: - name: knowledge-api - lifecycle: service + lifecycle: application image: knowledge-api runtime: jvm placement: {memory: 768Mi, cpu: 250m} diff --git a/spec/v1/examples/negative/duplicate-application-id/intent-b/agents.yml b/spec/v1/examples/negative/duplicate-application-id/intent-b/agents.yml new file mode 100644 index 0000000..bd255cb --- /dev/null +++ b/spec/v1/examples/negative/duplicate-application-id/intent-b/agents.yml @@ -0,0 +1,26 @@ +# Fixture B: a different project, in a different repository, claiming the same +# Application Id. Identity is flat and unique estate-wide +# (docs/adr/model/0010-flat-application-identity.md), so the union of these two fragments +# must fail with E_DUPLICATE_APPLICATION_ID -- and with nothing else. +# +# The project header does NOT namespace the id. Deriving the Kubernetes namespace +# from `project` (docs/adr/model/0063-intent-authored-per-project.md) would place these +# two in knowledge-system and agents-system, so nothing would collide at apply. +# That is exactly why the check exists at composition: the id is what one +# Application uses to reference another, and two Applications answering to one string +# makes every `dependsOn: {application: knowledge, ...}` edge ambiguous. +schemaVersion: 1.0.0 + +project: agents +owner: joris + +applications: + - id: knowledge + alertClass: business-hours + processes: + - name: other-knowledge + lifecycle: application + image: other-knowledge + runtime: jvm + placement: {memory: 256Mi, cpu: 50m} + probes: none diff --git a/spec/v1/examples/negative/duplicate-process-name/README.md b/spec/v1/examples/negative/duplicate-process-name/README.md new file mode 100644 index 0000000..fd11355 --- /dev/null +++ b/spec/v1/examples/negative/duplicate-process-name/README.md @@ -0,0 +1,48 @@ +# Negative fixture: `E_DUPLICATE_PROCESS_NAME` + +One project file whose two Applications declare the same Process name. Composition +must reject it, **with this error code**. + +`intent/agents.yml` holds Applications `agents-api` and `lightrag`, and both call +their process `api`. Nothing about that is exotic (each Application id already +carries the product name, so `api` is what an author reaches for twice), and it +is precisely what the rule forbids: **Process names are unique within a +project**, because the ServiceAccount and the Vault role are the Process name +alone under the project's namespace +([0024](../../../../../docs/adr/model/0024-identity-per-process.md), +[0063](../../../../../docs/adr/model/0063-intent-authored-per-project.md)). Both +Processes here would derive `agents-system.api`, and the second Application's pods +would authenticate as the first Application's principal and receive its grants. + +## Why one fragment is enough + +`E_DUPLICATE_APPLICATION_ID` needs a union of two fragments to demonstrate, because +ids collide across repositories. This one does not: a project never spans +repositories and one file is the whole project +([0063](../../../../../docs/adr/model/0063-intent-authored-per-project.md)), so every +Process name that must be compared is in this single file. The check runs +where the other identity checks run (the union, [chapter +40](../../../40-composition.md)), and a union of one fragment is still a union. + +The fixture is otherwise valid and schema-complete: complete `placement` blocks +with the required `memory` and `cpu` +([0061](../../../../../docs/adr/model/0061-placement-is-hard-dimensions.md)), probes +declared rather than omitted, no secret grants and therefore no env files to +bind. If it tripped a different check on the way in, it would prove that check +can fail and say nothing about Process 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_PROCESS_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 +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.* + +Renaming one of the two Processes is the fix an author would make (`api` to +`lightrag-api`, say), and it is a one-line edit, because a Process is not +independently referencable: `dependsOn` names `{application, surface}` +([0062](../../../../../docs/adr/model/0062-application-is-the-release-unit.md)), so no +other document names either Process. diff --git a/spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml b/spec/v1/examples/negative/duplicate-process-name/intent/agents.yml similarity index 62% rename from spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml rename to spec/v1/examples/negative/duplicate-process-name/intent/agents.yml index 9a5ced9..ac6455b 100644 --- a/spec/v1/examples/negative/duplicate-workload-name/intent/agents.yml +++ b/spec/v1/examples/negative/duplicate-process-name/intent/agents.yml @@ -1,28 +1,28 @@ -# Fixture: one domain file, two Services, one Workload name. +# Fixture: one project file, two Applications, one Process name. # # 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. +# E_DUPLICATE_PROCESS_NAME and with nothing else. schemaVersion: 1.0.0 -# One file per domain (docs/adr/model/0063-intent-authored-per-domain.md). The +# One file per project (docs/adr/model/0063-intent-authored-per-project.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 -# (docs/adr/model/0024-identity-per-workload.md) -- which is what makes the collision +# and Vault role of a Process are its NAME under that namespace +# (docs/adr/model/0024-identity-per-process.md) -- which is what makes the collision # below an identity collision rather than a style complaint. -domain: agents +project: agents owner: joris -services: - # Two Services in one file are ordinary: co-location couples nothing, and +applications: + # Two Applications in one file are ordinary: co-location couples nothing, and # these two release independently - # (docs/adr/model/0062-service-is-the-release-unit.md). What is not ordinary is that - # both name a Workload `api`. + # (docs/adr/model/0062-application-is-the-release-unit.md). What is not ordinary is that + # both name a Process `api`. - id: agents-api alertClass: urgent - workloads: + processes: - name: api # -> agents-system.api - lifecycle: service + lifecycle: application image: agents-api runtime: jvm provides: {http: 8080} @@ -33,9 +33,9 @@ services: - id: lightrag alertClass: business-hours - workloads: + processes: - name: api # -> agents-system.api, ALREADY TAKEN - lifecycle: service + lifecycle: application image: lightrag runtime: python provides: {http: 9621} 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 deleted file mode 100644 index d4eea47..0000000 --- a/spec/v1/examples/negative/duplicate-service-id/intent-b/agents.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Fixture B: a different domain, in a different repository, claiming the same -# Service Id. Identity is flat and unique estate-wide -# (docs/adr/model/0010-flat-service-identity.md), so the union of these two fragments -# must fail with E_DUPLICATE_SERVICE_ID -- and with nothing else. -# -# The domain header does NOT namespace the id. Deriving the Kubernetes namespace -# from `domain` (docs/adr/model/0063-intent-authored-per-domain.md) would place these -# two in knowledge-system and agents-system, so nothing would collide at apply. -# 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. -schemaVersion: 1.0.0 - -domain: agents -owner: joris - -services: - - id: knowledge - alertClass: business-hours - workloads: - - name: other-knowledge - lifecycle: service - image: other-knowledge - runtime: jvm - placement: {memory: 256Mi, cpu: 50m} - probes: none diff --git a/spec/v1/examples/negative/duplicate-workload-name/README.md b/spec/v1/examples/negative/duplicate-workload-name/README.md deleted file mode 100644 index f6ecbab..0000000 --- a/spec/v1/examples/negative/duplicate-workload-name/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Negative fixture: `E_DUPLICATE_WORKLOAD_NAME` - -One domain file whose two Services declare the same Workload name. Composition -must reject it, **with this error code**. - -`intent/agents.yml` holds Services `agents-api` and `lightrag`, and both call -their process `api`. Nothing about that is exotic (each Service id already -carries the product name, so `api` is what an author reaches for twice), and it -is precisely what the rule forbids: **Workload names are unique within a -domain**, because the ServiceAccount and the Vault role are the Workload name -alone under the domain's namespace -([0024](../../../../../docs/adr/model/0024-identity-per-workload.md), -[0063](../../../../../docs/adr/model/0063-intent-authored-per-domain.md)). Both -Workloads here would derive `agents-system.api`, and the second Service's pods -would authenticate as the first Service's principal and receive its grants. - -## Why one fragment is enough - -`E_DUPLICATE_SERVICE_ID` needs a union of two fragments to demonstrate, because -ids collide across repositories. This one does not: a domain never spans -repositories and one file is the whole domain -([0063](../../../../../docs/adr/model/0063-intent-authored-per-domain.md)), so every -Workload name that must be compared is in this single file. The check runs -where the other identity checks run (the union, [chapter -40](../../../40-composition.md)), and a union of one fragment is still a union. - -The fixture is otherwise valid and schema-complete: complete `placement` blocks -with the required `memory` and `cpu` -([0061](../../../../../docs/adr/model/0061-placement-is-hard-dimensions.md)), probes -declared rather than omitted, no secret grants and therefore no env files to -bind. If it tripped a different check on the way in, it would prove that check -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 -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.* - -Renaming one of the two Workloads is the fix an author would make (`api` to -`lightrag-api`, say), and it is a one-line edit, because a Workload is not -independently referencable: `dependsOn` names `{service, surface}` -([0062](../../../../../docs/adr/model/0062-service-is-the-release-unit.md)), so no -other document names either Workload. diff --git a/spec/v1/examples/platform/README.md b/spec/v1/examples/platform/README.md index 342173d..65b3457 100644 --- a/spec/v1/examples/platform/README.md +++ b/spec/v1/examples/platform/README.md @@ -2,8 +2,8 @@ [`platform.intent.yml`](platform.intent.yml) is the second authored document ([chapter 14](../../14-platform-intent.md)), what the estate offers, published -as an Intent Fragment by digest like any domain and composed with the three -worked domains. It replaces the Cluster Context example that used to sit here: +as an Intent Fragment by digest like any project and composed with the three +worked projects. It replaces the Cluster Context example that used to sit here: same facts, now with a chapter, a kind, and a publication path ([0095](../../../../docs/adr/model/0095-platform-intent-is-the-second-authored-document.md)). @@ -16,7 +16,7 @@ nothing in it names a Kubernetes field, a Traefik key, a k3s flag or a command |---|---|---| | `substrate` | cluster facts that decide other decisions, named for what they are | [0057](../../../../docs/adr/model/0057-datastore-and-restore.md), [0028](../../../../docs/adr/model/0028-secrets-at-rest-gate.md) | | `bootstrap` | what must exist before the first rendered object can apply | [0099](../../../../docs/adr/model/0099-bootstrap-set-is-recorded.md) | -| `tiers` | the shared edge is finite; four edge facts, and the Traefik Service each tier is | [0076](../../../../docs/adr/model/0076-middleware-has-one-producer.md), [0097](../../../../docs/adr/model/0097-authored-values-name-model-concepts.md) | +| `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) | @@ -31,7 +31,7 @@ with `cni: flannel`, which keeps default-deny render-only Both are the gates working. **Not here, deliberately.** The foundation (Vault, VSO, the two Traefik -instances, Prometheus, Gatus) is declared as Services in domain files the +instances, Prometheus, Gatus) is declared as Applications in project files the platform owns ([0096](../../../../docs/adr/model/0096-the-foundation-is-declared.md)); those files are the next worked example to write. The node contract is its own pinned input, named above by digest diff --git a/spec/v1/examples/platform/platform.intent.yml b/spec/v1/examples/platform/platform.intent.yml index 3d6ed06..0484389 100644 --- a/spec/v1/examples/platform/platform.intent.yml +++ b/spec/v1/examples/platform/platform.intent.yml @@ -1,6 +1,6 @@ # Platform Intent: the second authored document. What the estate offers, held to -# the same rule as a domain file -- facts and policy, never mechanisms -- and -# published as an Intent Fragment by digest like any domain +# the same rule as a project file -- facts and policy, never mechanisms -- and +# published as an Intent Fragment by digest like any project # (spec/v1/14-platform-intent.md, docs/adr/model/0095-platform-intent-is-the-second-authored-document.md). # # Every block is a value the contention test put on the platform's side @@ -15,7 +15,7 @@ owner: joris metadata: cluster: production - domain: jorisjonkers.dev + project: jorisjonkers.dev nodeContract: sha256:6f1c2a8e5d3b4c7f9a0e1d2c3b4a5968778695a4b3c2d1e0f9a8b7c6d5e4f3a2 # Facts about the cluster, named for what they are. How k3s is told is a @@ -54,7 +54,7 @@ tiers: listener: tls certificates: acme forwardAuth: http://auth-api.auth-system.svc.cluster.local:8081/api/auth/forward - traefik: traefik-public # the declared Service in the platform edge domain, + traefik: traefik-public # the declared Application in the platform edge project, # placed on the public-ingress node - name: lan audiences: [lan] @@ -83,7 +83,7 @@ engines: files: { backup: file-backup } # One cadence for every rendered monitor, for the same reason the probe cadence -# below is here: it is contended across the estate, and no Service knows better +# below is here: it is contended across the estate, and no Application knows better # (docs/adr/model/0004-contention-decides-authority.md). monitors: interval: 30s @@ -92,9 +92,9 @@ monitors: # NO rule catalog, no receivers, no severity map. Those belong to the monitoring # stack, which reads the published projection; this document decides how often # to scrape and nothing else about alerting -# (spec/v1/10-service-intent.md#observability). +# (spec/v1/10-project-intent.md#observability). -# One posture for every container the estate renders. A Workload authors no +# One posture for every container the estate renders. A Process authors no # hardening at all: it declares the paths it writes, and an image that cannot # meet this class is refused (docs/adr/model/0016-pod-hardening.md). hardening: restricted @@ -111,7 +111,7 @@ probes: ephemeral: sizeLimit: 64Mi -# Things the estate runs and does not deploy, that a Service depends on. A fact +# Things the estate runs and does not deploy, that an Application depends on. A fact # with an address, resolvable by an edge -- not a hole, so no review date # (docs/adr/model/0090-edges-resolve-against-the-register.md). providers: diff --git a/spec/v1/examples/refusals/README.md b/spec/v1/examples/refusals/README.md index 2a60362..cf999f4 100644 --- a/spec/v1/examples/refusals/README.md +++ b/spec/v1/examples/refusals/README.md @@ -8,12 +8,12 @@ one defect so the refusal has a single cause. | fixture | expects | why | |---|---|---| -| [`alert-class-without-signal.domain.yml`](alert-class-without-signal.domain.yml) | `E_ALERT_CLASS_WITHOUT_SIGNAL` | an `observability` block carrying a class and no `scrape`. A class states how loudly to wake someone and means nothing without a signal to wake them about ([chapter 10](../../10-service-intent.md#observability)) | -| [`alert-class-unknown.domain.yml`](alert-class-unknown.domain.yml) | schema validation | a value outside the closed `AlertClass` vocabulary, refused before composition runs, so no new error code carries it | -| [`cutover-rolling-over-rwo.domain.yml`](cutover-rolling-over-rwo.domain.yml) | `E_CUTOVER_UNHONOURABLE` | `cutover: rolling` over an RWO volume, which cannot surge ([chapter 10](../../10-service-intent.md#cutover-is-declared-not-promised)) | -| [`cutover-recreate-over-rwo.domain.yml`](cutover-recreate-over-rwo.domain.yml) | accepted | the same Workload and storage with the cutover it can honour, the pair that makes the refusal above meaningful | +| [`alert-class-without-signal.project.yml`](alert-class-without-signal.project.yml) | `E_ALERT_CLASS_WITHOUT_SIGNAL` | an `observability` block carrying a class and no `scrape`. A class states how loudly to wake someone and means nothing without a signal to wake them about ([chapter 10](../../10-project-intent.md#observability)) | +| [`alert-class-unknown.project.yml`](alert-class-unknown.project.yml) | schema validation | a value outside the closed `AlertClass` vocabulary, refused before composition runs, so no new error code carries it | +| [`cutover-rolling-over-rwo.project.yml`](cutover-rolling-over-rwo.project.yml) | `E_CUTOVER_UNHONOURABLE` | `cutover: rolling` over an RWO volume, which cannot surge ([chapter 10](../../10-project-intent.md#cutover-is-declared-not-promised)) | +| [`cutover-recreate-over-rwo.project.yml`](cutover-recreate-over-rwo.project.yml) | accepted | the same Process and storage with the cutover it can honour, the pair that makes the refusal above meaningful | -There is no fixture for "no monitoring". A Service that wants none omits the +There is no fixture for "no monitoring". An Application that wants none omits the `observability` block, which is an accepted input and appears in the worked set as `platform-valkey` rather than here. @@ -31,5 +31,5 @@ as blockers rather than described as verified: surface it points at, and that a block missing its `scrape` is refused at composition rather than merely being absent from the input. -The `expect:` key is fixture metadata. It is not part of the Domain schema, and +The `expect:` key is fixture metadata. It is not part of the Project schema, and no accepted worked example carries it. diff --git a/spec/v1/examples/refusals/alert-class-unknown.domain.yml b/spec/v1/examples/refusals/alert-class-unknown.project.yml similarity index 79% rename from spec/v1/examples/refusals/alert-class-unknown.domain.yml rename to spec/v1/examples/refusals/alert-class-unknown.project.yml index b4e9cc9..e51c224 100644 --- a/spec/v1/examples/refusals/alert-class-unknown.domain.yml +++ b/spec/v1/examples/refusals/alert-class-unknown.project.yml @@ -1,37 +1,37 @@ # REFUSED: schema validation, against the closed AlertClass enumeration # # `AlertClass` is a closed vocabulary: `business-hours`, `urgent`, `page` -# (spec/v1/10-service-intent.md). There is no `none` member: a Service that +# (spec/v1/10-project-intent.md). There is no `none` member: an Application that # wants no monitoring omits the block. A value outside the vocabulary is refused # at schema validation, before composition runs, so no new error code carries # this case. # -# The Service publishes a signal, which isolates the defect: the only thing +# The Application publishes a signal, which isolates the defect: the only thing # wrong here is the class itself. apiVersion: intent.jorisjonkers.dev/v1 -kind: Domain +kind: Project schemaVersion: 1.0.0 expect: schema, alertClass is not a member of AlertClass -domain: refusals +project: refusals owner: joris -services: +applications: - id: unknown-class # Not a member of the enumeration. The signal beside it is well-formed, so # the class is the only defect. observability: alertClass: critical scrape: - workload: unknown-class-api + process: unknown-class-api surface: http path: /metrics - workloads: + processes: - name: unknown-class-api - lifecycle: service + lifecycle: application image: unknown-class-api runtime: node diff --git a/spec/v1/examples/refusals/alert-class-without-signal.domain.yml b/spec/v1/examples/refusals/alert-class-without-signal.project.yml similarity index 88% rename from spec/v1/examples/refusals/alert-class-without-signal.domain.yml rename to spec/v1/examples/refusals/alert-class-without-signal.project.yml index 61e36f9..b104a36 100644 --- a/spec/v1/examples/refusals/alert-class-without-signal.domain.yml +++ b/spec/v1/examples/refusals/alert-class-without-signal.project.yml @@ -6,27 +6,27 @@ # # Omitting the block entirely would be accepted and would render no monitoring. # It is declaring the urgency while publishing nothing that is refused -# (spec/v1/10-service-intent.md #observability). +# (spec/v1/10-project-intent.md #observability). apiVersion: intent.jorisjonkers.dev/v1 -kind: Domain +kind: Project schemaVersion: 1.0.0 expect: E_ALERT_CLASS_WITHOUT_SIGNAL -domain: refusals +project: refusals owner: joris -services: +applications: - id: unwired # The loudest value in the vocabulary, and no surface to carry it. observability: alertClass: page # No `scrape`. That absence is the refusal. - workloads: + processes: - name: unwired-worker - lifecycle: service + lifecycle: application image: unwired-worker runtime: node diff --git a/spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml similarity index 80% rename from spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml rename to spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml index 03225c0..e7695ed 100644 --- a/spec/v1/examples/refusals/cutover-recreate-over-rwo.domain.yml +++ b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml @@ -1,32 +1,32 @@ -# ACCEPTED. The counterpart to cutover-rolling-over-rwo.domain.yml. +# ACCEPTED. The counterpart to cutover-rolling-over-rwo.project.yml. # -# The same Workload over the same `ReadWriteOnce` storage, declaring the cutover +# The same Process over the same `ReadWriteOnce` storage, declaring the cutover # its storage can honour. The owner accepts stop-then-start, and the adapter # derives the safe Kubernetes strategy (`Recreate`, with no surge) from the # declared intent and the storage facts. No Kubernetes token appears here: # `RollingUpdate`, `maxSurge` and `maxUnavailable` are the adapter's, not the -# Service author's (spec/v1/10-service-intent.md #cutover-is-declared-not-promised). +# Application author's (spec/v1/10-project-intent.md #cutover-is-declared-not-promised). # # Rendering that strategy is renderer proof, and there is no renderer yet. What # this fixture proves today is the accepted half of the pair at the fixture # layer: recreate over RWO is expressible and valid where rolling is refused. apiVersion: intent.jorisjonkers.dev/v1 -kind: Domain +kind: Project schemaVersion: 1.0.0 expect: accepted -domain: refusals +project: refusals owner: joris -services: +applications: - id: recreate-over-rwo # No `observability` block: this fixture isolates the cutover defect. - workloads: + processes: - name: recreate-over-rwo-store - lifecycle: service + lifecycle: application image: recreate-over-rwo-store runtime: static engine: valkey diff --git a/spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml similarity index 78% rename from spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml rename to spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml index 6e5bf55..b512be6 100644 --- a/spec/v1/examples/refusals/cutover-rolling-over-rwo.domain.yml +++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml @@ -1,34 +1,34 @@ # REFUSED: E_CUTOVER_UNHONOURABLE # -# The Workload declares `cutover: rolling`, which is a request for continuity +# The Process declares `cutover: rolling`, which is a request for continuity # through the cutover, and it declares a volume. Every claim on this estate is # `ReadWriteOnce` on `local-path`, and an RWO volume cannot attach to two pods # at once, so the surge a rolling cutover needs cannot happen. # # The model refuses rather than downgrading silently to `Recreate`. A silent # downgrade is what makes an owner believe they have continuity they do not have -# (spec/v1/10-service-intent.md #cutover-is-declared-not-promised, +# (spec/v1/10-project-intent.md #cutover-is-declared-not-promised, # spec/v1/20-resolved-deployment.md #authority). # -# Its accepted counterpart is cutover-recreate-over-rwo.domain.yml: the same -# Workload, the same storage, the honest declaration. +# Its accepted counterpart is cutover-recreate-over-rwo.project.yml: the same +# Process, the same storage, the honest declaration. apiVersion: intent.jorisjonkers.dev/v1 -kind: Domain +kind: Project schemaVersion: 1.0.0 expect: E_CUTOVER_UNHONOURABLE -domain: refusals +project: refusals owner: joris -services: +applications: - id: rolling-over-rwo # No `observability` block: this fixture isolates the cutover defect. - workloads: + processes: - name: rolling-over-rwo-store - lifecycle: service + lifecycle: application image: rolling-over-rwo-store runtime: static engine: valkey diff --git a/spec/v1/examples/workflows/compose.yml b/spec/v1/examples/workflows/compose.yml index 5055043..b195e56 100644 --- a/spec/v1/examples/workflows/compose.yml +++ b/spec/v1/examples/workflows/compose.yml @@ -89,7 +89,7 @@ jobs: if: ${{ !cancelled() }} run: | # participants.yml is the one central artefact. It changes when a - # domain is added or retired, not when a declaration changes. + # project is added or retired, not when a declaration changes. npx --no-install deploy-config-schema compose pull \ --participants participants.yml \ --out fragments/ \ @@ -97,10 +97,10 @@ jobs: # Fails with E_PARTICIPANT_MISSING, or E_PARTICIPANT_STALE past the # participant's maxAge -- seven days by default # (docs/adr/model/0038-participants-list-staleness.md). Not pedantry: the - # composed union is the estate's only complete picture, so a domain + # composed union is the estate's only complete picture, so a project # that silently stops publishing yields a ComposedIntent that looks - # entirely valid and is missing every Service that domain owns. What - # then happens to the objects those Services already have in a + # entirely valid and is missing every Application that project owns. What + # then happens to the objects those Applications already have in a # cluster is a delivery question, defined separately from the model: # see docs/adr/deferred/README.md. @@ -140,7 +140,7 @@ jobs: # exists to detect, one level up. set +e out="$(npx --no-install deploy-config-schema compose union \ - --fragments spec/v1/examples/negative/duplicate-service-id/ \ + --fragments spec/v1/examples/negative/duplicate-application-id/ \ --context-ref "${{ vars.CONTEXT_REF }}" \ --out /tmp/should-fail 2>&1)" status=$? @@ -149,38 +149,38 @@ jobs: if [ "$status" -eq 0 ]; then echo "gate did not fail"; exit 1 fi - printf '%s' "$out" | grep -q 'E_DUPLICATE_SERVICE_ID' || { - echo "gate failed, but not on E_DUPLICATE_SERVICE_ID"; exit 1; } - echo "gate rejects a duplicate Service Id with E_DUPLICATE_SERVICE_ID" + printf '%s' "$out" | grep -q 'E_DUPLICATE_APPLICATION_ID' || { + echo "gate failed, but not on E_DUPLICATE_APPLICATION_ID"; exit 1; } + echo "gate rejects a duplicate Application Id with E_DUPLICATE_APPLICATION_ID" - - name: Prove the workload-name gate can fail, on the right error + - name: Prove the process-name gate can fail, on the right error if: ${{ !cancelled() }} run: | - # One negative fixture per invariant. This one is a single domain - # file whose two Services both name a Workload `api`: Workload names - # are unique within a domain, because the ServiceAccount and the - # Vault role are the Workload name under the domain's namespace - # (docs/adr/model/0024-identity-per-workload.md, - # docs/adr/model/0063-intent-authored-per-domain.md). It needs no second - # fragment -- a domain never spans repositories, so one file holds + # One negative fixture per invariant. This one is a single project + # file whose two Applications both name a Process `api`: Process names + # are unique within a project, because the ServiceAccount and the + # Vault role are the Process name under the project's namespace + # (docs/adr/model/0024-identity-per-process.md, + # docs/adr/model/0063-intent-authored-per-project.md). It needs no second + # fragment -- a project never spans repositories, so one file holds # every name that must be compared. # # Again the assertion is the ERROR CODE. The fixture is otherwise # valid, so a non-zero exit could just as easily mean a schema slip. set +e out="$(npx --no-install deploy-config-schema compose union \ - --fragments spec/v1/examples/negative/duplicate-workload-name/ \ + --fragments spec/v1/examples/negative/duplicate-process-name/ \ --context-ref "${{ vars.CONTEXT_REF }}" \ - --out /tmp/should-fail-workload 2>&1)" + --out /tmp/should-fail-process 2>&1)" status=$? set -e printf '%s\n' "$out" if [ "$status" -eq 0 ]; then echo "gate did not fail"; exit 1 fi - printf '%s' "$out" | grep -q 'E_DUPLICATE_WORKLOAD_NAME' || { - echo "gate failed, but not on E_DUPLICATE_WORKLOAD_NAME"; exit 1; } - echo "gate rejects a duplicate Workload name with E_DUPLICATE_WORKLOAD_NAME" + printf '%s' "$out" | grep -q 'E_DUPLICATE_PROCESS_NAME' || { + echo "gate failed, but not on E_DUPLICATE_PROCESS_NAME"; exit 1; } + echo "gate rejects a duplicate Process name with E_DUPLICATE_PROCESS_NAME" - name: Publish ComposedIntent and its lock id: publish diff --git a/spec/v1/examples/workflows/service-publish-fragment.yml b/spec/v1/examples/workflows/project-publish-fragment.yml similarity index 86% rename from spec/v1/examples/workflows/service-publish-fragment.yml rename to spec/v1/examples/workflows/project-publish-fragment.yml index 6939e07..1d893c0 100644 --- a/spec/v1/examples/workflows/service-publish-fragment.yml +++ b/spec/v1/examples/workflows/project-publish-fragment.yml @@ -1,4 +1,4 @@ -# services/knowledge/.github/workflows/publish-fragment.yml +# applications/knowledge/.github/workflows/publish-fragment.yml # # Publishes this repository's Intent Fragment when platform intent changes. # @@ -6,14 +6,14 @@ # -> oras resolve # -> verify digest # -# ONE DOMAIN FILE IS ONE FRAGMENT (docs/adr/model/0063-intent-authored-per-domain.md). +# 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 -# fragment; a repository holding several domain files runs these steps once per -# file, and a domain never spans repositories, so no fragment is ever a partial -# domain. +# 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. # # Fires independently of any image release. That is deliberate: a change to the -# domain file, an env file or a secret grant produces no image, and the unit of +# project file, an env file or a secret grant produces no image, and the unit of # composition is the FRAGMENT, not the image # (docs/adr/model/0037-composition-oci-fragments.md). Tying this to a version tag # would let intent-only changes sit unpublished until the participant tripped @@ -64,7 +64,7 @@ jobs: # the previous render-local.sh copies went stale by four minor # releases. # - # It is NOT read from the domain file's schemaVersion. That + # It is NOT read from the project file's schemaVersion. That # field is the DATA MODEL's own semver and no longer tracks the # package version (docs/adr/model/0039-artifact-schema-versioning.md): # reading it as a package version pins the toolkit to a release that @@ -84,22 +84,22 @@ jobs: # Estate-wide invariants cannot be checked here and are # composition's job. npx --no-install deploy-config-schema intent validate \ - --domain platform/knowledge.yml \ + --project platform/knowledge.yml \ --env-dir platform/env \ --strict - name: Build the fragment tree if: ${{ !cancelled() }} run: | - # --env-dir holds one directory per WORKLOAD - # (docs/adr/model/0011-configuration-env-files-per-workload.md): + # --env-dir holds one directory per PROCESS + # (docs/adr/model/0011-configuration-env-files-per-process.md): # platform/env/knowledge-api/base.env and # platform/env/knowledge-ingest-worker/base.env, each with its own - # per-cluster overlay. Workload names are unique within a domain, so - # one directory per Workload is unambiguous across every Service the - # file declares (docs/adr/model/0024-identity-per-workload.md). + # per-cluster overlay. Process names are unique within a project, so + # 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 \ - --domain platform/knowledge.yml \ + --project platform/knowledge.yml \ --env-dir platform/env \ --out fragment/ diff --git a/test/diagram-model-consistency.test.ts b/test/diagram-model-consistency.test.ts index 9c07ba2..7b6b5d5 100644 --- a/test/diagram-model-consistency.test.ts +++ b/test/diagram-model-consistency.test.ts @@ -1,7 +1,7 @@ // The drawings, the chapters and the worked examples say the same thing. // // Two defects this file exists to catch actually shipped and were found by -// hand: `data.domain.yml` authored an `onChange` key that 0094 deleted and no +// hand: `data.project.yml` authored an `onChange` key that 0094 deleted and no // class carries, and four `PrometheusRule` fixtures stayed in the rendered // trees after chapter 30 stopped rendering the kind. Both were invisible to // every existing gate, because each artefact was internally consistent. @@ -34,7 +34,7 @@ function mermaidModel(): { comps: Pair[]; deps: Pair[]; } { - const md = read(join(spec, "10-service-intent.md")); + const md = read(join(spec, "10-project-intent.md")); const body = capture( md, /```mermaid\nclassDiagram\n([\s\S]*?)\n```/, @@ -96,7 +96,7 @@ function svgModel(name: string): { test("the class diagram draws exactly the mermaid's classes and attributes", () => { const { classes } = mermaidModel(); - const { boxes } = svgModel("10-service-intent-model.drawio.svg"); + const { boxes } = svgModel("10-project-intent-model.drawio.svg"); expect( Object.keys(boxes).sort(), "the drawing and the mermaid disagree about which classes exist", @@ -107,7 +107,7 @@ test("the class diagram draws exactly the mermaid's classes and attributes", () test("only the relations that span layers are left undrawn", () => { const { comps, deps } = mermaidModel(); - const { edges } = svgModel("10-service-intent-model.drawio.svg"); + const { edges } = svgModel("10-project-intent-model.drawio.svg"); // Placeholder reaches Grant and Exposure across four layers. Those two are // stated in the chapter instead; everything else is on the drawing. const undrawn = deps.filter(([from]) => from === "Placeholder").length; @@ -126,7 +126,7 @@ test("no drawing carries an enumeration box", () => { }); test("every closed vocabulary names an attribute that exists", () => { - const md = read(join(spec, "10-service-intent.md")); + const md = read(join(spec, "10-project-intent.md")); const table = capture( md, /## The closed vocabularies\n([\s\S]*?)\n## /, @@ -172,10 +172,10 @@ test("a worked example authors no key the model does not carry", () => { "apiVersion", "kind", "schemaVersion", - "domain", + "project", "owner", - "services", - "workloads", + "applications", + "processes", "provides", "probes", "placement", @@ -197,12 +197,12 @@ test("a worked example authors no key the model does not carry", () => { "readiness", "liveness", ]); - const domainFiles = walk(join(spec, "examples")).filter((f) => - f.endsWith(".domain.yml"), + const projectFiles = walk(join(spec, "examples")).filter((f) => + f.endsWith(".project.yml"), ); const surfaces = new Set(); - for (const file of domainFiles) { + for (const file of projectFiles) { const lines = read(file).split("\n"); lines.forEach((line, i) => { if (!/^ {8}provides:/.test(line)) return; @@ -217,7 +217,7 @@ test("a worked example authors no key the model does not carry", () => { } const unknown: string[] = []; - for (const file of domainFiles) { + for (const file of projectFiles) { // 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; @@ -243,7 +243,7 @@ test("a worked example authors no key the model does not carry", () => { test("every rendered kind is a column of the deliverables matrix", () => { const { xml } = svgModel("16-derivation-map-deliverables.drawio.svg"); // Namespace-scoped operator objects are rendered once per namespace, not per - // Service, so the per-Service map does not carry a column for them. + // Application, so the per-Application map does not carry a column for them. const perNamespace = new Set(["VaultAuth", "VaultConnection", "Namespace"]); const alias: Readonly> = { Kustomization: "kustomization", diff --git a/test/simplification-contract.test.ts b/test/simplification-contract.test.ts index 97f93cb..c86fff1 100644 --- a/test/simplification-contract.test.ts +++ b/test/simplification-contract.test.ts @@ -4,15 +4,15 @@ // fixture-level checks at the narrowest layer available, with any renderer // proof reported as a blocker. This file is that check, 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 +// 1. Observability: one optional `observability` block per Application, whole or +// absent. A declared class names a scrape surface that its own Process +// provides, and no project file carries monitoring policy. +// 2. Cutover: `zeroDowntime` is gone, every Process declares `cutover`, and an +// RWO Process must declare `recreate` (rolling over RWO is the // E_CUTOVER_UNHONOURABLE case; there is no renderer yet to run it in). // 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. +// 4. Hardening: no Process 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. @@ -24,11 +24,11 @@ const repo = join(import.meta.dirname, ".."); const examples = join(repo, "spec", "v1", "examples"); const read = (path: string): string => readFileSync(path, "utf8"); -const domainFiles = [ - "auth/auth.domain.yml", - "knowledge/knowledge.domain.yml", - "data/data.domain.yml", - "minimal/notes.domain.yml", +const projectFiles = [ + "auth/auth.project.yml", + "knowledge/knowledge.project.yml", + "data/data.project.yml", + "minimal/notes.project.yml", ].map((file) => join(examples, file)); interface Slice { @@ -37,24 +37,24 @@ interface Slice { } /** - * 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. + * The processes of a project file as {name, text} slices. Indentation-keyed: + * a process 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 + * `- name:`, but an Application'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 PROCESS_RE = /^ {6}- name: (\S+)/; -function workloadsOf(file: string): Slice[] { +function processesOf(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. + const m = PROCESS_RE.exec(line); + // An Application-level `exposure` entry sits at the same indent as a Process + // under `processes:`; only slices opened outside the exposure block are + // processes. if (m && !inExposure) { if (current) out.push(current); current = { name: m[1] ?? "", text: "" }; @@ -66,11 +66,11 @@ function workloadsOf(file: string): Slice[] { return out; } -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) { +test("every worked Process declares cutover, and zeroDowntime is gone", () => { + for (const file of projectFiles) { + const processes = processesOf(file); + expect(processes.length, `${file}: no processes parsed`).toBeGreaterThan(0); + for (const w of processes) { const rel = `${file.split("/").pop() ?? file}#${w.name}`; expect(w.text, `${rel}: no cutover declaration`).toMatch( /^\s+cutover: (rolling|recreate)$/m, @@ -82,9 +82,9 @@ test("every worked Workload declares cutover, and zeroDowntime is gone", () => { } }); -test("RWO Workloads declare recreate; volume-free Workloads declare rolling", () => { - for (const file of domainFiles) { - for (const w of workloadsOf(file)) { +test("RWO Processes declare recreate; volume-free Processes declare rolling", () => { + for (const file of projectFiles) { + for (const w of processesOf(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(); @@ -97,14 +97,14 @@ test("RWO Workloads declare recreate; volume-free Workloads declare rolling", () } }); -test("no domain file carries overrides, and replicas is the sole capacity exception", () => { - for (const file of domainFiles) { +test("no project file carries overrides, and replicas is the sole capacity exception", () => { + for (const file of projectFiles) { const text = read(file); expect(text, `${file}: overrides key present`).not.toMatch(/^overrides:/m); expect(text, `${file}: override entry syntax present`).not.toMatch( /derivation:/, ); - for (const w of workloadsOf(file)) { + for (const w of processesOf(file)) { const replicas = /^\s+replicas:\s*$\n\s+count: (\d+)(?:\n\s+reason: (.+))?/m.exec( w.text, @@ -132,19 +132,19 @@ const declarationsOf = (file: string): string => const refusals = join(examples, "refusals"); const platform = join(examples, "platform", "platform.intent.yml"); -interface Service { +interface Application { 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. + * The Applications of a project file as {id, text} slices. An Application starts at + * ` - id:` (two spaces) and runs to the next one, so an Application's + * `observability` block and its Processes are read together. */ -function servicesOf(file: string): Service[] { - const out: Service[] = []; - let current: Service | null = null; +function applicationsOf(file: string): Application[] { + const out: Application[] = []; + let current: Application | null = null; for (const line of read(file).split("\n")) { const m = /^ {2}- id: (\S+)/.exec(line); if (m) { @@ -161,14 +161,14 @@ function servicesOf(file: string): Service[] { interface Observability { readonly alertClass: string | null; readonly hasScrape: boolean; - readonly workload: string | null; + readonly process: 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"); +/** The `observability` block of an Application slice, or null when it declares none. */ +function observabilityOf(applicationText: string): Observability | null { + const lines = applicationText.split("\n"); const start = lines.findIndex((line) => /^ {4}observability:\s*$/.test(line)); if (start === -1) return null; const body: string[] = []; @@ -186,15 +186,15 @@ function observabilityOf(serviceText: string): Observability | null { return { alertClass: value("alertClass"), hasScrape: body.some((line) => /^ {6}scrape:\s*$/.test(line)), - workload: value("workload"), + process: value("process"), 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"); +/** The surface names a Process slice declares under `provides`. */ +function surfacesOf(processText: string): string[] { + const lines = processText.split("\n"); const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line)); if (start === -1) return []; const out: string[] = []; @@ -212,8 +212,8 @@ const ALERT_CLASSES = ["business-hours", "urgent", "page"]; 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)) { + for (const file of projectFiles) { + for (const s of applicationsOf(file)) { const o = observabilityOf(s.text); if (o === null) { expect( @@ -237,44 +237,44 @@ test("the observability block is whole or absent, and never partial", () => { declared += 1; } } - expect(declared, "no Service declares observability").toBeGreaterThan(0); + expect(declared, "no Application declares observability").toBeGreaterThan(0); expect( omitted, - "no Service omits it, so the opt-out is untested", + "no Application omits it, so the opt-out is untested", ).toBeGreaterThan(0); }); test("`none` is gone: an omitted block is the opt-out", () => { - for (const file of [...domainFiles, platform]) + for (const file of [...projectFiles, platform]) expect( read(file), `${file}: alertClass none is no longer a member of the vocabulary`, ).not.toMatch(/alertClass:\s*none/); }); -test("a scrape names a surface its own Workload provides, never a port", () => { - for (const file of domainFiles) { - for (const s of servicesOf(file)) { +test("a scrape names a surface its own Process provides, never a port", () => { + for (const file of projectFiles) { + for (const s of applicationsOf(file)) { const o = observabilityOf(s.text); if (o === null) continue; - expect(o.workload, `${s.id}: scrape names no workload`).toBeTruthy(); + expect(o.process, `${s.id}: scrape names no process`).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); + const w = processesOf(file).find((x) => x.name === o.process); expect( w, - `${s.id}: scrape names a Workload that does not exist`, + `${s.id}: scrape names a Process that does not exist`, ).toBeDefined(); expect( surfacesOf(w?.text ?? ""), - `${s.id}: its Workload provides no surface of that name`, + `${s.id}: its Process provides no surface of that name`, ).toContain(o.surface); } } }); -test("no Workload restates a scrape port, and no domain carries alerting policy", () => { - for (const file of [...domainFiles, platform]) { +test("no Process restates a scrape port, and no project carries alerting policy", () => { + for (const file of [...projectFiles, platform]) { const text = read(file); expect( text, @@ -296,17 +296,17 @@ test("the monitor cadence is one estate-wide value in the Platform document", () ); expect(text, "no monitor interval").toMatch(/^\s+interval: \S+$/m); expect(text, "no monitor timeout").toMatch(/^\s+timeout: \S+$/m); - for (const file of domainFiles) + for (const file of projectFiles) expect( read(file), - `${file}: a domain file restates the cadence`, + `${file}: a project file restates the cadence`, ).not.toMatch(/interval:|scrapeTimeout:/); }); 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"); + const noSignal = join(refusals, "alert-class-without-signal.project.yml"); expect(read(noSignal)).toMatch(/^expect: E_ALERT_CLASS_WITHOUT_SIGNAL$/m); - const a = observabilityOf(servicesOf(noSignal)[0]?.text ?? ""); + const a = observabilityOf(applicationsOf(noSignal)[0]?.text ?? ""); expect(a?.alertClass, "the fixture must declare a class").toBeTruthy(); expect( a?.hasScrape, @@ -317,9 +317,9 @@ test("a class with no signal is refused, and an unknown class is not a member", "the class must be a valid member, so the missing signal is the only defect", ).toContain(a?.alertClass); - const unknown = join(refusals, "alert-class-unknown.domain.yml"); + const unknown = join(refusals, "alert-class-unknown.project.yml"); expect(read(unknown)).toMatch(/^expect: schema\b/m); - const b = observabilityOf(servicesOf(unknown)[0]?.text ?? ""); + const b = observabilityOf(applicationsOf(unknown)[0]?.text ?? ""); expect(b?.hasScrape, "the fixture must publish a signal").toBe(true); expect( ALERT_CLASSES, @@ -328,20 +328,20 @@ test("a class with no signal is refused, and an unknown class is not a member", }); 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"); + const refused = join(refusals, "cutover-rolling-over-rwo.project.yml"); + const accepted = join(refusals, "cutover-recreate-over-rwo.project.yml"); expect(read(refused)).toMatch(/^expect: E_CUTOVER_UNHONOURABLE$/m); expect(read(accepted)).toMatch(/^expect: accepted$/m); const only = (file: string): Slice => { - const workloads = workloadsOf(file); + const processes = processesOf(file); expect( - workloads, - `${file}: a refusal fixture carries one Workload`, + processes, + `${file}: a refusal fixture carries one Process`, ).toHaveLength(1); - const [workload] = workloads; - if (workload === undefined) throw new Error(`${file}: no Workload`); - return workload; + const [process] = processes; + if (process === undefined) throw new Error(`${file}: no Process`); + return process; }; const bad = only(refused); const good = only(accepted); @@ -358,23 +358,23 @@ test("rolling over RWO is refused and recreate over RWO is accepted", () => { for (const file of [refused, accepted]) expect( declarationsOf(file), - `${file}: a Kubernetes rollout token leaked into Service Intent`, + `${file}: a Kubernetes rollout token leaked into Project Intent`, ).not.toMatch(/RollingUpdate|maxSurge|maxUnavailable/); }); -test("no Workload or sidecar authors hardening", () => { +test("no Process or sidecar authors hardening", () => { const inputs = [ - ...domainFiles, + ...projectFiles, ...[ - "alert-class-without-signal.domain.yml", - "alert-class-unknown.domain.yml", - "cutover-rolling-over-rwo.domain.yml", - "cutover-recreate-over-rwo.domain.yml", + "alert-class-without-signal.project.yml", + "alert-class-unknown.project.yml", + "cutover-rolling-over-rwo.project.yml", + "cutover-recreate-over-rwo.project.yml", ].map((file) => join(refusals, file)), ]; for (const file of inputs) { const text = declarationsOf(file); - expect(text, `${file}: a Workload authors hardening`).not.toMatch( + expect(text, `${file}: a Process authors hardening`).not.toMatch( /^\s+hardening:/m, ); expect(text, `${file}: a hardening exception survives`).not.toMatch( @@ -389,8 +389,8 @@ 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)) { + for (const file of projectFiles) { + for (const w of processesOf(file)) { const lines = w.text.split("\n"); const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line)); if (start === -1) continue;