The mesh knows where a module came from, and whether it is behind

Delivery is a comparison, not a pipeline: the control plane holds what source
exists and what has been built from it, and the difference is the work. Both
halves are written down now, so "is this current" is a question about two
columns rather than something you find out by building.

`status` answers "did my change go out?", which ADR 0010 names as the real risk
of replacing a pipeline with a comparison -- it is answerable today by opening
a pipeline, and something had to replace that.

  zsh    holds 4f2a9c1e, source has 9e3b7d2a
         running on laptop

The machines are the point. A module being out of date is a fact about the
catalogue; which machines are running last week's version is the thing with
consequences.

Three things this had to get right.

A module with no source is never behind -- it was handed over directly, which
is how a one-off arrives, and saying "out of date" about it would be inventing
a comparison against nothing.

A source nobody has checked is not behind either. Reporting it as behind would
put every module on the list the moment provenance was recorded, which makes
the list say nothing. Fault injection found this: my first test passed with the
guard removed, because both halves were empty strings and compared equal. The
case that actually needed it -- a known commit and an unknown head -- was
untested.

And handing over a manifest by hand does not erase where the module normally
comes from. Fixing something in a hurry is legitimate; silently forgetting its
origin is not, because that record is the only thing that would say afterwards
that a machine is running something nobody can rebuild.

