From aeb65a3e1dbc717422b9b2871ea9e41e7c87f489 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 22:10:58 +0200 Subject: [PATCH] catalogue: a module accesses operator-owned data, and does not own it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 04-ISSUES/036: the media stack is several modules that must share the library and download directories on one machine, but the manifest could only say "a directory I own". Six modules each declared the same paths as their own resources, and the resolver's duplicate-owner refusal — right in general — would refuse the stack's only sensible assignment the first time two of them landed on one node. Add an `accesses` field: a pre-existing, operator-owned path a module is granted use of but does not own (novox/hq ADR 0051). Distinct from a `directory` resource on every axis the host acts on — the mesh creates, chowns and reconciles a directory; it mounts an access and owns nothing. An access is not a resource, so it never enters the duplicate-owner map and several modules may name one path with no conflict. What is refused is the contradiction: a path one module owns and another accesses. Rendered into the declaration as an `access` resource, before the container that mounts it, so the host can find it present or refuse clearly. Unit tests cover co-resolution (the exact 036 case), the unchanged owner-vs-owner refusal, the owner-vs-accessor refusal, and access validation. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- internal/catalogue/accesses_test.go | 108 ++++++++++++++++++++++++++++ internal/catalogue/declaration.go | 10 +++ internal/catalogue/manifest.go | 70 ++++++++++++++++++ internal/catalogue/resolve.go | 33 +++++++++ 4 files changed, 221 insertions(+) create mode 100644 internal/catalogue/accesses_test.go diff --git a/internal/catalogue/accesses_test.go b/internal/catalogue/accesses_test.go new file mode 100644 index 0000000..b95250a --- /dev/null +++ b/internal/catalogue/accesses_test.go @@ -0,0 +1,108 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the +// acquisition managers, a download client — that must share directories on one machine. Written +// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which +// is right in general and wrong here: sharing those directories is the whole point of the stack. +// +// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access +// is not a resource and never enters the duplicate-owner map, so this is the exact case the +// resolver refuses above when the same path is owned — and does not, when it is merely accessed. +func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessReadWrite}} + b := mod("b", nil, nil, nil) + b.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessRead}} + + got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) + if err != nil { + t.Fatalf("two modules accessing one operator-owned path were refused: %v", err) + } + + // And each accessing module's grant reaches the machine as its own `access` resource, so the + // host can find the path present or refuse — both naming the one operator-owned path. + var accesses int + for _, r := range mustDeclare(t, got) { + if r["type"] == "access" && r["path"] == "/services/media/downloads" { + accesses++ + } + } + if accesses != 2 { + t.Errorf("expected both modules' accesses in the declaration, got %d", accesses) + } +} + +// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside: +// two modules OWNING one path is still refused, exactly as before accesses existed. +func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + + _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) + if err == nil { + t.Fatal("two modules owning the same directory were both assigned") + } + if !strings.Contains(err.Error(), "/services/media/movies") { + t.Errorf("the refusal does not name the path: %v", err) + } +} + +// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path +// another module was told to expect the operator to provide would create and chown it — and the +// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction +// machine-checkable rather than a convention nobody enforces. +func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { + owns := mod("owns", nil, nil, nil) + owns.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + uses := mod("uses", nil, nil, nil) + uses.Accesses = []Access{{Path: "/services/media/movies", Mode: AccessRead}} + + _, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), World{}) + if err == nil { + t.Fatal("a module owning a path another accesses was allowed") + } + if !strings.Contains(err.Error(), "/services/media/movies") || + !strings.Contains(err.Error(), "the operator's") { + t.Errorf("the refusal does not explain the ownership conflict: %v", err) + } +} + +// A manifest states an access's extent, the way a listening port states its source. An absolute +// path and a mode of read or read-write; anything else is refused rather than acted on wrongly. +func TestAnAccessIsValidated(t *testing.T) { + good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`) + if _, err := ParseManifest(good); err != nil { + t.Fatalf("a valid access was refused: %v", err) + } + + // Mode is optional and narrows to read — the safe default, because the danger with an access + // is being given more than was meant, not less. + bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`) + m, err := ParseManifest(bare) + if err != nil { + t.Fatalf("an access with no mode was refused: %v", err) + } + if m.Accesses[0].At() != AccessRead { + t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead) + } + + relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`) + if _, err := ParseManifest(relative); err == nil { + t.Error("a relative access path was accepted") + } + + wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`) + if _, err := ParseManifest(wrong); err == nil { + t.Error("an access with an unknown mode was accepted") + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 9989df8..2806531 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -215,6 +215,16 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed, }) } + // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before + // the module's own resources, and so before the container that mounts them: the host must + // find each present — refusing clearly if the operator has not provided it — before it + // starts anything that depends on it. The mesh creates, chowns and reconciles none of it; + // an `access` resource says only *this path must exist, and this module reaches it*. + for _, a := range m.Accesses { + first = append(first, map[string]any{ + "id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.At(), + }) + } for _, to := range sortedKeys(m.Secrets) { var found *Needed for i, n := range r.Needs { diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 97841b2..4b875a6 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -57,6 +57,49 @@ func (c Claim) At() string { return c.Scope } +// Modes an access may be granted at. +// +// **The grant states its extent, the way a listening port states its source** (the rule the +// manifest already keeps for `listens.from`). Absent narrows to read — the safe default, because +// the danger with an access is being given more than was meant, not less. +const ( + AccessRead = "read" + AccessReadWrite = "read-write" +) + +// Access is a pre-existing path on the machine that this module is GRANTED USE OF, and does not +// own. +// +// **The distinction this exists for** (novox/hq ADR 0051, 04-ISSUES/036): a `directory` resource +// is a thing the mesh owns — it creates it, sets its owner and mode, and removes it when it is +// empty and no longer declared ([ADR 0030](novox/hq)). Shared, pre-existing data is none of that. +// A media library, a download spool, an ingest folder is the **operator's**: it existed before the +// mesh, several modules read and write it at once, and the mesh must not create, chown, reconcile +// or remove it. It mounts it and owns nothing about it. +// +// Written as its own field rather than a flag on a directory because the two are opposite on every +// axis the host acts on, and 04-ISSUES/026 records what happens when *the directory my data lives +// in* and *a facility I was granted* are spelled the same: the second gets created as root and the +// ownership fields silently do not apply. Two modules may name the same access with no conflict — +// that is the whole point of it — whereas two owning one path is the fault the resolver refuses. +// +// If the path is absent when a machine applies, the host refuses clearly rather than creating it: +// the mesh does not own it, so conjuring it would be a lie the host then acts on. +type Access struct { + // Path is the absolute path on the machine, as the operator provides it. + Path string `json:"path"` + // Mode is "read" or "read-write". Absent narrows to read. + Mode string `json:"mode,omitempty"` +} + +// At is this access's mode, with the default applied. +func (a Access) At() string { + if a.Mode == "" { + return AccessRead + } + return a.Mode +} + // Offer is something a module provides, and where the answer to it may live. // // **The distinction this exists for:** a shell, a display server and a private network have to be @@ -157,6 +200,12 @@ type Manifest struct { // Resources are what this module puts on a node, in the host's own vocabulary. Resources []map[string]any `json:"resources,omitempty"` + // Accesses are pre-existing, operator-owned paths this module is granted use of but does not + // own — a shared media library, a download spool (novox/hq ADR 0051). Distinct from a + // `directory` resource, which the mesh creates and owns: an access is mounted and nothing + // about it is reconciled, and several modules may name the same one without conflict. + Accesses []Access `json:"accesses,omitempty"` + // Computed names something in the control plane that works this module's resources out per // node, instead of them being fixed here. // @@ -415,6 +464,13 @@ func GrantID(provision, consumer string) string { return "grant-" + provision + // BoundID is the resource identity of the file a module is told about a provision in. func BoundID(requirement string) string { return "bound-" + requirement } +// AccessID is the resource identity of an operator-owned path this module is granted use of. +// +// Derived from the path rather than a name the module chose, so two modules granted the same +// access name the same identity within their own qualification — and neither has to invent a +// label for something that is not theirs. +func AccessID(path string) string { return "access-" + strings.TrimPrefix(path, "/") } + // Wants is everything that must be provided on the same node: what this module requires, and what // it contributes to. func (m Manifest) Wants() []string { @@ -730,6 +786,20 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s receives contributions to %q and does not provide it", m.Module, to)) } } + for _, a := range m.Accesses { + if !strings.HasPrefix(a.Path, "/") { + problems = append(problems, fmt.Sprintf( + "%s accesses %q, which is not an absolute path", m.Module, a.Path)) + } + switch a.At() { + case AccessRead, AccessReadWrite: + default: + problems = append(problems, fmt.Sprintf( + "%s accesses %q at mode %q; an access is %q or %q", + m.Module, a.Path, a.Mode, AccessRead, AccessReadWrite)) + } + } + for i, r := range m.Resources { id, _ := r["id"].(string) if id == "" { diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 13288b4..1ec6e01 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -566,9 +566,18 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held) ([]Held, []str // This costs no manifest field: the mesh already holds every resource of every module, so two // declaring one path or one unit are visible without either having to know about the other. A // declared claim is only for the abstract conflicts nothing in the resources reveals. +// +// **The refusal is about ownership, not use** (novox/hq ADR 0051, 04-ISSUES/036). Two modules +// owning one path is the fault this catches — the class of collision this repository keeps +// recording. Two modules *accessing* one operator-owned path is not a collision: it is the whole +// point of a media stack, where the library server, the managers and the download client must see +// the same directories. An access is not a resource and never enters the owner map, so co-access +// resolves with no refusal. What is refused is the contradiction — a path one module owns and +// another merely accesses — because shared data is the operator's and nobody's to own. func checkResources(modules []Manifest) []string { var problems []string owner := map[string]string{} + ownedPath := map[string]string{} // path → owning module, for the access check below for _, m := range modules { for _, r := range m.Resources { @@ -583,6 +592,30 @@ func checkResources(modules []Manifest) []string { "%s and %s both declare the %s %q", other, m.Module, field, value)) } owner[key] = m.Module + if field == "path" { + ownedPath[value] = m.Module + } + } + } + } + + // An accessed path is the operator's, so no module may declare it as one of its own + // (novox/hq ADR 0051). Refused here rather than silently tolerated: an owner would create and + // chown the very directory another module was told to expect the operator to provide, and the + // two intentions cannot both hold. Two modules *accessing* it, by contrast, is never checked, + // which is what lets the stack in 04-ISSUES/036 co-resolve. + for _, m := range modules { + for _, a := range m.Accesses { + switch other := ownedPath[a.Path]; other { + case "": + // Nobody owns it — the ordinary, correct case for shared data. + case m.Module: + problems = append(problems, fmt.Sprintf( + "%s both owns and accesses %q — it is one or the other", m.Module, a.Path)) + default: + problems = append(problems, fmt.Sprintf( + "%s accesses %q, which %s declares it owns — shared data is the operator's, "+ + "owned by no module (novox/hq ADR 0051)", m.Module, a.Path, other)) } } }