feat: Platform Intent, the twenty-six render gaps, and gates for all of it - #12
Merged
Conversation
Session-scratch specs are drafted locally and never committed: they restate decisions that belong in docs/adr and spec/v1, and a committed copy is one more thing to drift.
docs/adr was flat, and its membership rule was normative: everything at the root was v1 model scope, everything under deferred/ was not. That rule has no room for a third kind of decision, and the compiler needs one — how the code itself is layered is neither a model rule nor delivery machinery. Three domains now, each its own directory, each held to its own normative root by the lint: - model/ (51 files, moved) — pointers resolve against spec/v1/ - architecture/ (new) — pointers resolve against docs/architecture.md - deferred/ (unchanged) — not linted, as before Numbers stay one estate-wide sequence, so a citation resolves without knowing which domain it lands in; the lint now fails if one number appears in two domains, and if an ADR is left at the docs/adr root where no normative root applies to it. Also in scope, because the move is only correct if every reference moved with it: ~400 references rewritten across spec/v1, the register, the examples, the env files, README, CLAUDE.md, eslint.config.js and CI; internal ADR links now resolve relative to the linking file's own directory, which is what makes a cross-domain link into ../deferred/ work; chapter 00's partition sentence and the register grew a domain table. docs/architecture.md lands as a skeleton with the headings it will carry, since three documents now point at it. Its sections are empty until decided, and an ADR may not point at an empty one. Fixes a pre-existing wrong path in CI, which cited docs/adr/0048 for a decision that has lived in deferred/ since the split. Nine negative fixtures cover the new rules: both domains' normative roots in both directions, a stray root ADR, a duplicated number, and cross-domain links resolving and failing. npm run verify passes.
…cruiser The hexagon was prose. Prose does not fail a build, and the generation this compiler replaces shows what that costs: adapters reading manifests from disk inside render, a registry laundering its input through a double cast, and 1,967 lines of dead renderer that survived a --lines 90 coverage gate because its own tests imported it. .dependency-cruiser.cjs now encodes the layering as thirteen named rules, and reading it is how a contributor learns what the hexagon is: - the domain reaches inward only, reads nothing ambient (no filesystem, network, clock, environment or crypto — hashing arrives through a port), and does not know the wire shape - the object model imports nothing; the wire layer maps inward only - no adapter reads another adapter, shared code goes to adapters/shared - a use-case takes ports, never a concrete implementation - nothing depends on the CLI - no cycles, and no module reachable from no entry point Fourteen fixtures check the ruleset against trees that cross exactly one boundary each, plus one that respects all of them. A ruleset that has only run against a clean tree is untested; this one is not. scripts/lint-boundaries.mjs wraps it for one reason: src/ does not exist yet, and depcruise exits non-zero on a missing directory. It skips loudly, prints why, and starts enforcing with the first module. It takes a root argument the way lint-adrs.mjs does, so the fixtures can point it at a throwaway tree, and it calls the local binary by absolute path — npx resolves the name from the registry when the cwd is outside this repository. docs/architecture.md gains its Layers and Gates sections, which is where these rules are normative. The remaining headings stay empty until decided.
spec/v1/00-overview.md has said "the vocabulary lives in CONTEXT.md" since the rebuild, and linked a file that did not exist. The link resolves now. It defines the words and nothing else: no field lists, no error tables, no rationale — a definition names the concept and points at the chapter that governs it and the ADR that decided it. That split is what keeps the glossary, the chapters and the register from restating each other. It is also the naming authority for code, which is why it lands before src/. A type or a folder naming one of these concepts uses the word unchanged. Three terms needed disambiguating rather than defining, and the last section says so: - Fragment means an input in chapter 40 and an output in chapter 30. In code the input is IntentFragment and the output is Deliverable; nothing is called Fragment. - Deployment is ambiguous between the Kubernetes kind and the estate's old deployment.jorisjonkers.dev documents, which is the confusion 0003 exists to end. - Render is serialization only. Deciding is resolution or derivation.
Writing the glossary surfaced a third use. Chapter 40's Intent Fragment is an input and chapter 30's Fragment is an output, but chapter 30 also names the documents its five producer adapters emit — TraefikRouteFragment and the rest — and those are wire kinds a consumer pins rather than either of the other two. Chapter 30 also uses Fragment and Deliverable interchangeably for the output unit. The glossary records that, names Deliverable as the code word, and leaves the chapter edit open rather than rewriting normative text as a side effect of writing a glossary.
docs/architecture.md was a skeleton of headings. It now carries the sections the compiler is actually built to, and five ADRs in the new architecture/ domain justify them — the first decisions in this repository that are about code rather than about the model. - 0065 one hexagon, two use-cases, domain folders named after the three layers. The role-flag alternative is refused with the evidence for it: today's registry decides an adapter's input shape with a string comparison and hands five adapters the wrong document. - 0066 Zod declares the authoring shape and a mapper builds the domain. Rests on 0007: two supported schemaVersions must not mean two domains. - 0067 adapters build typed objects, one serializer owns the bytes. The narrow object model is what makes "layer 3 holds no decisions" a compile error. - 0068 failures are coded diagnostics in a list, with Result at the use-case edge. One run reports every violation; a code is what CI can assert on. - 0069 boundaries and reachability are gates on the module graph. Reachability is the half coverage cannot do — 1,967 dead lines passed a --lines 90 gate because their own tests imported them. Each carries a falsifiable claim and a settling test, so the lint holds them to the same contract as a model ADR: 0065 settles when lint:boundaries passes over the full compiler with no rule relaxed, 0066 when the first schemaVersion bump leaves src/domain/ untouched. The register gains an Architecture section, and the anchor check now resolves architecture pointers against docs/architecture.md. 56 files clean.
Chapter 30 said a Fragment's path is a pure function of its adapter and the object it carries. That reads like serialisation and is authority in disguise: a path says which directory owns an object, and therefore which kustomization includes it, which Reconcile Unit applies it, and who is answerable for a field. Layer 3 holds no decisions, so the path cannot be one of its. Two rows of the render-gap list are the evidence, and both close here: - R12, a per-domain object. 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 — data has three Services and renders three identical Namespace objects at three paths, with write order deciding which survives. - R26, an estate-scoped Deliverable. The Gatus endpoints ConfigMap lands in utility-system, a namespace no participating Service owns, and E_FOREIGN_NAMESPACE passes only because the adapter owns the path. 0070 moves authority to layer 2: the Resolved Deployment carries, per rendered object, its owning Adapter and its path. An Adapter still declares a defaultPath — that is how the registry states what it is for, and it is what the plan assigns from — but the path a Fragment carries comes from the plan. E_PATH_COLLISION becomes decidable at plan assembly, before any adapter runs, which is the only place it can be reported as a defect in a decision rather than as a race in a writer. It has zero occurrences under src/ today while the writer applies each file in turn: second write wins, silently, both reporting create. Chapter 20 gains "The path plan"; chapter 30's rule sentence is rewritten and records the old form and why it failed rather than quietly replacing it.
Ten findings, verified against throwaway trees. The two that mattered: - The rule forbidding zod in the domain could never fire. dependency-cruiser matches to.path against the *resolved* path, which for an npm package is node_modules/zod/... or the bare specifier, never the pattern "^zod" against a dependencyTypes filter. So the boundary 0066 exists to enforce was unenforced, and docs/architecture.md claimed a check nothing performed. It was also the one rule with no fixture, which is why it went unseen. - no-orphans is not reachability. dependency-cruiser's orphan means no incoming *and* no outgoing edges, so only a strictly isolated module qualifies — while the case 0069 cites is 14 modules with internal edges, reachable from no entry point. Added unreachable-from-an-entry-point, anchored on the two entry points, and shrank the orphan exemptions to those two files: a barrel or a cli/ module nothing imports is now caught rather than exempted. Also: - CI never ran the boundary gate: lint:boundaries was wired into `verify`, which CI does not invoke. It runs as its own step now, beside lint:links. - Three rows of the layer table in docs/architecture.md were claims nothing checked — domain importing objects, an adapter importing wire, infrastructure importing a use-case all cruised clean. domain-is-pure now forbids everything outside itself, adapters-render-only covers wire, and infrastructure-implements-ports-only is new. - Number uniqueness skipped deferred/, so architecture/0041 could sit beside deferred/0041 and make the citation [0041] ambiguous, and a duplicate inside one domain was accepted outright. - A tree with every ADR left at the docs/adr root reported "no ADR files found" and threw away the stray-file errors — the least useful of the two diagnoses, for exactly the migration mistake the check guards. - lint-boundaries.mjs ignored spawnSync's error, so a missing binary exited 1 with no output, in a script whose stated purpose is to skip loudly. - Fixture register rows were labelled "sion" — slice(-7,-3) on a path, not on the basename. The widest finding got its own gate. The domain move rewrote about a hundred links outside the decision set and nothing validated them: lint:links now checks every relative link and heading anchor in every tracked Markdown file, including explicit <a id> anchors, skipping fenced blocks and external URLs. 97 files clean. Eight fixtures, one per way it can fail. zod lands as a real dependency, which 0066 needs anyway, so the fixtures can import it and resolve it. Fixture count is now one per rule: 23 boundary, 24 ADR, 8 link, 4 contract.
…set is fixed R1 said atomic switchover is not expressible in plain Kubernetes objects, and that no rendered object has the identity "the Service". Both are true, and neither makes the mechanism a v1 decision: chapter 50 already assigns performing the switch to delivery. What was missing is what the model owes so that any delivery definition can perform it. 0071 settles that as three derived inputs per Service — the member list, each member's readiness reference, and one deadline — living in layer 2 and in the projection an owner reads back. All three candidate mechanisms (hold traffic at the edge, paused ReplicaSets plus a selector flip, a gate outside Kubernetes) differ in machinery and agree on inputs, so the inputs are the obligation and the machinery is not. Nothing is rendered for the gate. An applied object no controller consumes is the same defect as app.kubernetes.io/instance, which sits on every object and is read by nothing; a Flux Kustomization per Service would be worse, since its health checks evaluate after apply — after traffic has moved — and it would put a delivery object inside v1's render surface. The deadline is max over members of progressDeadlineSeconds, which deletes a contradiction rather than adding a rule. The health timeout class was a second derivation over the same declaration and the two disagree by 25 minutes on auth-api: a 600s budget derives 1800s, the class gives up at 300s. G-27 closes. 0072 fixes the label set, which R1's answer needs and which R24 recorded as unwritten. Two of the five are a Deployment's selector and therefore immutable on a live object, so the convention is a one-way door: part-of carries the Service Id and is deliberately not a selector, name and instance both carry the Workload name because a Service-named selector makes every Workload of a two-Workload Service ambiguous, and there is no version label. R1 is reclassified from blocking to discharged; R24 and G-27 close.
R2: vso emits VaultConnection, VaultAuth, the operator ServiceAccount, VaultStaticSecret and VaultDynamicSecret. None of those is a policy or a Kubernetes auth role, so the policy 0025 derives from four access tiers had no output anywhere — which under 0005 is a falsified premise rather than a missing feature. It is reached from delivery: self in auth and delivery: env in data, so it is not a delivery-mode edge case. 0073 makes the policy and the role Deliverables of a new vault-policy adapter, one document pair per Workload identity. Per identity, not per Service: 0024 makes the ServiceAccount and Vault role the Workload name alone, and the estate has already paid for the alternative — serviceAccountName() returned the Service name, so two Workloads authenticated as one principal and got the union of both policies whatever level a grant was written at. JSON rather than HCL, because Vault accepts it and because it lets the single serializer own key order, which byte-determinism needs. Rendered, not applied. Writing a policy into Vault is a privileged act against a live system, which is delivery and stays deferred. The CR-for-an-operator alternative would buy one applier at the price of an operator the substrate does not run. The auth method itself stays a platform fixture: mounting kubernetes auth, its JWT issuer and CA, and the KV mounts are estate-unique and draw on a shared resource, so 0004 makes them platform-assigned and 0013 delivers them at a pinned ref. Chapter 60 says so where secrets at rest are specified. 0052 is amended in place to seventeen adapters — the count exists so that adding one is a recorded decision. Chapter 30 gains the registry row and a section saying what the documents are and who does not apply them. R2 closes. R19, R20 and R21 are the three policy-derivation holes this producer will expose, and 0073 names them as such.
…load RBAC R3 bundled two gaps that turn out to have opposite answers. NetworkPolicy is the most completely specified derivation in the model and the least produced: 0035 and chapter 16 give the allow set rule by rule, and the only implementation is in the generation 0052 deletes — the same implementation whose omission of the DNS baseline is the evidence for why that baseline is not optional. 0074 gives the kind its own adapter, the eighteenth. Its own, because the namespace-wide default-deny is one object per domain rather than per Service, and because the assertion that every Egress policy matches UDP/53 is then a property of one producer, checkable in one place. RBAC goes the other way. Under delivery: env and file the kubelet projects the Secret and the pod never calls the API; under delivery: self the pod authenticates to Vault, not to Kubernetes. So the least-privilege Role for a Workload of this estate is the empty Role, and rendering sixty of them would make an oversight of every one: an empty Role invites a rule, and a standing RoleBinding is where a future broad grant would hide. 0075 therefore renders none and checks the absence instead: E_WORKLOAD_RBAC_GRANT refuses any rendered Deliverable that grants a Workload access to secrets. R3's complaint is that isolation rests on an absence; this makes the absence a property rather than luck. agents-api, which really does call the API, is a ledger entry until the model has vocabulary for it rather than an adapter default invented for one consumer. Chapter 30's largest counted gap — 16 RBAC objects — is struck as decided against rather than pending. 0052 is amended to eighteen and records that an rbac adapter is not coming. R3 closes.
…rd-auth endpoint R4: traefik-public and traefik-lan emit IngressRoutes carrying middleware references, and nothing emits the referents. Every audience: authenticated route in the estate resolves against an object that is derived in chapter 20's authority table and produced nowhere. 0076 gives the kind its own adapter, the nineteenth, on the rule 0074 just established: one producer per kind. The Middlewares are estate-scoped and both route adapters reference them, so making one route adapter the owner would mean a lan-only change edits the public adapter and ownership falls to whichever adapter happened to receive it. A blueprint pack fails differently: which middlewares are needed follows from the audiences and content policies routes declare, so a fixture would be a hand-maintained superset of a derived set. The endpoint is the harder half. A forward-auth Middleware must name the address that performs the check, and deriving it from auth-api's own surface would write a Service id into a platform derivation and make the edge tree depend on resolving a Service — which chapter 10 refuses in as many words: auth-api's estate-wide role is this middleware, never an edge. By 0004 the address is platform-assigned, so it lives in the Cluster Context, on the tier rather than in a global field: a tier already declares which audiences it serves, a tier serving no authenticated route needs no endpoint, and two tiers with different endpoints need no model change. E_NO_FORWARD_AUTH_ENDPOINT fires when the chain is derived, which turns a 500 at the edge on an untested route into a build error. R4 closes. Four of eleven done; the tier field joins the missing-input rows the gap list already carries.
…t a process is R5: 0015 makes every volume declare what its data is worth, chapter 10 says what each class derives, and nothing derives it. No adapter read the field, and the three values a backup needs — when, how many to keep, where the off-cluster copy goes — had no declaring site anywhere in the model. The class was a label. It cannot be fixed with a snapshot: local-path has no CSI snapshot support and there are no VolumeSnapshot CRDs, so an application-level job is the only mechanism, which is why the missing pieces are a schedule and a command. 0077 splits the tuple on the contention test. A backup window is one node's IO on a cluster where every stateful Workload is pinned to the machine holding its PV, and an off-cluster destination is one remote target with one credential — both shared and finite, so 0004 makes them platform-assigned: the Cluster Context carries one policy per class, the volume declares only the class, and a volume needing different terms uses the override that already exists. The method is platform-owned too, keyed by engine and arriving with the pinned blueprint packs, because the alternative is an authored script and 0012 forbids an executable Asset — a backup.sh Asset is exactly what that decision refuses. The CronJob comes from the kubernetes adapter, whose kind it already is. The destination credential 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 they did not choose means a wrong path is found at 03:00. It is recorded in the projection and appears in the derived Vault policy. 0078 adds engine as layer-1 vocabulary — postgres, rabbitmq, valkey, files. Not runtime, which is instrumentation and is correctly none for platform-postgres, and which R22 already records as overloaded. Not inferred from the image alias, where a swap would silently change what the platform thinks it is backing up. It states a fact about the process, so it stays inside the layer-1 rule, and it is the field R7's database catalog and R23's reload-versus-restart question also needed and could not ask for. R5 closes. Chapter 60's restore rehearsal finally has something to rehearse.
… with no signal is refused R6: 0021 says receivers, notifier routes, Gatus checks, ServiceMonitors and PrometheusRules all derive from two declarations. Three Services declare three different Alert Classes and all three produce zero objects. The estate has exactly one PrometheusRule, and Gatus monitors 41 endpoints while notifying nobody — its ConfigMap has storage and ui and no alerting section at all. 0079 puts the rules in a platform catalog, because PromQL is a mechanism: a baseline set keyed off scrape (target absent, restart loop, probe failure), engine-specific rules where the catalog has them, and severity plus receiver from the class — which is what the class already claims to be, urgency and never routing. A receiver is a shared channel, so 0004 makes the mapping platform-assigned, and it is one mapping feeding both producers: page means the same thing whether the signal came from a scrape or a Gatus check. The refusal is the load-bearing half. platform-postgres declares page, the loudest value in the vocabulary, and produces no monitoring object at all because Gatus derives from exposure and a datastore is correctly not exposed. So the estate's most urgent Service is wired to nothing and nothing says so. E_ALERT_CLASS_WITHOUT_SIGNAL refuses a class above none with no scrape and no exposure. A warning would not do: in a one-maintainer estate a warning is a log line, which is how 41 endpoints came to notify nobody. Monitoring gets one owner. A PrometheusRule without release: metrics-stack is accepted, goes Ready, logs nothing and never evaluates — so that label rule belongs to one producer, and ServiceMonitor and PodMonitor move to the new prometheus adapter with PrometheusRule rather than staying in kubernetes, which is already the largest adapter by kind count. R25 closes with it: scrape interval and scrapeTimeout are stated in the Cluster Context and named on every emitted monitor, so the metrics stack's global default stops being an input nobody declared. 0052 amended to twenty. R6 and R25 close; the first render after this will fail for platform-postgres, which is the defect surfacing rather than a regression.
…e credentials R7: chapter 16 lists "a database and owning user per consumer" as an inbound derivation and names the evidence — 98 lines of init-databases.sh creating auth_db, agents_db, knowledge_db and n8n_db, one per Service claiming a Postgres credential, all four of which the edge set already knows. Nothing produces them, and chapter 10 refuses the obvious vehicle because an Asset may not be executable. 0080 applies the split 0077 established: the render emits data, the platform owns the method. A ConfigMap names each consumer's database, owning user and Vault role; the image and command that apply it come from the engine catalog with the pinned blueprint packs. Rendering the script instead would satisfy 0012's letter while putting a procedure in the render surface, where a change is a diff nobody can validate except by running it against a live database. Credentials are issued rather than stored. Vault's database secrets engine mints one per role and vso already emits the VaultDynamicSecret that projects it, so no password is rendered or rotated by hand — the class of secret this estate holds most of, and the one 0028 is most exposed by. Recorded as claim: open, with an owner, for an honest reason: the issued credential lives at database/creds/<role> and no grant in the model can name that path. R20 records exactly that mismatch, and choosing dynamic credentials makes that row blocking instead of latent. The static-password alternative would have hidden it. R7 is decided and not yet renderable; R20 is now the next thing in its way.
… it fits R8: chapter 10 said storage class and volume capacity do not appear because they draw on finite node disk and are assigned. Half of that was right, and the wrong half rendered storage: null — a PVC that parses and cannot apply. Capacity is contended, so 0004 makes the platform arbitrate it, but nothing in the pinned input set said how much any volume needed and no rule allocated from the disks[].usable_gib the node contract publishes. An arbitrator with no request to arbitrate assigns nothing. 0081 uses the shape 0061 already established for the other contended quantities: the owner states a hard dimension and the platform decides eligibility. 20Gi of vault clone is a property of the data; whether a node can hold it is a property of the estate. E_STORAGE_UNSATISFIABLE is the storage twin of E_PLACEMENT_UNSATISFIABLE. A size class per engine or Durability Class was the alternative, and this estate has priced that mistake twice already — the health-timeout class contradicted a declared budget inside one Service, and rollbackTargetRetention was a value every Service declared identically and no renderer read. placement.disk.size becomes derived, the sum of the Workload's volume sizes, because the same quantity was otherwise authored in two places and could disagree with nothing detecting it. disk.media stays authored: which media a Workload needs is not implied by how much it needs. R8 closes. The example domain files still need the new field, which lands with the comment-strip pass.
Every Closed row decided something the worked examples predate, so the three domain files and the Cluster Context inputs are now behind the model: engine and size on volumes, placement.disk.size removed, a signal source for platform-postgres, and six Cluster Context inputs that did not exist when the examples were written. Listed rather than fixed row by row, because the same files are also losing their commentary and both edits should be one pass.
…s from the gid R9: chapter 10 said the non-root control renders runAsNonRoot: true "with the UID from the image". That was not a derivation — the lock resolves an alias to a digest and records nothing about the user, and 0006 forbids reading a registry at render time, so the pod rendered with runAsNonRoot: true and no UID. Three failures share the missing field. A named USER cannot be verified as non-root by the kubelet, so the pod fails CreateContainerConfigError with nothing pointing at the cause. A freshly provisioned local-path directory is root-owned, so a non-root pod with no fsGroup cannot write its own PV — platform-postgres cannot initdb, blocked by the hardening class it is meant to satisfy. And the model had no way to state the UID at all: a mechanism in layer 1, and no pinned input in layer 2. 0082 puts it in the lock, which is the right place because the lock is an output built by a step that legitimately talks to a registry: the same step that resolves a digest reads the image config's user. runAsUser, runAsGroup and fsGroup then derive from a pinned input like everything else, and E_IMAGE_USER_NOT_NUMERIC moves the named-user failure from the kubelet to lock time, where it has a name. fsGroupChangePolicy: OnRootMismatch comes with it, because the default re-chowns the whole volume on every start — minutes of startup no startupBudget accounted for. The init-container chown that most charts use was refused for a specific reason: it needs a root-capable container on every stateful Workload, which is a hardening exception per Workload, and chapter 10 keeps that inventory precisely so its length stays visible. R9 closes. Nine of eleven; R10 and R11 remain.
…is v1's policy stage R10: auth-ui declares port 80, takes restricted, relaxes only writableRootFilesystem, and renders a pod that cannot bind its own port — non-root plus drop ALL removes CAP_NET_BIND_SERVICE. The render is internally consistent and the workload cannot start. 0083 refuses it: a provides port below 1024 without the control relaxed is E_PRIVILEGED_PORT_UNDER_NONROOT. The escape needs no new vocabulary — allow: capability:NET_BIND_SERVICE already requires a reason and already lands in the exception inventory, which chapter 10 keeps explicit so its length stays visible. Deriving the capability silently would re-add what the class dropped for every Workload with a low port, and the inventory would stop recording it. auth-ui's answer is 8080: a route names a surface, not a number, so the container port is invisible to consumers. R11 was blocking the wrong thing. networking.k8s.io/v1 has no audit mode and kube-router has none, so an audit stage needs a CNI nobody has picked — but what v1 owes is the model and a producer, not a loaded policy set. 0084 states that render-only is v1's stage, with 0036 as the single gate on promotion. Blocking v1 on a CNI lab evaluation is what 0059's stopping rule exists to prevent, and emitting policies that select nothing would be a policy that lies — the audit diff would compare flows against objects never in force. The honest cost is stated rather than mitigated: through the render-only stage the estate is segmented on paper and open east-west in fact. That was already true; what changes is that it is a stage with an exit criterion instead of a gap row. Both blocking sections of RENDER-GAPS are now discharged: nine closed, R1 and R11 reclassified, R7 decided and waiting on R20.
…etadata sibling R19, R20 and R21 were one defect: the grant has one shape, and the four access tiers are KV intents wearing the name of a general privilege vocabulary. Over transit each cell of that table is a guess — self-roll derives patch, and patch permits neither transit/keys/<name>/rotate nor transit/sign/<name>, which is what auth-api's live JWT key needs and has never had. Over the database engine there is no capability to choose, because the engine mints the credential. 0085 makes engine the discriminator, defaulting to kv so every grant already written stays valid. A kv grant is unchanged. A database grant names a role and takes no tier. A transit grant names a key and a closed operations set — sign, verify, encrypt, decrypt, rotate — each mapping to exactly one Vault path, so the policy derivation is a lookup rather than a translation. Declaring raw Vault capabilities was refused: capabilities are a mechanism, and 0025 exists so the author states intent once. The half that actually closes R20: every grant derives a read path, and 0027's join key becomes that derived path. For kv the two strings are identical, so no placeholder and no byte-equality check changes; for database it is database/creds/<role>. One rule, one string, no mount rewrite and no engine taxonomy inside the comparison — the property that made the join checkable. 0086 closes R21. KV-v2 splitting one document across secret/data/<path> and secret/metadata/<path> is an engine detail, not two objects with two reader sets, and denying the version list to a principal that already holds every value protects nothing while making a rotation unauditable. One declaration derives both stanzas. Soft-delete stays out: it is a write, and that distinction is what the tier table is for. 0027 and 0025 are amended in place. 0080 settles: the database catalog is renderable now that a grant can name the path its credential comes from.
…henticates R13: automountServiceAccountToken is underivable, and the obvious rule is wrong. "No grant, no token" gets platform-postgres backwards — it holds a grant and needs no token, because under delivery: env the VSO operator performs the Vault read and under delivery: file the kubelet projects it. In neither case does the pod present anything to anyone. 0087 derives it from delivery and nothing else: only delivery: self means the pod itself authenticates with its own token, which is the entire content of the word self. Everything else gets false, so a credential stops sitting in container filesystems that never use it — the thing an attacker reads first. Same shape as 0075, applied to the token rather than the Role: the privilege a Workload of this estate needs is smaller than the Kubernetes default, and refusing to render the default is what makes that visible. A default of false with an authored opt-in was the alternative, and it is a second authored field for something delivery already implies — with a forgotten opt-in surfacing as a runtime authentication failure. agents-api is the case the rule deliberately does not cover: it calls the Kubernetes API rather than Vault and may hold no self grant. It restates the derived value with a reason, which 0031 provides for exactly this. Deriving from 0075's ledger entry instead was refused — that would make editing a review artifact silently change what is rendered. R13 closes. Seven model holes left: R14, R15, R16, R17, R18, R22, R23.
… platform policy
R14: startupBudget gave the startup probe its period and failure threshold, and
nothing said which endpoint it polls — so a renderer picked one, which is a
decision taken during serialisation and chapter 30 forbids exactly that. The
worked projection shows the hole: startup: {periodSeconds: 5, failureThreshold:
120} with no target. Readiness and liveness cadence was worse — derived nowhere,
hand-written identically in all four first-party deployments, the reasoning left
in comments no tool can read.
0088 targets the liveness endpoint, on 0014's own argument. Exceeding a startup
probe's failure threshold kills the container, the same consequence a failing
liveness probe has, so pointing it at readiness reproduces precisely the defect
0014 exists to prevent: a dependency goes down, readiness fails, startup never
succeeds, and the pod crash-loops on someone else's outage. That the wider
convention points startup at readiness is not evidence against it — 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, because
there is nothing safe to poll, and its start is bounded by the progress deadline
alone. Narrower and honest; the alternative is inventing a target.
Cadence goes to the Cluster Context for the reason scrape timing did: one
operational default shared by the estate, stated so a render is a complete
description of how a pod is checked. initialDelaySeconds is 0 on both, because
the startup probe already gates them. Deriving cadence from startupBudget was
refused — cold-start duration says nothing about steady-state polling frequency.
R14 closes. Six left: R15, R16, R17, R18, R22, R23.
… over a single replica R16: two derivations existed and neither was compared with the other. replicas was said to derive from minAvailable bounded by the eligible node set, and the PDB was minAvailable serialised. For auth-api the eligible node set is one node, so the live two replicas are not reproducible from any input — and minAvailable: 1 against replicas: 1 permits zero voluntary evictions, so draining that node blocks forever. On this estate that node is also the control plane. 0089 grades minAvailable by deleting it, against 0002. 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 by construction. No rescheduling, no control-plane HA, and two replicas on one node are two processes on one kernel. A field meaning "how many pods must stay up" cannot be honoured where the answer is decided by which machine is running. The estate's own case proves it: auth-api's two replicas were a capacity decision on freed Frankfurt budget, not an availability requirement. That is now an override with a reason, which is the truthful encoding of what it always was. The field is not lost; the pretence is. The PDB is emitted only above one replica, as maxUnavailable: 1. That removes the deadlock class entirely, lets a drain always make progress, and does not need recomputing when a count changes. Emitting none at all was close but wrong: a two-replica stateless Workload across seven nodes does benefit from not losing both to one drain. Chapter 10's "still to be graded" list is down to one item, self-renew x file. R16 closes. Five left: R15, R17, R18, R22, R23.
…ster
R18: E_UNRESOLVED_SERVICE already refuses an edge naming a Service absent from
the union, so the silent case was subtler — an edge naming something the estate
has and the model does not deploy. {service: stalwart, surface: smtp} resolved to
nothing, derived no coordinates and therefore no egress rule, and produced a
valid policy that is short one rule. The on-call sees a timeout; no gate goes
red. The absence is invisible precisely because default-deny makes absence the
enforcement mechanism.
0090 gives an edge two namespaces: Services in the composed union, and 0019's
Registered Unmanaged Surfaces — samba as a NixOS module, wolf deployed in
neither target, host daemons with no cluster presence. A target in neither stays
E_UNRESOLVED_SERVICE.
What the register lacked is coordinates. An unmanaged target has no pod selector,
so an entry an edge points at now carries the address it answers on and its ports
keyed by surface, and one without them is
E_UNMANAGED_SURFACE_WITHOUT_COORDINATES. They are platform data by 0004, so
moving stalwart to another host is one context republish rather than an edit per
consumer — the AUTH_CORS_ALLOWED_ORIGINS shape chapter 16 already cites as a
derivation waiting to happen.
Chapter 16 also stops accepting a cost it no longer pays: a surface typo is
E_UNKNOWN_SURFACE and an unresolvable target is E_UNRESOLVED_SERVICE, so a
rendered policy can no longer be silently short a rule with every gate green.
What stays silent is a flow nobody declared, which is the audit stage's job.
One asymmetry recorded deliberately: such an edge derives egress and no ingress,
because the provider is not a pod this model selects.
R18 closes. Four left: R15, R17, R22, R23.
…laceholders
R22: delivery: self was specified as deriving "a Vault policy, a Kubernetes auth
role, and the application's own client wiring". The third cannot be derived. For
auth-api it is four spring-cloud-vault variables, and producing them requires
knowing the process is Spring Boot — which runtime: jvm does not say and must not
be made to say. runtime selects the Runtime Profile; crossing it with frameworks
multiplies it by every library the estate adopts.
A secretClient field with a platform catalog was the other real option and loses
on the same ground as a neutral IR: each entry is the model guessing at someone
else's configuration surface, and the taxonomy grows whenever a repository
changes library. 0078 added a field for what a process is, because the platform
must act on it 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 in the Workload's own env file, in the repository where the
framework knowledge lives. That leaves the real problem: one of those lines is
VAULT_KUBERNETES_ROLE: auth-api, and the role name is derived. Written as a
literal it is exactly the staleness that produced the serviceAccountName()
defect, where a hand-maintained name and a derived one disagreed until two
Workloads shared a principal.
0091 adds ${identity:...} as 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. A
literal where a placeholder belongs is already a build error, so the drift
closes.
Restricting it to vaultRole alone was refused: the next self-delivering Workload
writes its namespace as a literal, which is the same defect one field over.
R22 closes without the model learning what a framework is. Three left: R15, R17,
R23.
… an exception R15: readOnlyRootFilesystem does not mean nothing writes. A JVM needs /tmp; nginx needs /var/cache/nginx and /var/run before serving a request. The model could not say so, which produced two defects. The first is undeclared behaviour: auth.domain.yml states 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 is a property of an adapter rather than of the model. The second corrupts a control the estate depends on. auth-ui relaxes the whole readOnlyRootFilesystem control to get two writable directories, and its own reason predicts the fix: the exception retires when a rebuilt image relocates both paths onto a mounted emptyDir. Under 0092 no rebuild is needed and the exception retires now — which matters beyond one Workload, because chapter 10 keeps the exception list as the inventory of what the estate cannot harden, and an entry for something that is not an exception makes the inventory lie. So writablePaths is authored, each path derives an emptyDir, and the control stays true: the image's filesystem is immutable and what a process writes is mounted, which is what the control always meant. A writable path gets no inventory entry. sizeLimit is platform-assigned from one Cluster Context default, because ephemeral storage is finite node disk. Authoring a size per path is 0081's shape and is right for a volume whose size is a property of the data — wrong here, where a temp directory's size is the node's tolerance. Nothing is implicit: /tmp is not supplied unless declared, because an implicit mount is exactly what this row exists to remove. R15 closes, and the exception inventory shrinks for the first time. Two left: R17, R23.
…e route is refused R17 was two-thirds stale: chapter 10 now requires name on every exposure and path plus match on every route, and 0072 settled the label set with no hostname label. What survived is the part nobody had to think about because it happens to work. auth declares /api and / as prefixes on one host, and /api/foo reaches auth-api rather than auth-ui because Traefik sorts matching rules by rule length and then by name — behaviour of one proxy at one version, documented nowhere in this model, and load-bearing for the estate's most common exposure shape. Which route serves a request is a routing decision, and by 0005 a decision that determines behaviour is one the model makes. 0093 derives precedence from specificity — exact before prefix, longer prefix before shorter — and renders it explicitly. The document then says what the edge does, an ordering change appears in a diff, and a proxy that tie-breaks differently changes nothing. That last point is not bookkeeping: layer-1 files name no Kubernetes kind precisely so a substrate swap is a migration rather than an undo, and inheriting routing semantics from a proxy's internals is the same trap one layer down. Refusing overlaps was the other route to the same place and refuses the normal case: /api beside / is the standard split, and an overlap is exactly what it is. E_DUPLICATE_ROUTE handles the pair with no correct interpretation, one level down from E_DUPLICATE_HOST. Declaration order was refused: it would make YAML list order semantic, which nothing else here does, and a reordering diff would change routing silently. R17 closes. One left: R23.
… is deleted R23: onChange had two values and one meant nothing. restart already rendered a content-hashed object name — necessary, since 16 of 18 live ConfigMaps are plain, so an edit applies and never reaches the pod. reload had no mechanism: Kubernetes has no primitive that reloads a process, no image here watches its config file, and signalling one needs the API access 0075 refuses. Three ways to give reload meaning were priced. A hash-named Job running the engine's reload command over the datastore's own protocol would work and needs no Kubernetes RBAC, at the cost of a derived object, a catalog entry and a credential path. A reloader operator adds an operator the substrate lacks and restarts anyway. Watching the file is the image's job and no image does it. So reload is deleted, and with it the field: a one-value enum is a label an author types, which is 0078's rule applied to this vocabulary. Propagation is now unconditional for every Asset instead of a promise kept by the ones that asked. The cost is stated rather than hidden: with replicas: 1 and Recreate, editing one line of postgresql.conf restarts platform-postgres. That is what an Asset edit costs on this substrate. rotation.tolerates: reload on a secret survives deliberately — there the actor exists, the client library re-reads under delivery: self, which is why delivery: env with tolerates: reload is already E_ENV_CANNOT_RELOAD. Same word, different actor; CONTEXT.md now says which is which.
The property already existed and was one clause inside a paragraph about delivery: self. Replacing a secret with no downtime is a requirement for the API, so it gets a named section, a table of what each combination costs, and a pointer from chapter 20's derivation of restart targets. Nothing changed in the model. delivery: self with rotation.tolerates: reload rotates with no restart, which is what auth-api runs today; file with reload does the same for a consumer that watches its projected file; env cannot, because a pod's environment is fixed for its lifetime, and E_ENV_CANNOT_RELOAD refuses the claim at schema time rather than letting it fail at rotation time. Chapter 20 now records the derivation that makes it true: a self grant tolerating reload derives no rolloutRestartTargets entry.
…r Context
The three worked domains predate twenty-four decisions. Layer 1 now matches:
- auth-api declares writablePaths: [/tmp] instead of the render supplying it
implicitly, which was behaviour no chapter specified; its minAvailable is gone
and the live second replica is an override carrying its capacity reason.
- auth-api's transit grant takes the union shape: engine, key and a closed
operations set, because patch permits neither rotate nor sign.
- auth-ui listens on 8080 rather than 80, since restricted drops
CAP_NET_BIND_SERVICE, and a route names a surface rather than a number. Its
writableRootFilesystem exception is deleted in favour of declared writable
paths — the first entry to leave the exception inventory.
- postgres declares engine: postgres, a volume size, and writable paths instead
of its own exception; placement.disk.size is gone because the quantity is
derived from the volumes. Its Asset loses onChange.
- rabbitmq and the knowledge vault clone declare engine and size; valkey
declares neither engine nor a backup, because reconstructible derives none and
an engine there would be refused.
- auth-api's env file writes spring-cloud-vault's own wiring as literals and its
role as ${identity:vaultRole}, so the framework stays the repository's
business and the derived value cannot drift.
The Cluster Context is new, and it is the file the gap list said the example set
did not carry. Every block is a value some decision put on the platform's side
of the contention test: tiers with forward-auth endpoints, one durability policy
per class, backup methods per engine, the receiver mapping and rule catalog,
probe cadence, the ephemeral default, and the unmanaged register with
coordinates for stalwart's SMTP surface.
Two values in it are deliberately unflattering, because they are what the estate
has: secretsEncryption: false, which makes every env and file grant fail
E_SECRETS_AT_REST_REQUIRED, and cni: flannel, which keeps default-deny
render-only. Both are the gates working rather than the example being wrong.
…the decisions The rendered trees were 47% comment: 1,835 of 3,862 lines. They are now comment-free — one GENERATED header line per file and then the object — and 2,104 lines. Every strip was verified by parsing the file before and after and comparing the documents, which caught the one case that mattered: vault.yaml's `hcl: |` block scalar contains # lines that are content, not commentary. The commentary did not vanish. Each rendered/README.md already carried the per-file attribution table the headers duplicated, and each now states what this pass changed and points at RENDER-GAPS.md for row-by-row status rather than restating it in three places. Re-rendered against the decisions, so the trees now carry: - the fixed label set (0072) — instance is the Workload, not the Service, and component carries the runtime; selectors updated to match - automountServiceAccountToken (0087), false everywhere except auth-api, the one Workload whose grants are delivery: self - runAsUser, runAsGroup, and fsGroup with OnRootMismatch where a volume is held (0082), which is what lets postgres initdb under the hardening class - a startup probe pointed at the liveness endpoint and one probe cadence (0088) - an emptyDir per declared writable path at the platform's ephemeral size (0092), replacing auth-api's undeclared tmp mount and auth-ui's blanket exception - explicit route priority (0093) rather than Traefik's rule-length sort - PVC storage sizes (0081), so no PVC renders storage: null - a PodDisruptionBudget only above one replica, as maxUnavailable (0089), which leaves auth-api's — it has an override for two — and drops the deadlock shape And the objects that had no producer before this week: - apps/vso-secrets/policies/*.policy.json and *.role.json (0073), replacing auth's ad-hoc vault.yaml: JSON per Workload identity, KV reads carrying their metadata sibling (0086), transit as sign plus rotate paths (0085) - edge/middlewares.yaml (0076) — the forward-auth and security-headers objects every IngressRoute in the estate has been referencing and nothing emitted - observability/prometheusrules.yaml (0079) — baseline rules per scraped Workload, engine rules from the catalog, severity and receiver from the class - apps/*/backup.yaml (0077) — the backup and sweep CronJobs a Durability Class derives, with the off-cluster credential for the two irreplaceable volumes The policy JSON is deliberately not in any kustomization: nothing kustomize applies writes Vault configuration, and who does is delivery.
…D schemas "Fifty rendered files. All parse." was the old bar, and it is the bar that let R8 through: a PVC with storage: null parses and cannot apply. lint:manifests runs kubeconform over every rendered example against the schemas for the k3s version the worked Cluster Context records, plus the CRDs the estate actually uses. All 84 objects across 55 files validate with zero skipped — Traefik IngressRoute and Middleware, VSO's secrets, ServiceMonitor and PrometheusRule, and the kustomizations included. Two details worth keeping: - Zero skipped is the point. With -ignore-missing-schemas alone, 34 of the 84 objects were skipped and reported as success — every CRD in the estate, which is most of what the new adapters emit. The CRD catalog is a second schema-location, so a Traefik object with a misspelled field now fails. - The wrapper skips loudly when kubeconform is absent, the way the boundary gate does, and CI installs a pinned release rather than curl-pipe-bash from a default branch (deferred/0048's own finding). Nothing here talks to a cluster: schemas are fetched, state is not read.
0060 carries superseded-by: 0062 and claim: open with an owner, which reads as outstanding work. It is not: the decision was replaced before its settling test ran, so the claim records the state it was in when superseded. 0062 owns the question now. The owner stays named because the contract requires one for any claim other than settled, not because there is a task.
… DSL owns everything The direction: the DSL owns everything, and nothing authored is Kubernetes-shaped. An architecture review of the model found three defects that are one missing module, and this commit adds it. Chapter 14 — Platform Intent. Ten decisions in one week had added fields to "the Cluster Context", a document with no chapter whose only schema was a hand-written example. It is authored, not observed, so it is layer 1 and held to the layer-1 rule: facts and policy, never mechanisms. The contention test decides which of the two authored documents a value lives in. It publishes as an Intent Fragment like any domain and is a required participant under the same seven-day bound; the side channel is gone. Five decisions: - 0095 Platform Intent is the second authored document. Providers the estate depends on are facts in it, resolvable by an edge; the unmanaged register keeps only hostnames nobody deploys and nobody depends on. - 0096 The foundation is declared. Vault, VSO, the two Traefik instances, Prometheus and Gatus are Services in domain files the platform owns. No chart is rendered: a chart's defaults are what a declaration replaces and its CRDs are the bootstrap set. Blueprint packs and the raw-manifest pass-through are gone — 41 objects and an unbounded escape hatch that no invariant ever saw. Supersedes 0013. - 0097 An authored value names a model concept. Overrides address a derivation by its own name from a closed table, never a Kubernetes field; tiers declare four edge facts and the traefik adapter owns every Traefik spelling; the backup method IS an image, so nothing executable is authored; substrate facts are named for what they are, never as a k3s flag. Linux capability names in hardening exceptions are kept deliberately — the kernel is not the substrate. - 0098 One publication path. A repository publishes its Intent Fragment and nothing else; the five publish-time producers and their compat map are deleted, the estate-scoped catalogs become inbound derivations of the platform Services that consume them, image metadata becomes a projection, flux-root moves to the deferred set, and the two tier adapters plus the middleware producer fold into one traefik adapter. Six adapters remain, all central. 0052 is rewritten to record the rule — the registry is the enumeration — rather than a count that was amended four times in a week. The Fragment collision closes: the output unit is a Deliverable everywhere. - 0099 The bootstrap set is a recorded, enumerated table: k3s, the Flux source, Vault's unseal, the CRDs. Open, because its settling test is a fresh-cluster stand-up that 0057's restore rehearsal already owes. Jellyfin's LAN-only traffic, which may not be proxied through Frankfurt, now rests on facts an author can see — 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 — rather than on which of two adapters ran. Old ADRs rewritten or amended to agree: 0013 superseded; 0052 rewritten; 0031, 0038, 0053, 0054, 0055, 0057, 0059, 0076, 0077, 0079, 0090 amended; 0016 records why capability names stay; architecture 0065 amended for a publish use-case that renders nothing. Chapters 00, 10, 16, 20, 30, 40, 50 and 60 updated throughout; chapter 30's registry, role table, path allocation, coverage and determinism sections rewritten, and its open items reduced to the one that is a model question. CONTEXT.md gains Platform Intent, Tier, Provider, Bootstrap set and the foundation, retires Cluster Context, and resolves Fragment to one meaning.
… estate-scoped objects The Cluster Context example becomes spec/v1/examples/platform/platform.intent.yml in chapter 14's shape: substrate facts by name, the bootstrap set, tiers as four edge facts plus the Traefik Service each tier is, engines as image aliases, providers with coordinates. No entryPoint, no certResolver, no shell command, no k3s flag. Its README says which block each decision put on the platform's side, and what is deliberately not in it. edge/middlewares.yaml and observability/gatus-endpoints.yaml leave the three tenant trees: the Middleware set is emitted per tier into the platform edge domain, and the Gatus endpoint list is an inbound derivation rendered as the declared gatus Service's own Asset. A tenant contributes routes and exposures to both and owns neither. Each rendered README says so. auth's override names its derivation rather than a Kubernetes field. The manifest gate validates all 75 remaining objects with zero skipped, and gains four fixtures of its own — skip, pass, fail, empty tree — because a gate with no negative test is the failure mode this repository keeps finding.
…e work Twenty-three open items across five chapters mixed three kinds, so "open" carried no information. Each now has one fate. Decided and struck, with the deciding ADR named: chapter 16's "nothing enforces the rendered policy set" (0084 makes render-only the stage), chapter 50's "atomicity is untested" (0060 is superseded; the test is 0071's), and the three adapter items chapter 00's coverage entry carried (deleted by 0098, on the path plan by 0070, Assets by 0096). Settling tests, moved onto the ADR whose claim they settle rather than kept as chapter items: the schema range's determinism (0039), the seven-day bound (0038), the ClusterState digest's stability (0034), Release Unit atomicity (0071). A failure there flips a claim; as a chapter item it flipped nothing. Delivery's, moved to the deferred set: whether a Release Unit may span an ownership boundary. Deduplicated: fleet over-subscription was open in chapters 20 and 40 with two owners; chapter 20 owns it and chapter 40 points there. What remains open is only what needs a decision: the CORS predicate, the drift check's failure mode for upstream staleness, the allocatable reserve, fleet over-subscription, the union spanning clusters, fragment signing, three images for the ConfigMap-hosted scripts, label prefix retirement, and the class-A number.
Every remaining mention of packs, producers, the compat map or Traefik's tier spelling in a normative or decision text is either rewritten or marked as the state a decision was taken in: chapter 30's Vault fixtures are Assets of the declared vault Service; 0073, 0018, 0011 and 0029 carry amendments; the render-gap list records that its missing-inputs section is discharged by the Platform document; and the knowledge tree's README names where each estate-scoped object now renders. Rendered IngressRoutes still say entryPoints and certResolver, correctly: that is the traefik adapter's spelling of a tier's listener and certificates, and the adapter is where Traefik's words belong.
… example Every chapter diagram is now a draw.io drawing committed beside its chapter as an SVG with the editable diagram embedded: GitHub renders it inline, and opening the same .svg in draw.io recovers the drawing, so there is no second source file to keep in step. The mermaid each chapter used to embed is kept at the foot of the chapter under "Diagram sources" as the text form of the same structure — a diagram change still shows in a plain diff, and where the two disagree the SVG is the diagram and the mermaid is what gets fixed. Thirteen drawings for twelve former fences. One palette runs through all of them, so a box's colour says which layer it belongs to without a legend: blue Service Intent, indigo Platform Intent, slate a pinned input, amber a layer-2 decision, green a layer-3 Deliverable, red a refusal, dashed border defined separately. Orthogonal connectors only, no curves and no diagonals. The class diagram follows UML properly: Domain at the top, Service below it, Workload below that, and every composite hanging off the Workload that declares it, with filled diamonds for composition. All eighteen closed vocabularies are separate «enumeration» nodes in one panel rather than inline lists, each naming the class it belongs to. Chapter 16's derivation map is two drawings rather than one. As a single frame it is about ninety edges, which no layout makes readable; split, each half also carries one of the two properties — totality for assignments, in-degree at least one for Deliverables. An edge that runs straight from a declaration to a Deliverable is drawn in grey and passes behind the layer-2 column. Three details that keep the SVGs honest: labels are plain text with manual line breaks, which is what makes the export real searchable text rather than an embedded raster (the first attempt was 2.3 MB of base64 PNG for one diagram, now 231 kB of text); groups are emitted before nodes so they do not paint over them; and the whole set is 752 kB. examples/minimal/ is new and is the file to read first: one domain, one Service, one Workload, and no field that is not required — 26 authored lines reaching 10 objects across 5 of the 6 adapters. Its README tabulates what each declared line buys and, more usefully, what is absent and which decision makes it absent: no PDB (0089), no PVC or backup (0077), no Vault policy (0073), no RBAC (0075), no writable path (0092), no Middleware of its own (0096). It is also the only worked example that renders on today's pinned inputs. The other three each hold an env or file grant and secretsEncryption is false, so they all fail E_SECRETS_AT_REST_REQUIRED; this one holds no grant, so the gate has nothing to refuse. All 85 rendered objects across the four sets validate against pinned Kubernetes and CRD schemas with zero skipped.
Rework the layouts so relations run in empty space rather than over the shapes: - Class diagram: root at the top, one band per generation, and every parent-to-child connector routed through the gap between two bands. Class boxes now carry a filled background, and the name-reference dependencies run in their own lanes below the tree. - Meta-model: the two authored documents sit side by side on one row, so no riser passes through a neighbouring box; the prose moves above the layer-1 group and the resolved.yml feedback edge enters from below. - Resolved Deployment I/O: the published-back edge routes outside the pinned-input group instead of straight down through all six inputs. - Composition run: wider stage spacing so each arrow sits in a gap. - Release Unit switchover and the end-to-end change: the hold and the delivery branches get their own lanes rather than passing through the readiness and switchover boxes. - What an edge derives: a top-down tree with the "required: false" edge routed clear of the other three. - Worked exposure trace: one lane per target for the host bus and the tier edges. - Reconcile Unit DAG: the vso-to-agents edge routes above the graph. Edges also draw an arc where they cross another edge, so a crossing now reads as a hop rather than a junction.
The last pass was eyeballed, which missed real overlaps. Add a check that parses the exported SVG itself — every filled path or rect is a shape, every stroked path is a connector — and reports each straight run that crosses a shape it is not attached to. It now reports zero over all thirteen drawings; before this commit it reported the two below. - The layer-1 class diagram ran the placeholder-to-grant reference lane up through the Rotation box, because Rotation sits in Grant's own column. The lane now runs up the left margin and enters Grant from the side. The band gap also grows from 54px to 174px, so the Workload bus has clear air on both sides and each multiplicity label sits on its child's riser rather than on the bus. - The Deliverables half of the derivation map ran twenty declaration-to-object edges straight through the layer-2 column. The Deliverable column now sits in the middle, with declarations entering from the left and assignments from the right, so no edge has to cross a column at all. Chapter 16 and the diagram README describe the new arrangement.
The two prose blocks explained tree direction and composition notation inside the drawing that uses them. A reader of a class diagram does not need the diagram's own notation narrated, so both are gone. The enumeration panel keeps its note, which states a rule about the model rather than about UML.
… trace Arrows between two twenty-five-row columns are indistinguishable however they are routed — the second attempt moved the Deliverable column into the middle to stop lines crossing boxes, and the result still read as everything reaching everything. Both halves of the derivation map are now matrices: a mark at a row-column intersection is one derivation, so there is no line to trace and nothing to cross. - Rows carry their layer's colour (declared, pinned, assignment) and the mark takes the row's colour, so a Deliverable's column shows how much of it is declared and how much is decided. - The `in` row and `out` column are counted from the same edge list as the marks, which makes totality and in-degree-at-least-one readable off the drawing instead of asserted beside it. A zero in `out` is annotated, not flagged: those rows reach an object in the other matrix. The exposure trace loses every jump. The host fans out from a trunk on the left of the Deliverable stack and the tier fans in from a trunk on the right, so no spur crosses another; the two IngressRoutes — the only Deliverables the tier touches — move to the bottom of the stack, and the tier sits below it. Chapter 16 and the diagram README describe the matrices.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
what this branch does
Closes the twenty-six blocking render gaps, adds the second authored document, and
puts gates under the parts of the model a reader could otherwise only trust.
The model was one authored document short. Everything the platform owns — tiers,
durability policy, engines, probe cadence, providers — had no home, so it kept
leaking into Service Intent or into prose. Platform Intent is now the second
authored document, with the contention test of
0004 deciding which of the
two a value lives in, and the foundation declared as Services like anything else.
decisions
Twenty-nine new ADRs,
0071–0099, one per gap, each pointing at the chapter thatstates it normatively. The ones that changed the shape of the model rather than
filling a hole:
0095–0099) — the second authored document, the declaredfoundation, authored values naming model concepts, one publication path over six
registered adapters, and the recorded bootstrap set.
0071) — the path plan is a derivation, sono renderer picks a filename.
0061, amended) — size became placement; avolume declares its size and the platform decides whether it fits.
by the delivery mode.
Old decisions were rewritten rather than left to contradict the new ones:
0013issuperseded,
0052records the rule instead of a count, and0060's open claim isrecorded as moot. Twenty-three open items across five chapters were reconciled into
three fates — decided, a settling test on the owning ADR, or code work out of the
spec.
gates
Four checks, all wired into
npm run verify, all with negative fixtures:lint:adrs— frontmatter, register integrity, qualified citations, and normativeanchors resolving against real headings.
lint:links— the ~100 non-ADR links the domain move rewrote.lint:manifests— every rendered object validated against Kubernetes and CRDschemas, zero skipped.
lint:boundaries— dependency-cruiser over the compiler's layers; it skips loudlyuntil
src/exists rather than passing vacuously.the drawings
Thirteen diagrams, authored as draw.io and exported with the source embedded, plus a
checker that parses the exported SVG — filled paths are shapes, stroked paths are
connectors — and fails on any run that crosses a shape it is not attached to. It
reports zero. Two rounds of eyeballing had missed real overlaps, which is why the
check exists. The derivation map is a matrix rather than a graph: ninety arrows
between two twenty-five-row columns are indistinguishable however they are routed.
The mermaid sources stay at the foot of each chapter, so a diagram change still shows
up in a plain diff.
verification
npm run verifyis green: lint, format, typecheck, 86 ADRs, 128 links, 85 renderedobjects with zero skipped, boundaries, and 63 tests. There is no compiler yet — this
branch is model, decisions, examples and gates only.