Skip to content

docs: chapter 50, lifecycle, and push delivery via aggregators - #63

Closed
ExtraToast wants to merge 3 commits into
docs/ch40-compositionfrom
docs/ch50-lifecycle
Closed

docs: chapter 50, lifecycle, and push delivery via aggregators#63
ExtraToast wants to merge 3 commits into
docs/ch40-compositionfrom
docs/ch50-lifecycle

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Chapter 50, plus ADR-0019 and revisions to ADR-0006, ADR-0014, ADR-0015 and chapters 30 and 40. Stacked on #62#61#60#59#58.

Replaces pull-based GitOps delivery with a push-based one gated by relationship-scoped aggregators.

1. a service repo merges     -> publishes its Intent Fragment. No PR.
2. any fragment publishes    -> composition runs, 26 invariants. No PR.
3. Renovate                  -> bumps the composed-lock pin in each aggregator
4. the aggregator's PR       -> vcluster; apply the composed estate; run its suite
5. green                     -> merge
6. on merge                  -> apply MY slice to production
7. hourly, in-cluster        -> re-apply MY lock; idempotent

The boundary was already measured

Chapter 30 split coverage into 364 intent-derived / 41 pack-delivered / 45 authored objects for entirely unrelated reasons. Delivery splits on the same line — and class B cannot move, for a concrete reason rather than an architectural one:

18 of those 41 objects are HelmRelease, and applying one with kubectl accomplishes literally nothing without Flux's helm-controller. Those charts are vault, vault-secrets-operator, metrics-stack, traefik ×2, cert-manager, external-dns, metallb and all observability — the Secret Store, the mechanism that delivers every secret under ADR-0005, Prometheus, every route, TLS, DNS and LoadBalancer addresses.

Rendering them to plain manifests was considered and rejected: it means owning 18 upstream charts' values, hooks and CRD upgrade paths, and vault plus VSO are in the set — so a bad render breaks secret delivery for everything.

Resolves chapter 30's class C

The 45 homeless Grafana objects divide by nature: 14 service-specific dashboards become Assets on the owning Service, 3 runtime-family dashboards (spring-boot-jvm, spring-boot-2.1, spring-service-endpoints) ship with the Runtime Profile alongside the OTEL_*/PYROSCOPE_* values it already injects, 14 platform dashboards ship in the observability pack, and service-overview/service-template become derived per Service from scrape, runtime and exposureservice-template becomes the renderer's template rather than a cluster object, which is what its name always implied.

Coverage can now reach 100%, which matters because the ledger's own header says "every entry is a deferred fix, not a permanent exemption."

What is given up, stated plainly

Pruning. Flux prunes because a Kustomization keeps an inventory; kubectl keeps none. So every applied object carries a deployer label and the delete pass is the difference between that query and the render. The cluster is the inventory — nothing to lose, nothing to resync, and no first-run special case: on adoption nothing is labelled, so the delete pass deletes nothing and adoption leaves orphans for the coverage assertion instead.

Continuous reconciliation. Replaced by an in-cluster CronJob, not a GitHub Actions schedule — CLAUDE.md measures why: "four to seven runs per repo per day regardless of the declared interval… Never build anything needing prompt reaction on a schedule alone, and do not raise the frequency to compensate." Drift correction is exactly such a reaction. A cluster CronJob is also the pattern vault-metrics-token-renewal already uses.

A single answer to what is live. Given up deliberately: forcing convergence would let one red aggregator halt the estate, which is the coupling aggregators exist to break. Lag is measured instead, and cross-slice removals go through expand/contract — checkable by composition, since it sees both sides of every inbound derivation. E_CONTRACT_TOO_EARLY covers the auth-api CORS case: removing an origin while it is still deployed at an older lock breaks production.

One property improves. Server-side apply reports a field-ownership conflict when a human edited a field the aggregator owns, rather than silently reverting it as reconciliation would.

Enforcement, not convention

