ADR 0157: a build says what it does on the bus, as it happens #238

Merged
mesh-admin merged 1 commits from feat/a-build-says-what-it-does into main 2026-09-30 22:43:59 +00:00
4 changed files with 127 additions and 3 deletions
@@ -0,0 +1,99 @@
---
topic: the mesh
status: accepted
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
---
# 157. A build says what it does on the bus, as it happens
## Context
[ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) made a build work submitted to a role: the
build-machine seat accepts a build and emits its outcome, one publish that reaches whoever asked, the
controller that records it and the catalogue that places it. Everything **between** the request and
the outcome — which command is running, how long it has taken, where it hung, the compiler's error,
the clone's refusal — lived in one container's standard error on one machine.
The night of 2026-09-30 showed the cost three times over. A build that failed showed a person one
line, the first of its failure, in the controller's `builds`; the rest was read with `docker logs` over
ssh, which the mesh's own rule forbids. A build that ran for minutes could not be told from one that
had hung. And the builder has no tools and emits nothing but the outcome, so the console
([ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)) had nothing to show while a
build ran, and no viewer could be built on top of it. The operator's ask was plain: the builder is to
be fully transparent, with its log on the bus, so that a log viewer can be built on the bus later.
## Considered Options
1. **Keep the log in the outcome.** The result carries the whole log when the build ends. Nothing new
on the bus; nothing while the build runs; a viewer sees a build only once it is over, which is
exactly when the log matters least.
2. **A log store.** The builder writes its log to a file or a table and a tool reads it. A second
place to keep something the bus already carries, with its own retention, access and failure modes,
and no live reading without inventing a subscription over it.
3. **The log is the role's own events.** Two more events on the build-machine seat beside `built`:
`started` when work is taken, and `log.<build id>` for every line, published as the build runs.
The events stream already retains every role's events for a week, so a reader follows a build
live by subscribing its subject, or reads it back afterwards from the stream, and a viewer is a
subscriber and nothing more.
## Decision
**Option 3.** A build machine says everything it does on the bus, as the role it holds, under the
build's id, and the mesh keeps no other copy.
- The build-machine seat's protocol gains `started` and `log.*`. A holder may therefore publish
`mesh.seat.mesh-build-machine.event.started` and `…event.log.<id>`, and no other subject, by the
same derivation every seat's grants follow ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
The event's tail token is the build's id, so one build is one subject: a reader filters by subject
alone, on the server, and a week of other builds does not travel to show one.
- **Every line goes two ways**: to the machine's own standard error as before, and onto the bus. That
includes every command the builder runs, its duration and its failure, and on failure the command's
own output line by line — the compiler's words, the clone's refusal. A build machine with nobody
listening still prints; a listener reads the same lines.
- A line is a core publish, unawaited. The stream that holds the role's events captures it on its way
through, and a build does not slow to the pace of an acknowledgement per line. Each line carries a
sequence number from one, so a reader who joined late, or reads two copies, sees order and gaps.
`started` and `built` are published into the stream and awaited, because they are the two facts a
later reader must never find missing.
- **The mesh reads it back from the stream**, never from a record of its own: `builds --log <id>`, and
the same verb on the controller's seat ([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
reads one build's subject with a consumer that is gone when the reading is done. `builds` lists
each build's id beside it, and `build` says the id it asked with, so a person can follow.
- Nothing is declared by the builder module for this. The protocol is the seat's, seeded additively
into the store ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)), and the
holder's grant follows on the next composition of the broker node.
## Consequences
- A build is watchable while it runs, from anywhere on the mesh, with no access to the build machine.
The console's gap of 2026-10-01 — no live progress, no per-merge view — closes on the progress half;
the per-merge view is a reader over these subjects and the outcome, and is not built here.
- A log viewer on the bus is now a plain subscriber: live on `mesh.seat.mesh-build-machine.event.>`,
historical from the events stream filtered by a build's subject. NATS carries and retains; it does
not view. The `nats` command-line client can tail or replay a subject today; a viewer of our own is
later work and needs nothing more from the builder.
- The events stream grows by a build's log per build, for a week. A build is a few hundred lines; the
stream's limits are the bound, as for every other event, and a stream that fills drops the oldest.
- A line the bus did not take is lost, deliberately, and visible as a gap in the sequence. The outcome
is not affected: a build's result never depended on its narration.
## How this is checked
| Rule | Checked by |
|---|---|
| The seat's holder may publish `started` and `log.<id>` and nothing wider | `TestTheBuildMachineMaySayWhatItDoesUnderTheBuildsId` (broker) |
| A build's lines reach a reader of its subject in order, and the stream holds them afterwards | `TestNatsABuildIsTakenAndItsOutcomeReachesEverybody` against a real server (link) |
| The seat verb `builds` with a build's id reads that build's log | `TestBuildsWithAnIdReadsThatBuildsLog` |
| Every command the builder runs is said, with its output on failure | `Command` speaks through the hook every build sets; the builder's tests still see the lines on standard error when nothing listens |
| Live: a build triggered after the roll-out is readable line by line through the console | done by hand after the merge of mesh-controller PR — see the design's note |
## References
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — extended: the role now narrates as well as answers
- [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md) — the protocol and the verb
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — the console this feeds
- [Design 25 — The bus on NATS](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §3, [Design 18 — Building a module](../03-DESIGN/01-to-be/18-building-a-module.md)
- [Issue 176](../04-ISSUES/176-the-consoles-build-tool-neither-waits-nor-registers/00-report.md) — the tool that starts a build and hears nothing; this gives it something to hear
+1
View File
@@ -170,6 +170,7 @@ python3 00-META/checks/index.py fail if stale
- **0142** — [The mesh delivers its own components as binaries, not as container images](0142-the-mesh-delivers-its-own-components-as-binaries.md) - **0142** — [The mesh delivers its own components as binaries, not as container images](0142-the-mesh-delivers-its-own-components-as-binaries.md)
- **0154** — [The mesh's own verbs are the mesh-controller seat's tools, and which verbs those are](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md) - **0154** — [The mesh's own verbs are the mesh-controller seat's tools, and which verbs those are](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
- **0156** — [An artifact is what a build produces, the artifact store serves every kind, and its seat is named for its scope](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md) - **0156** — [An artifact is what a build produces, the artifact store serves every kind, and its seat is named for its scope](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
- **0157** — [A build says what it does on the bus, as it happens](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)
### Its tiers, from the bottom up ### Its tiers, from the bottom up
+24 -1
View File
@@ -5,8 +5,9 @@ code:
- mesh-controller cmd/mesh-builder - mesh-controller cmd/mesh-builder
- mesh-controller internal/builder - mesh-controller internal/builder
- mesh-catalog modules/builder - mesh-catalog modules/builder
updated: 2026-09-30 updated: 2026-10-01
decisions: decisions:
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md - 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
- 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md - 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
@@ -267,6 +268,28 @@ ships one and wrong for code the mesh built, which has no unit until the mesh wr
**Tools, hooks and consumers are not further modes**, which is the test of whether three is the **Tools, hooks and consumers are not further modes**, which is the test of whether three is the
right number: they are loaded by a tool host, and a tool host is a process that stays up. right number: they are loaded by a tool host, and a tool host is a process that stays up.
## A build says what it does, as it happens
*2026-10-01 — [ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md).*
A build machine narrates every build on the bus as the role it holds: `started` when it takes the
work, one `log.<build id>` event per line — every command it runs with its duration, every step of
the recipe, and on failure the command's own output, line by line — and `built` for the outcome as
before. The same lines still go to the machine's standard error, so a build machine with nobody
listening is as readable as it was; a listener reads the same lines, live, from anywhere on the mesh.
One build is one subject. A reader follows it by subscribing that subject and nothing else, and the
events stream keeps it for a week, so `builds --log <id>` — on the command line and as the
controller's seat verb through the console — reads it back afterwards. `builds` lists every build's
id beside it, and `build` says the id it asked with. The mesh keeps no second copy: the stream is the
log. A viewer of builds, when one is built, is a subscriber over these subjects and the outcome; the
builder needs nothing more for it.
*How it is checked:* the holder's grant is exactly `started`, `built` and `log.*` (broker test); a
build's lines reach a reader of its subject in order and the stream holds them afterwards (link test
against a real server); the seat verb with an id reads the log (controller test); and, live, a build
after the roll-out read line by line through the console.
## The builder compiles the languages the mesh is written in ## The builder compiles the languages the mesh is written in
*2026-09-29 — *2026-09-29 —
+3 -2
View File
@@ -7,8 +7,9 @@ code:
- mesh-tools src/broker-amqp.ts (to be replaced) - mesh-tools src/broker-amqp.ts (to be replaced)
- mesh-catalog modules/nats (to be written) - mesh-catalog modules/nats (to be written)
- mesh-sdk src (the protocol's NATS binding, step 3) - mesh-sdk src (the protocol's NATS binding, step 3)
updated: 2026-09-27 updated: 2026-10-01
decisions: decisions:
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
- 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md - 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md
@@ -135,7 +136,7 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea
|---|---|---|---| |---|---|---|---|
| CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | | CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped |
| NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest | | NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest |
| EVENTS | `mesh.mod.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream) | | EVENTS | `mesh.mod.*.event.>` and `mesh.seat.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream). *2026-10-01:* a build's whole log is here too, as the build-machine seat's `log.<build id>` events ([ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)) — one subject per build, a week of retention, read back by `builds --log <id>` with a consumer that is gone when the reading is done |
Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool
call is a timeout the caller already handles. call is a timeout the caller already handles.