Also fixed the flag parsing, which stopped at the first positional argument and
silently ignored every flag after it -- so `module add thing.json --source x`
recorded no source at all and said it had succeeded. The host's own parser
documents this exact footgun and I wrote it again anyway.
This commit is contained in:
2026-08-29 22:32:16 +02:00
parent 931a3a19a5
commit 653e232f1c
4 changed files with 424 additions and 17 deletions
+116 -6
View File
@@ -20,25 +20,135 @@ var ErrNoSuchModule = errors.New("no module of that name")
// removing the record would leave the mesh unable to describe what is on it.
var ErrStillAssigned = errors.New("that module is still assigned to nodes")
// Source is where a module comes from and what has been built from it.
type Source struct {
Repository string
Ref string
// BuiltFrom is the commit the manifest the mesh holds was read at.
BuiltFrom string
// Head is the newest commit the source is known to have.
Head string
}
// Current reports whether what the mesh holds is what the source last had.
//
// A module with no source is always current: it was handed over directly, and there is nothing
// it could be behind. Saying "out of date" about it would be inventing a comparison.
func (s Source) Current() bool {
if s.Repository == "" || s.Head == "" {
return true
}
return s.BuiltFrom == s.Head
}
// RegisterModule records a module, replacing what was there.
//
// Replacing rather than refusing, because a manifest changing is the ordinary case -- a module
// gains a requirement, a claim, a resource. What matters is that the change is visible the next
// time a node is resolved, which it is.
func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest) error {
func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, from Source) error {
raw, err := json.Marshal(m)
if err != nil {
return err
}
// A module registered without provenance keeps whatever it had. Handing over a manifest by
// hand is a legitimate way to fix something in a hurry, and it should not silently erase the
// record of where the module normally comes from — which is the only thing that would say,
// afterwards, that the machine is running something nobody can rebuild.
_, err = i.store.Pool().Exec(ctx,
`insert into module (name, manifest, version) values ($1, $2, nullif($3,''))
on conflict (name) do update set manifest = excluded.manifest,
version = excluded.version,
registered = now()`,
m.Module, raw, m.Version)
`insert into module (name, manifest, version, source, ref, built_from, source_head)
values ($1, $2, nullif($3,''), nullif($4,''), nullif($5,''), nullif($6,''), nullif($6,''))
on conflict (name) do update set
manifest = excluded.manifest,
version = excluded.version,
registered = now(),
source = coalesce(excluded.source, module.source),
ref = coalesce(excluded.ref, module.ref),
built_from = coalesce(excluded.built_from, module.built_from),
source_head = coalesce(excluded.built_from, module.source_head)`,
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom)
return err
}
// SourceMoved records that a module's source has a newer commit than the mesh has built.
//
// This is the whole of noticing. Nothing here builds anything — it writes down that the two
// halves differ, which is what makes *is this current?* answerable without building, and what
// makes a module that nobody rebuilt visible rather than silent.
func (i *Inventory) SourceMoved(ctx context.Context, module, head string) error {
tag, err := i.store.Pool().Exec(ctx,
`update module set source_head = $2, source_seen = now() where name = $1`, module, head)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
}
return nil
}
// SourceOf is where a module came from and whether the mesh is behind it.
func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error) {
var s Source
var repo, ref, built, head *string
err := i.store.Pool().QueryRow(ctx,
`select source, ref, built_from, source_head from module where name = $1`,
module).Scan(&repo, &ref, &built, &head)
if errors.Is(err, pgx.ErrNoRows) {
return Source{}, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
}
if err != nil {
return Source{}, err
}
for _, pair := range []struct {
from *string
to *string
}{{repo, &s.Repository}, {ref, &s.Ref}, {built, &s.BuiltFrom}, {head, &s.Head}} {
if pair.from != nil {
*pair.to = *pair.from
}
}
return s, nil
}
// Behind is every module the mesh has not built from what its source now has, with the nodes
// running the old one.
//
// The nodes are the point. "Is this module out of date" is a fact about the catalogue; "which
// machines are running last week's version" is the question somebody actually has, and it is the
// one novox/hq ADR 0010 names as the thing that must not be lost.
func (i *Inventory) Behind(ctx context.Context) (map[string][]string, error) {
rows, err := i.store.Pool().Query(ctx,
`select m.name, coalesce(n.name, '')
from module m
left join assignment a on a.module = m.name
left join node n on n.id = a.node
where m.source is not null
and m.source_head is not null
and coalesce(m.built_from, '') is distinct from m.source_head
order by m.name, n.name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]string{}
for rows.Next() {
var module, node string
if err := rows.Scan(&module, &node); err != nil {
return nil, err
}
if _, seen := out[module]; !seen {
out[module] = nil
}
if node != "" {
out[module] = append(out[module], node)
}
}
return out, rows.Err()
}
// Catalogue is every module the mesh knows about, which is what resolution needs: the question
// "how many modules provide this" cannot be asked of a subset.
func (i *Inventory) Catalogue(ctx context.Context) (map[string]catalogue.Manifest, error) {
+160 -6
View File
@@ -22,7 +22,7 @@ func TestAModuleRoundTripsWholeAndUnshredded(t *testing.T) {
Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}},
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/X11/x.conf"}},
}
if err := inv.RegisterModule(t.Context(), m); err != nil {
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
t.Fatal(err)
}
@@ -46,10 +46,10 @@ func TestRegisteringAgainReplacesTheManifest(t *testing.T) {
// A manifest changing is the ordinary case — a module gains a requirement, a claim, a
// resource. What matters is that the change is what the next resolution sees.
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil {
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"a-thing"}, nil)); err != nil {
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"a-thing"}, nil), Source{}); err != nil {
t.Fatal(err)
}
@@ -72,7 +72,7 @@ func TestAModuleAMachineIsRunningCannotBeForgotten(t *testing.T) {
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil {
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
@@ -99,7 +99,7 @@ func TestRemovingANodeTakesItsAssignments(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil {
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
@@ -146,7 +146,7 @@ func TestAssigningTwiceIsNotAnError(t *testing.T) {
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil {
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
@@ -210,3 +210,157 @@ func TestOnlyPresentCapabilitiesCount(t *testing.T) {
t.Error("a capability the node reported as ABSENT was counted as present")
}
}
func TestAModuleWithNoSourceIsNeverBehind(t *testing.T) {
// It was handed over directly, which is how a one-off arrives and how every module got here
// before provenance existed. Saying "out of date" about it would be inventing a comparison
// against nothing.
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
from, err := inv.SourceOf(t.Context(), "thing")
if err != nil {
t.Fatal(err)
}
if !from.Current() {
t.Error("a module with no source was reported as behind")
}
behind, err := inv.Behind(t.Context())
if err != nil {
t.Fatal(err)
}
if len(behind) != 0 {
t.Errorf("a module with no source is in the behind list: %v", behind)
}
}
func TestASourceThatMovedMakesTheModuleBehind(t *testing.T) {
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
Source{Repository: "novox/thing", Ref: "main", BuiltFrom: "aaaa1111"}); err != nil {
t.Fatal(err)
}
from, err := inv.SourceOf(t.Context(), "thing")
if err != nil {
t.Fatal(err)
}
if !from.Current() {
t.Fatal("a module built from the only commit its source has is behind")
}
if err := inv.SourceMoved(t.Context(), "thing", "bbbb2222"); err != nil {
t.Fatal(err)
}
from, err = inv.SourceOf(t.Context(), "thing")
if err != nil {
t.Fatal(err)
}
if from.Current() {
t.Error("the source moved and the module still reports as current")
}
}
func TestBeingBehindNamesTheMachinesRunningTheOldOne(t *testing.T) {
// The question somebody actually has. A module being out of date is a fact about the
// catalogue; machines running last week's version is the thing with consequences.
inv := fresh(t)
for _, n := range []string{"laptop", "workstation"} {
if _, err := inv.AddNode(t.Context(), n); err != nil {
t.Fatal(err)
}
}
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}); err != nil {
t.Fatal(err)
}
for _, n := range []string{"laptop", "workstation"} {
if err := inv.Assign(t.Context(), n, "thing"); err != nil {
t.Fatal(err)
}
}
if err := inv.SourceMoved(t.Context(), "thing", "bbbb2222"); err != nil {
t.Fatal(err)
}
behind, err := inv.Behind(t.Context())
if err != nil {
t.Fatal(err)
}
if len(behind["thing"]) != 2 {
t.Errorf("running on %v; both machines have the old one", behind["thing"])
}
}
func TestRebuildingCatchesUp(t *testing.T) {
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}); err != nil {
t.Fatal(err)
}
if err := inv.SourceMoved(t.Context(), "thing", "bbbb2222"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"a-thing"}, nil),
Source{Repository: "novox/thing", BuiltFrom: "bbbb2222"}); err != nil {
t.Fatal(err)
}
from, err := inv.SourceOf(t.Context(), "thing")
if err != nil {
t.Fatal(err)
}
if !from.Current() {
t.Errorf("built from the commit the source has and still behind: %+v", from)
}
}
func TestHandingOverAManifestDoesNotEraseWhereItComesFrom(t *testing.T) {
// Fixing something in a hurry is legitimate. Silently forgetting where the module normally
// comes from is not: it is the only thing that would say, afterwards, that a machine is
// running something nobody can rebuild.
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
Source{Repository: "novox/thing", Ref: "main", BuiltFrom: "aaaa1111"}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"patched"}, nil),
Source{}); err != nil {
t.Fatal(err)
}
from, err := inv.SourceOf(t.Context(), "thing")
if err != nil {
t.Fatal(err)
}
if from.Repository != "novox/thing" {
t.Errorf("handing over a manifest erased the source: %+v", from)
}
}
func TestASourceNobodyHasCheckedIsNotBehind(t *testing.T) {
// A module built from a commit, where nothing has yet told the mesh whether that source has
// moved. It is not behind — nobody has looked. Reporting it as behind would put every module
// on the list the moment provenance was recorded, which makes the list say nothing.
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}); err != nil {
t.Fatal(err)
}
from, err := inv.SourceOf(t.Context(), "thing")
if err != nil {
t.Fatal(err)
}
// Registering sets the head to what was built, so the two agree until something says
// otherwise. Either way it must not read as behind.
if !from.Current() {
t.Errorf("a source nobody has checked reports as behind: %+v", from)
}
// And with the head genuinely unknown, which is what a module registered before provenance
// existed looks like after somebody adds a source to it.
if (Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}).Current() == false {
t.Error("a module with no known head reports as behind")
}
}
@@ -0,0 +1,20 @@
-- Where each module came from, and whether what the mesh holds is still current.
--
-- novox/hq ADR 0010: delivery is a comparison, not a pipeline. The control plane holds what
-- source exists and what has been built from it, and builds the difference. So both halves have
-- to be written down, and *is this current?* is a question about two columns rather than
-- something you find out by building.
alter table module add column source text; -- where it comes from
alter table module add column ref text; -- the branch followed there
alter table module add column built_from text; -- the commit this manifest was read at
alter table module add column source_head text; -- the newest commit the source is known to have
-- When the mesh last learned the source had moved. Kept apart from `registered`, which is when
-- the manifest last changed: a source that moved and was never built is exactly the state this
-- exists to make visible, and one timestamp could not show it.
alter table module add column source_seen timestamptz;
-- A module with no source is not a fault. It was handed over directly -- which is how every
-- module got here before this existed, and how a one-off still arrives. It is simply never out
-- of date, because there is nothing it could be behind.