The deploys list generates the aggregator's ServiceAccount and Role, so "deploy authority is exactly one" is enforced by the API server — a workflow applying a Service it does not own gets a 403. That makes the rbac adapter pay for itself twice: it was already 16 of the 36 objects in chapter 30's coverage gap.

Upstream changes

  • ADR-0015's rationale shifts rather than breaking. A merge is now required to deploy, but it is per-relationship rather than estate-wide, and exists to run tests rather than to record pointers. Composition still requires none.
  • ADR-0014's SystemTestProject becomes the deploy unit, gaining deploys alongside exercises.
  • ADR-0006's Reconcile Unit gains a second consumer: the Flux DAG for class B, the apply-order DAG for class A. Both read lock.spec.dependencyGraph.order, which apply-candidate.mjs already does.
  • Chapter 40 reaches 26 invariants and its open items 1 and 2 are resolved.

Most of the test stage exists

deploy-harness already has provision-vcluster.mjs, apply-candidate.mjs (server-side apply per DAG layer, route rewriting), the SC-6 triple-digest assertion, scoped Vault AppRoles per test namespace, CrashLoop and VSO verification, sharded execution with retry and transient classification, an owner-approved quarantine registry and guaranteed teardown. It is wired to one central compose gate rather than to N aggregators — the work is rewiring, not writing.

Four open items

