A struct per resource kind, instead of one struct with every field

Jochen asked why we don't simply have dedicated structs. We should, and the
flat struct was me extending an existing pattern rather than questioning it.

Before: one Resource struct carrying path, content, mode, unit, state, package,
image, name, env, ports, volumes, args, command, verify and in. Because a file
and a container shared it, nothing stopped {"type":"file","image":"postgres"},
so a `uses` map listed which fields each kind was allowed to carry -- a second
place to keep current, and the kind nobody updates is the one that silently
accepts a field the host will never read.

Now: Directory, File, Service, Package, Container and Action are separate
structs behind a Resource interface. File has no Image field, so the mistake is
not detected -- it is unrepresentable. Adding a field to a kind is the whole of
adding it; there is nowhere else that has to agree.

Parsing is two passes: read the envelope and each resource's raw bytes, peek at
"type" to choose the struct, then decode into it. Peeking is lenient on purpose
-- reading strictly there would report an unknown field before knowing which
fields are known.

Unknown fields are found by comparing the JSON keys against the struct's own
json tags rather than by catching the decoder's error. The decoder stops at the
first unknown field, and RefusalError promises every problem at once: a caller
fixing one field at a time learns the next only by running again. Caught by
testing the refactor against a real declaration -- a container carrying both
`unit` and `mode` reported only one of them.

apply.go switches on the concrete type instead of a string, so a new kind that
has no applier is a compile error rather than a runtime default branch.

No behaviour change otherwise. All existing tests pass unmodified except two
that reached for fields the interface no longer exposes.
This commit is contained in:
2026-08-27 21:03:59 +02:00
parent 337126603e
commit 9a9937b7e6
4 changed files with 365 additions and 236 deletions
+12 -4
View File
@@ -36,7 +36,7 @@ func TestAValidDeclarationParsesInOrder(t *testing.T) {
t.Fatalf("unexpected refusal: %v", err)
}
// Order is stated, not derived. The host must not sort.
got := []string{d.Resources[0].ID, d.Resources[1].ID, d.Resources[2].ID}
got := []string{d.Resources[0].Identity(), d.Resources[1].Identity(), d.Resources[2].Identity()}
want := []string{"etc", "conf", "svc"}
for i := range want {
if got[i] != want[i] {
@@ -244,15 +244,23 @@ func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
// novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them.
// Asserted so that removing one is a failing test rather than a discovery during a
// first-node install.
speaks := map[Type]bool{}
for _, t := range Vocabulary() {
speaks[t] = true
}
for _, want := range []Type{
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
} {
if _, ok := uses[want]; !ok {
if !speaks[want] {
t.Errorf("the host no longer speaks %q", want)
}
if newOf(want) == nil {
t.Errorf("%q is in the vocabulary and cannot be constructed", want)
}
}
if len(uses) != 6 {
if len(speaks) != 6 {
t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+
"control plane can express, so a change here is a decision: %s", len(uses), vocabulary())
"control plane can express, so a change here is a decision: %s",
len(speaks), vocabulary())
}
}