Merge pull request 'A module accesses operator-owned data, it does not own it (ADR 0051)' (#8) from feat/shared-data-access into main

This commit was merged in pull request #8.
This commit is contained in:
2026-09-05 22:48:11 +02:00
4 changed files with 221 additions and 0 deletions
+108
View File
@@ -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")
}
}
+10
View File
@@ -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, "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) { for _, to := range sortedKeys(m.Secrets) {
var found *Needed var found *Needed
for i, n := range r.Needs { for i, n := range r.Needs {
+70
View File
@@ -57,6 +57,49 @@ func (c Claim) At() string {
return c.Scope 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. // 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 // **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 are what this module puts on a node, in the host's own vocabulary.
Resources []map[string]any `json:"resources,omitempty"` 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 // Computed names something in the control plane that works this module's resources out per
// node, instead of them being fixed here. // 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. // BoundID is the resource identity of the file a module is told about a provision in.
func BoundID(requirement string) string { return "bound-" + requirement } 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 // Wants is everything that must be provided on the same node: what this module requires, and what
// it contributes to. // it contributes to.
func (m Manifest) Wants() []string { 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)) "%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 { for i, r := range m.Resources {
id, _ := r["id"].(string) id, _ := r["id"].(string)
if id == "" { if id == "" {
+33
View File
@@ -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 // 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 // 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. // 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 { func checkResources(modules []Manifest) []string {
var problems []string var problems []string
owner := map[string]string{} owner := map[string]string{}
ownedPath := map[string]string{} // path → owning module, for the access check below
for _, m := range modules { for _, m := range modules {
for _, r := range m.Resources { 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)) "%s and %s both declare the %s %q", other, m.Module, field, value))
} }
owner[key] = m.Module 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))
} }
} }
} }