Skip to content

feat: Platform Intent, the twenty-six render gaps, and gates for all of it - #12

Merged
ExtraToast merged 42 commits into
mainfrom
feat/adr-domain-directories
Sep 8, 2026
Merged

feat: Platform Intent, the twenty-six render gaps, and gates for all of it#12
ExtraToast merged 42 commits into
mainfrom
feat/adr-domain-directories

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

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, 00710099, one per gap, each pointing at the chapter that
states it normatively. The ones that changed the shape of the model rather than
filling a hole:

  • Platform Intent (00950099) — the second authored document, the declared
    foundation, authored values naming model concepts, one publication path over six
    registered adapters, and the recorded bootstrap set.
  • Layer 2 assigns every output path (0071) — the path plan is a derivation, so
    no renderer picks a filename.
  • Placement is hard dimensions (0061, amended) — size became placement; a
    volume declares its size and the platform decides whether it fits.
  • Zero-downtime secret rotation — stated as its own section rather than implied
    by the delivery mode.

Old decisions were rewritten rather than left to contradict the new ones: 0013 is
superseded, 0052 records the rule instead of a count, and 0060's open claim is
recorded 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 normative
    anchors resolving against real headings.
  • lint:links — the ~100 non-ADR links the domain move rewrote.
  • lint:manifests — every rendered object validated against Kubernetes and CRD
    schemas, zero skipped.
  • lint:boundaries — dependency-cruiser over the compiler's layers; it skips loudly
    until 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 verify is green: lint, format, typecheck, 86 ADRs, 128 links, 85 rendered
objects with zero skipped, boundaries, and 63 tests. There is no compiler yet — this
branch is model, decisions, examples and gates only.

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.
@ExtraToast ExtraToast added type: feature New user-facing or operator-facing capability. area: platform Platform, deployment, cluster, or runtime concern. component: docs Runbook, README, API docs, or documentation tooling. labels Sep 8, 2026
@ExtraToast ExtraToast self-assigned this Sep 8, 2026
@ExtraToast
ExtraToast merged commit 1945903 into main Sep 8, 2026
3 checks passed
@ExtraToast
ExtraToast deleted the feat/adr-domain-directories branch September 8, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: platform Platform, deployment, cluster, or runtime concern. component: docs Runbook, README, API docs, or documentation tooling. type: feature New user-facing or operator-facing capability.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant