ADR 0076: the SDK is a published package the toolchain resolves by version

Records the decision the package-registry work turns on — the SDK is built on a
public base and published before the toolchain that consumes it, so nothing is
circular; mesh-tools stays the thin toolchain base but resolves the SDK by
version. Reconciles docs 12/17/22 and indexes the record.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-16 10:28:08 +02:00
parent 17c2e061df
commit b43b60b183
5 changed files with 118 additions and 0 deletions
@@ -0,0 +1,87 @@
---
topic: building it
status: proposed
date: 2026-09-16
deciders: jochen
reconstructed: false
extends: 0075-two-stores-and-which-provides-what.md
---
# 76. The SDK is a published package, and the toolchain resolves it by version
## Context
[ADR 0014](0014-no-npm-workspace.md) decided a module consumes its dependencies — the mesh's own
shared library included — from the private registry. [ADR 0075](0075-two-stores-and-which-provides-what.md)
decided the private registry is a `package-registry` provision, and that gitea provides it. What
neither settled, and what [issue 053](../04-ISSUES/053-the-sdk-is-pinned-twice-and-the-two-disagree/00-report.md)
left open, is the one build where the rule cannot simply be obeyed: **the first one.**
The TypeScript toolchain image is built *from* the SDK — it carries the SDK so that every module
compiled inside it resolves the shared library without each build fetching it. So the thing that
compiles TypeScript and the thing that contains the SDK were the same object, and that object
cannot be what builds the SDK. Stated as a question — "how does the SDK reach the registry before
the toolchain exists, when the toolchain is what builds it?" — it reads as a paradox.
It is not one. The paradox exists only because the toolchain *bakes a git-cloned copy* of the SDK.
The SDK itself is plain TypeScript: it needs `node` and `tsc` and nothing the mesh makes. A public
base image can build it. The circularity is a property of the workaround, not of the SDK.
## Decision
**The SDK is an ordinary published package in the mesh's `package-registry`, consumed by version.**
The git URL in the toolchain's manifest and the sibling-path lock beside it — the two halves of
issue 053 — are both removed. A build resolves the SDK the way it resolves any dependency, with a
lock that agrees with its manifest, so `npm ci` is the command and reproducibility is by
construction rather than by the machine the build ran on.
**The SDK is built with a public base image, not with the mesh's toolchain.** It is *not* one of
the components the loop cannot build — the control plane, the registry, the builder, the catalogue
([`12-a-module-repository`](../03-DESIGN/01-to-be/12-a-module-repository.md)), which arrive by
carrying an init builder because they are the loop's own machinery. The SDK is machinery for
nothing; it is an ordinary dependency the loop builds and publishes like any other. The only
constraint is narrow: it cannot be compiled *in the mesh toolchain*, because that toolchain is built
from it. So it is compiled on a public base image instead — which needs nothing the mesh makes — and
published before the toolchain that consumes it. It is not carried, because building it does not
wait on a mesh existing first.
**The toolchain base stays, thinned.** mesh-tools remains the image bundles are compiled in and the
one place the SDK is resolved — but it `npm ci`s the SDK by version from the registry instead of
baking a copy cloned from a git URL. Bundles keep borrowing its resolved dependencies; what changes
is that the version they borrow is named and honest. This was the shape chosen over dropping the
shared base entirely and having every bundle resolve the SDK itself: one resolution point, one
place to be right about the version.
**Genesis orders the publish before the first compile.** The package-registry provider is a public
image (gitea), so it comes up needing no toolchain; the SDK is published into it; only then is the
toolchain built, so the first `npm ci` has a registry to read from. Nothing in that chain is
circular, because the only thing that needed the toolchain — baking the SDK — is gone.
## Consequences
Each language's toolchain repeats the shape: its own SDK, built from that language's public base
image, published to the same registry, resolved by version with that ecosystem's lockfile-honest
install (`npm ci`, `cargo` against a vendored or registry source, `pip` against a pinned set). The
warning in issue 053 — that whatever the TypeScript repository does the others will copy — is
answered by making the copied thing the correct one.
A change to the SDK is publish-then-consume, exactly as [ADR 0014](0014-no-npm-workspace.md) already
priced it: publish the new SDK version, then bump the toolchain (and any module pinning it directly)
to consume it. There is no shortcut that resolves an unpublished SDK, which is the property that was
missing.
mesh-tools is no longer an SDK carrier in the sense that mattered — it does not contain a copy
whose provenance is a branch head somebody force-pushes. It contains a version.
A mesh with no package-registry cannot build TypeScript. This is accepted and is not new: it is the
same shape as a mesh that cannot reach a forge being unable to be raised
([ADR 0071](0071-where-genesis-gets-its-source.md)). Installing brings the registry up first.
## How this is checked
| Rule | Checked by |
|---|---|
| The SDK a build compiles against is named, not cloned from a branch | The toolchain manifest pins `@novox/mesh-sdk` to a version, and the build runs `npm ci`, which refuses a lock that disagrees with the manifest. Issue 053's two checks become this one. |
| The SDK builds without the mesh's own toolchain | The SDK's build recipe names a public base image. A recipe that named the mesh toolchain would reintroduce the cycle and is refused in review. |
| The registry is up before the first compile | The genesis bed asserts the package-registry answers, and the SDK is published, before the base build runs. A base build that ran first would fail its `npm ci` with no registry, which is the positive control. |
| A second language repeats the shape, not a new one | When a second SDK is added, its recipe is compared to this one: public base, publish by version, lockfile-honest install. |
+1
View File
@@ -108,6 +108,7 @@ python3 00-META/checks/index.py fail if stale
- **0073** — [The installer carries a builder, and the registry stays where it is](0073-the-installer-carries-a-builder.md) - **0073** — [The installer carries a builder, and the registry stays where it is](0073-the-installer-carries-a-builder.md)
- **0074** — [The mesh defines a module protocol; an SDK is an implementation of it](0074-the-wire-is-specified-not-the-types.md) - **0074** — [The mesh defines a module protocol; an SDK is an implementation of it](0074-the-wire-is-specified-not-the-types.md)
- **0075** — [An artifact store is a provision; a package registry is a different one](0075-two-stores-and-which-provides-what.md) *(proposed)* - **0075** — [An artifact store is a provision; a package registry is a different one](0075-two-stores-and-which-provides-what.md) *(proposed)*
- **0076** — [The SDK is a published package, and the toolchain resolves it by version](0076-the-sdk-is-a-published-package.md) *(proposed)*
### What runs on them, and how it gets there ### What runs on them, and how it gets there
@@ -408,6 +408,15 @@ being a builder rather than a result.
Everything outside those four is either upstream — a third-party image pulled by digest — or built Everything outside those four is either upstream — a third-party image pulled by digest — or built
by the builder from a repository and a path, and published to the registry. by the builder from a repository and a path, and published to the registry.
**One of those built things has an ordering constraint worth naming, because it looks like a fifth
member of the list and is not.** The SDK the toolchain compiles against is built by the ordinary
builder and published like anything else — but it cannot be compiled *in the mesh toolchain*, since
that toolchain is built from it, and it is published to the *package* registry rather than the
artifact store. So it is compiled on a public base image and published before the toolchain that
consumes it ([ADR 0076](../../02-DECISIONS/0076-the-sdk-is-a-published-package.md)). It is not
carried and it is not machinery; it is a dependency with a sequence, which is why it belongs here as
a footnote to the rule rather than a row in the table.
**A carried artifact is not a differently-pinned artifact.** Once published it is named by a digest **A carried artifact is not a differently-pinned artifact.** Once published it is named by a digest
the mesh's registry assigned, exactly like everything the builder produces. A reader cannot tell the mesh's registry assigned, exactly like everything the builder produces. A reader cannot tell
from a running mesh which of its images were carried, and that is the point: carrying is how the from a running mesh which of its images were carried, and that is the point: carrying is how the
+10
View File
@@ -178,6 +178,16 @@ credential for a database; it does not yet do so for the store its own images li
avoids the question by carrying the image it needs, which makes this a joining problem and a avoids the question by carrying the image it needs, which makes this a joining problem and a
pulling problem, not a genesis one. pulling problem, not a genesis one.
**The SDK still comes from a git URL, and the ordering that fixes it is decided but not built.**
[ADR 0076](../../02-DECISIONS/0076-the-sdk-is-a-published-package.md) settles that the package
registry (gitea) comes up and the SDK is published into it *before* the base toolchain is built, so
the toolchain resolves the SDK by version rather than cloning it — closing
[issue 053](../../04-ISSUES/053-the-sdk-is-pinned-twice-and-the-two-disagree/00-report.md). The
builder already knows how to be handed a package-registry credential and inject it into a build; what
is not yet wired is the genesis step that raises gitea and publishes the SDK ahead of the base, and
the toolchain's own manifest still names the SDK by a git URL. Until both land, the base build clones
the SDK inside `docker build`, which is slow and names a branch head rather than a version.
## How these rules are checked ## How these rules are checked
| Rule | Checked by | | Rule | Checked by |
+11
View File
@@ -63,6 +63,17 @@ registry. Nothing installs one, so the SDK comes from a git URL (issue 053). Nee
**Done when.** A module builds against the SDK resolved from the mesh's own registry, and issue 053 **Done when.** A module builds against the SDK resolved from the mesh's own registry, and issue 053
closes. closes.
**Where it stands (2026-09-16).** The decision the bootstrap turned on is settled and recorded
([ADR 0076](../../02-DECISIONS/0076-the-sdk-is-a-published-package.md)): the SDK is a published
package, built on a public base image and published before the toolchain that consumes it; mesh-tools
stays the thin toolchain base but `npm ci`s the SDK by version. On the code, the builder now resolves
a package-registry credential — from a binding the mesh writes or from the environment for a hand-run
or bootstrap build — and injects it into an image build as a buildkit secret, never a layer, so a
token is not baked into the toolchain image. Unit-tested. Still ahead: gitea serving the registry in
full with a provisioner that mints tokens (2.1), the SDK built and published by the mesh (2.2), the
mesh-tools manifest flipped off the git URL to `npm ci` by version (2.3), and the genesis step that
raises gitea and publishes the SDK before the base build. Those close together in one lab run.
## Phase 3 — nothing is special after installation ## Phase 3 — nothing is special after installation
**Why last.** The hardest and riskiest, and it needs everything above: an installer that completes, **Why last.** The hardest and riskiest, and it needs everything above: an installer that completes,