--- effort: 006-mesh-from-scratch updated: 2026-08-23 --- # The code skeleton [`skeleton.md`](skeleton.md) laid out tiers and repositories. This is the level below: what a module looks like on disk, and — the question that forced a correction — where a given piece of today's catalogue actually lands. ## The tier test A decision procedure, so placement is answerable rather than argued. Ask in order; first match wins: 1. **Does it apply state on a machine?** → tier 0, inside the host. 2. **Can the control plane exist without it?** If *no* → tier 1, substrate. 3. **Does it decide what should be true across nodes?** → tier 2, a control-plane context. 4. **Is it a way to talk to tier 2, holding no logic of its own?** → tier 3, a surface. 5. **Otherwise** → tier 4, a workload. ## Worked example — where postgres ends up The obvious answer is "twice": once as the mesh's own database in the substrate, once as a hosted database in the catalogue. That answer is wrong, and seeing why fixes something. Run the test. *Can the control plane exist without a relational store?* No. **Postgres is tier 1.** It lives once: ``` hal-substrate/store/postgres/ ``` There is no second copy in the catalogue, because the thing that differs between the mesh's own database and a project's database is **not the module**. It is how that instance is brought up: | | The mesh's own instance | A project's database | |---|---|---| | Brought up by | the host, from the pinned bundle, with no control plane present | the ordinary delivery and provisioning path | | Declared in | `hal-substrate/bundle.yml` | the consuming module's `requires:` | | Exists because | the control plane cannot start without it | something asked for it | Same module, two roles. **Tier is a property of the module** — what must exist before what — and **the bundle is a property of the mesh's own instance.** This is also legal under the dependency rule, which is worth checking rather than assuming: a tier-4 workload that requires a database depends on tier 1, which points *downward*. The inverse — substrate reaching into the catalogue for a module — would not be, and is the shape the naive "twice" answer would have created. The same reasoning places the rest of the substrate: the bus, the object store, the image registry. Each fails step 2, each lives once, each is pinned. **One genuine boundary case, flagged rather than decided.** The identity provider passes step 2 only if the control plane delegates authentication rather than doing it natively. If tier 2 authenticates callers itself, the identity provider drops to tier 4 and the substrate has four services instead of five. The test does not answer this; it turns it into a question with a clear shape, which is what a test is for. ## The five fates of a module Research 005 asked whether a catalogue module should be **grouped into a domain** or **leave the repository**. Working the tier test across the catalogue surfaces a third answer that neither option covers, and it is the most common one. | Fate | Means | Examples from today | |---|---|---| | **Absorbed into the host** | It is not a module at all. It is part of what "managing a machine" means, and belongs in tier 0. | overlay membership, packet filtering, package management, service supervision, container runtime, filesystem management | | **Substrate** | The control plane cannot exist without it. Pinned, host-applied. | relational store, bus, object store, image registry | | **Control-plane context** | It decides something across nodes. | connectivity policy, inventory, delivery, provisioning, observability | | **Workload module** | The mesh hosts it. Grouped per [ADR 0017](../../02-DECISIONS/0017-modules-outside-the-core-are-grouped-by-domain.md). | media library, desktop session, collaboration tooling | | **Leaves the repository** | A standalone application, per [ADR 0010](../../02-DECISIONS/0010-applications-live-in-their-own-repository.md). | the applications identified in research 005 | **The first fate is the finding.** Research 005 measured the reachability cluster — proxy, resolver, firewall, overlay — as the only place in the catalogue where modules genuinely change together under one intent. The skeleton explains *why*: they are not four modules that ought to be one domain module. They are four facets of one thing the host should own, currently expressed as modules because a module was the only unit available. Under this skeleton the overlay module and the firewall module **stop existing**. The host holds membership and applies filtering; tier 2 decides the policy; the swappable backends stay modules. That is a different and better answer than grouping them, and it was not visible from inside the current frame. It also partly answers research 005's other open question — the fifty modules that co-change with nothing. Several are host concerns rather than domains: package management, container runtime, filesystem tooling. Silence was the right signal after all; the wrong conclusion was that grouping was the only available fix. ## What a module looks like on disk Per [`skeleton.md`](skeleton.md) Move 4, a module declares **parts** — independently selectable pieces of desired state — and produces **artifacts** — things built once per version. ``` / module.yml identity, what it provides, what it requires, which host profiles it can land on parts/ service/ desired state: container, volumes, exposure provisioner/ how it grants its resource to consumers migrations/ its own persistent state tools/ capabilities it contributes artifacts/ / source for something built and published ``` A module with no artifacts consumes an upstream image and builds nothing. A module with no parts is not a module. Worked through for the substrate's relational store: ``` hal-substrate/store/postgres/ module.yml provides: database · profiles: [managed] parts/ service/ the container, its volume, its network exposure provisioner/ grants a database and role to a consumer migrations/ none — it holds no state of its own artifacts/ none — upstream image, pinned by digest in bundle.yml ``` ## The tree, at file level ``` hal-host/ TIER 0 cmd/host/ internal/ apply/ reconcile declared state inventory/ what this machine is and can do link/ outbound connection to the control plane overlay/ membership: address, keys, tunnel filter/ packet filtering from tier-2 policy packages/ package management services/ supervision containers/ container runtime store/ embedded local state profile/ managed · user · edge substrate.lock pinned tier-1 descriptor hal-substrate/ TIER 1 bundle.yml the pinned set, by digest store/postgres/ bus// objects// images// identity// boundary case — see above hal-mesh/ TIER 2 record/ the event log contexts integrate through inventory/ nodes · modules · assignments · versions config/ settings · secrets · derivation connectivity/ addresses · resolution · exposure · filtering · certificates provisioning/ grants between modules delivery/ source → artifact → node observability/ health · logs · metrics identity/ agents · humans · services · authorisation work/ tasks · workflows · runs knowledge/ memory · documents · retrieval api/ the one interface surfaces speak to hal-surfaces/ TIER 3 tools/ web/ cli/ hal-catalog/ TIER 4 // layout as above hal-lab/ hal-sdk/ hal-hq/ ``` ## What this does not settle - **The identity boundary case.** Four substrate services or five. - **Where the record lives.** Still the open question from [`00-overview.md`](00-overview.md), and the tier test does not resolve it: the record is needed by tier 2 and is *of* tier 2, which is exactly the shape that produces a circularity. - **Whether absorbing into the host makes the host too large.** Six internal concerns is already a lot for a binary whose whole argument is that it has no dependencies. The counter-argument is that each is small and none can be optional — but this is the skeleton's biggest unproven claim, and it should be tested by writing the host's interface before anything else. - **The migration.** Nothing here says how today becomes this.