Aggregator CI cost (~6 suites per change, against CLAUDE.md's measured billing shape of "561 minutes billed 2,845"); whether the test vcluster keeps Flux for class B; the lag bound's actual N; and what narrows deploy/production to the foundation, which ADR-0008 in the workspace currently describes.

Replaces pull-based GitOps delivery with a push-based one gated by
relationship-scoped aggregators. ADR-0019 records the decision and what it
costs; chapter 50 describes the pipeline.

The stages: a service repo merges and publishes its Intent Fragment, no PR. Any
publish triggers composition automatically, no PR. Renovate bumps the composed
lock pin in each aggregator. The aggregator's PR provisions a vcluster, applies
the whole composed estate and runs that relationship's suite. Green merges. On
merge the aggregator applies its own slice to production. An in-cluster CronJob
re-applies that slice hourly.

THE BOUNDARY WAS ALREADY MEASURED. Chapter 30 split coverage into 364
intent-derived, 41 pack-delivered and 45 authored objects for entirely different
reasons. Delivery splits on the same line, and class B cannot move for a
concrete reason: 18 of those 41 objects are HelmRelease, and applying a
HelmRelease with kubectl accomplishes nothing without Flux's helm-controller.
Those 18 charts are vault, vault-secrets-operator, metrics-stack, traefik x2,
cert-manager, external-dns, metallb and all observability -- the Secret Store,
the mechanism that delivers secrets, Prometheus, every route, TLS, DNS and
load-balancer addresses.

Rendering those charts to plain manifests was considered and rejected: it means
owning 18 upstream charts' values, hooks and CRD upgrade paths, and vault plus
VSO are in the set, so a bad render breaks secret delivery for everything.

RESOLVES CLASS C. The 45 Grafana objects divide by nature: 14 service-specific
dashboards become Assets on the owning service, 3 runtime-family dashboards ship
with the Runtime Profile alongside the OTEL and Pyroscope values it already
injects, 14 platform dashboards ship in the observability pack, and
service-overview plus service-template become derived per service from scrape,
runtime and exposure. service-template becomes the renderer's template rather
than a cluster object, which is what its name always implied. Coverage can now
reach 100%, which matters because the ledger's own header says every entry is a
deferred fix rather than a permanent exemption.

What is given up, stated plainly. Pruning: Flux prunes because a Kustomization
keeps an inventory; kubectl keeps none, so every applied object carries a
deployer label and the delete pass is the difference between that query and the
render. The cluster is the inventory, which means nothing to lose, nothing to
resync, and no first-run special case -- on adoption nothing is labelled, so the
delete pass deletes nothing and adoption leaves orphans for the coverage
assertion instead.

Continuous reconciliation: replaced by an in-cluster CronJob, not a GitHub
Actions schedule. CLAUDE.md measures why -- four to seven runs per repo per day
regardless of the declared interval, crons late by hours, and never build
anything needing prompt reaction on a schedule alone. Drift correction is
exactly a reaction to something having gone wrong. A cluster CronJob is also the
pattern vault-metrics-token-renewal already uses.

A single answer to what is live: given up deliberately. Each slice sits at
whatever lock its aggregator merged. Forcing convergence would let one red
aggregator halt the estate, which is the coupling aggregators exist to break. Lag
is measured instead, and cross-slice removals go through expand/contract --
checkable by composition because it sees both sides of every inbound derivation.
E_CONTRACT_TOO_EARLY covers the auth-api CORS case where removing an origin while
it is still deployed at an older lock breaks production.

One property improves: server-side apply reports a field-ownership conflict when
a human edited a field the aggregator owns, rather than silently reverting it as
continuous reconciliation would.

Four new invariants bring chapter 40 to 26: E_NO_DEPLOYER,
E_MULTIPLE_DEPLOYERS, E_CLASS_BOUNDARY_CONFLICT, E_CONTRACT_TOO_EARLY. And
chapter 40's open items 1 and 2 are resolved -- fragments publish on merge
independently of any image release, and composition runs automatically on any
publish.

ADRs 0006, 0014 and 0015 are revised in place, all still status: proposed.
ADR-0015's rationale shifts rather than breaking: a merge is now required to
deploy, but it is per-relationship rather than estate-wide and exists to run
tests rather than to record pointers. Composition still requires none.

Most of the test stage already exists in deploy-harness -- provision-vcluster,
apply-candidate with server-side apply per DAG layer, the SC-6 triple-digest
assertion, scoped Vault AppRoles, CrashLoop and VSO verification, sharded
execution with retry and transient classification, an owner-approved quarantine
registry, guaranteed teardown. It is wired to one central compose gate rather
than to N aggregators, so the work is rewiring rather than writing.
@ExtraToast ExtraToast added type: docs Documentation-only change. area: deploy homelab-deploy, deploy-v2, manifests, and rollout flow. component: config Configuration schema, defaults, or repo settings. labels Aug 31, 2026
…revisions

An audit found the docs had drifted behind the model. Fixed.

THE GLOSSARY WAS MATERIALLY STALE. It had been appended to but never revised, so
it still described superseded decisions:

- Claim Mode defined four modes -- env, fetch, file, write -- which the model
  no longer has. Replaced by two orthogonal terms: Access Tier (read,
  self-renew, self-roll, custody) and Delivery (env, file, self).
- Contended Value conflated two things chapter 20 separated. Split into
  Contended Value, drawn from a finite pool and assigned, and Identity Value,
  unique but not pooled, declared and checked. That distinction is what lets a
  Service Id and an exposure name be declared at all.
- Rotation Tolerance still said reload is achievable only under fetch. It is
  delivery: self.
- Exposure still said it carries no hostname. It carries a name, which is an
  Identity Value; the hostname is assembled from it.
- Reconcile Unit had one consumer. It now has two: a Flux Kustomization for the
  foundation, and the apply order for everything an Aggregator pushes.

Seven terms were missing entirely: Purity Rule, Placeholder, Composed Intent,
Composition Lock, Participant, Aggregator, Deployer. 39 terms to 47.

A NAMING CONFLICT I INTRODUCED. Chapter 50 declared kind: Aggregator while
ADR-0014 and chapter 40 said SystemTestProject. Unified on Aggregator, because
the thing deploys now and a test-only name understates it. SystemTestProject is
recorded under _Avoid_.

THREE ENV EXAMPLES REFERENCED A FILE THAT NO LONGER EXISTS. Their comments
pointed at secrets.yml, which was folded back into service.yml two revisions
ago. Now they name the level the grant sits at, which is the thing a reader
actually needs.

Also: the decision register described ADR-0005 as "Claims by Vault path; four
Claim Modes", and chapter 10's examples table cited only one of knowledge's two
env files.

Audit now clean: no broken relative links, no stale vocabulary, 7 example files
all parsing and all referenced, 8 mermaid blocks and 24 yaml blocks valid, every
ADR present in the register.
Adds the parts the specification described but never showed: the pipeline as
runnable workflows, the Aggregator as a real declaration, the two Deliverables
chapter 50 introduces, a negative fixture, and the setup and adoption path.

CHAPTER 60, setup and adoption. Bootstrap order, onboarding a Service,
creating an Aggregator, and adopting the ~30 live Services.

The adoption section is the one that matters, because getting it wrong deletes
production. Flux prunes, so removing a Service's manifests from the Flux source
before the Aggregator owns them deletes it. The safe order sets spec.prune:
false on the covering Flux Kustomization FIRST, then applies from the
Aggregator, then removes the manifests, then restores prune. Steps 2 and 4 the
wrong way round is the destructive case, and it is called out as such.

CLAUDE.md adds a timing constraint on top: Flux reads deploy/production which a
workflow publishes from main, and reconciling before the publish lands silently
applies the previous revision -- which would restore the manifests just removed.

Adoption order across the estate is dependency-first: nodes, data, auth,
knowledge, agents, media, remainder. auth is third rather than last on purpose,
because it has by far the best test coverage and is where the pipeline gets
proven. media is late for the opposite reason: zero test classes means its gate
cannot tell you much, so it should run on machinery already trusted.

FOUR WORKFLOWS, each ONE job with many steps. CLAUDE.md measured why: 561
minutes of real compute billed 2,845, four fifths of it rounding, and prefer one
job with many steps. Every step carries if: !cancelled() so a single failure
still surfaces the rest.

- service-publish-fragment.yml fires on merge to platform/**, not on a version
  tag, because an intent-only change produces no image and ADR-0019 requires it
  to reach an Aggregator. It pushes, resolves the digest afterwards -- an
  artefact cannot contain its own digest -- then pulls the package back and
  checks it against its own manifest.
- compose.yml is triggered by the publish rather than polled, because CLAUDE.md
  forbids putting anything needing prompt reaction on an Actions schedule. The
  nightly cron is a safety net, labelled as one. It also applies a negative
  fixture on every run, because an assertion that stopped running looks
  identical to one that passes -- which is exactly how
  E_ROUTE_AUTH_MODE_NOT_IN_TIER came to be implemented and vacuous.
- aggregator-gate.yml asserts the triple digest BEFORE provisioning, so a
  tampered candidate costs nothing, and applies the whole composed estate with
  Flux for class B and SSA for class A, so the test target mirrors production's
  split rather than diverging from it.
- aggregator-deploy.yml prunes by label query, applies in DAG order, reads the
  state back rather than trusting the exit code, and reports lag. Its
  break-glass dispatch input documents that the annotation makes a rollback
  stick rather than being undone by the CronJob.

NEW EXAMPLES. The Aggregator declaration with exercises many-to-many and
deploys one-to-one; a renovate.json custom manager for the single pin; the
generated deployer RBAC that turns deploy authority into an API-server control
rather than a CI convention; the in-cluster reapply CronJob; and a negative
fixture for E_DUPLICATE_SERVICE_ID, the first of one per invariant.

DIAGRAMS. A top-level system diagram in the overview showing authored files
through fragments, composition, resolution and both delivery classes back to the
cluster -- and resolved.yml returning to the owning repository. A sequence
diagram in chapter 50 tracing one change end to end across six actors.

Eleven mermaid blocks now, across flowchart, classDiagram and sequenceDiagram.
The validator was extended to check sequence participants as well as flowchart
endpoints, and to fail on a diagram type it does not know rather than skipping
it silently.

Audit clean: 11 diagrams valid, 18 example files parsing, no broken links, all
four workflows single-job.
@ExtraToast
ExtraToast changed the base branch from docs/ch40-composition to v1-pre-release August 31, 2026 13:42
@ExtraToast
ExtraToast changed the base branch from v1-pre-release to docs/ch40-composition August 31, 2026 13:42
ExtraToast added a commit that referenced this pull request Aug 31, 2026
Lands the remainder of the v1 specification. Chapters 10 and 16 merged as #58
and #59; this carries 20, 30, 40, 50 and 60 together, plus the revisions those
chapters make to earlier ADRs and the examples.

Combined rather than stacked, because the stack could not be salvaged after the
first two squash-merges: each branch was cut from the pre-squash tip of the one
below, so rebasing them onto the merged base conflicted. Splitting them again
after the fact would also have produced PRs that do not stand alone -- chapters
50 and 60 revise 30 and 40 (prune semantics, class C's home, two resolved open
items), and the glossary correction touches all of them. #60 through #63 remain
as the per-chapter review record.

Chapter 20, the Resolved Deployment. One rule carries the specification: every
assignment is a pure function of Service Intent, the pinned Cluster Context and
the pinned locks. Nothing is allocated or remembered between renders. Applying
it exposed a hole in ADR-0003 -- taken literally it forbids declaring the
Service Id -- so identity is separated from pool: identity is declared and
checked, a pool is assigned. Not one live hostname derives from a Service Id, so
an exposure carries a name. Also corrects something said repeatedly and wrongly:
layer 2 assigns a selector and an affinity, never a node.

Chapter 30, the Deliverable Set. Closes the coverage question with a
measurement: 450 objects, 39 kinds, 329 files, split 364 intent-derived, 41
pack-delivered, 45 authored. 328 of the 364 have an adapter; the 36-object gap
is 14 that need only registering and 22 that need writing.

Chapter 40, composition. 26 estate-wide invariants across identity, references,
secrets and completeness. The unit of publication is a repository rather than a
domain, which resolves the homelab-collections split question without a
decision. Order-independence forces every collision to be an error rather than
a last-write-wins merge.

Chapter 50, lifecycle, with ADR-0019. Push delivery gated by
relationship-scoped aggregators; Flux keeps the foundation, because 18 of the 41
pack-delivered objects are HelmRelease and applying one with kubectl does
nothing without helm-controller. The cluster is the inventory: a deployer label
supplies what Flux gets from a Kustomization's record. Drift correction is an
in-cluster CronJob, not an Actions schedule, because the estate measured Actions
crons at four to seven runs per day regardless of the declared interval.

Chapter 60, setup and adoption. The ordering hazard is the point: Flux prunes,
so spec.prune must be set false BEFORE a Service's manifests leave the Flux
source, or adoption deletes it.

Also: the glossary was materially stale and is corrected -- Claim Mode described
a model that no longer exists, Contended Value conflated identity with pool, and
seven terms were missing. 39 terms to 47.

Four workflows, an Aggregator declaration, a Renovate custom manager, the
generated deployer RBAC, the reapply CronJob and a negative fixture. Every
workflow is one job with many steps, because jobs bill by the minute rounded up.

Audit at commit time: 11 mermaid blocks valid across three diagram types, 18
example files parsing, no broken relative links, no stale vocabulary.
@ExtraToast

Copy link
Copy Markdown
Contributor Author

Superseded by #64, which landed chapters 20-60 together. The stack could not be rebased after #58 and #59 squash-merged: each branch was cut from the pre-squash tip of the one below. Kept open until now as the per-chapter review record.

@ExtraToast ExtraToast closed this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deploy homelab-deploy, deploy-v2, manifests, and rollout flow. component: config Configuration schema, defaults, or repo settings. type: docs Documentation-only change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant