From 36d9eba51db9c1f659c5846d819dd416c0deb703 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 15:39:38 +0200 Subject: [PATCH 1/4] Each node has an object bucket of its own for files by digest (issue 1694, ADR 0486) --- go/declaration/declaration.go | 2 +- go/declaration/filereference.go | 30 +++++++++++++++++++++++++--- go/declaration/filereference_test.go | 22 +++++++++++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/go/declaration/declaration.go b/go/declaration/declaration.go index 60e5521..cec208d 100644 --- a/go/declaration/declaration.go +++ b/go/declaration/declaration.go @@ -326,7 +326,7 @@ type File struct { // Digest and Size say the content is not carried but fetched: a file over FileReferenceThreshold travels as // the sha256 digest of its content, "sha256:<64 hex>", and its size in bytes, in place of Content or Bytes - // (novox/hq ADR 0486 §1). The node-engine fetches the object of that name from FileBucket, checks what it + // (novox/hq ADR 0486 §1). The node-engine fetches the object of that name from FileBucketFor(node), checks what it // received against Digest and Size, and only then writes the file. Exclusive with Content, Sealed, Bytes, // VaultSealed and Secrets; sent only to an engine that reports FileReferencesCapability, as an older engine // refuses a field it does not know, whole. diff --git a/go/declaration/filereference.go b/go/declaration/filereference.go index 166022a..a0aa172 100644 --- a/go/declaration/filereference.go +++ b/go/declaration/filereference.go @@ -14,13 +14,37 @@ const ( // FileReferenceThreshold is the size above which a file travels by digest: a file of this many bytes or fewer // stays in the declaration as it is. FileReferenceThreshold = 64 * 1024 - // FileBucket is the object bucket of the bus that holds the contents, each under the hex of its sha256 digest - // (ObjectName). The controller alone writes it; a node-engine reads an object by name. - FileBucket = "declaration-files" + // FileBucketPrefix starts the name of a node's object bucket, FileBucketFor(node), which holds the contents of the + // files of that node's declaration, each under the hex of its sha256 digest (ObjectName). One bucket per node, + // so a grant names it and a literal `*` is never needed (ADR 0486 §2). The controller alone writes it; the + // node's engine reads an object by name. + FileBucketPrefix = "declaration-files-" + // FileStreamPrefix starts the name of the stream behind such a bucket, which the bus's grants name. + FileStreamPrefix = "OBJ_" // FileReferencesCapability is what a node-engine reports when it reads a file given as a digest and size. FileReferencesCapability = "file-references" ) +var nodeToken = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// FileBucketFor is the name of the object bucket of a node's files. The node name must be a name the bus accepts in a +// bucket and in a subject token: letters, digits, '_' and '-', so no '.', '*', '>' or space. +func FileBucketFor(node string) (string, error) { + if !nodeToken.MatchString(node) { + return "", fmt.Errorf("node %q; a node's bucket is named by letters, digits, '_' and '-' only", node) + } + return FileBucketPrefix + node, nil +} + +// FileStreamFor is the name of the stream behind a node's bucket, "OBJ_" and the bucket's name. +func FileStreamFor(node string) (string, error) { + b, err := FileBucketFor(node) + if err != nil { + return "", err + } + return FileStreamPrefix + b, nil +} + var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) // DigestOf is the digest a reference names for a content: "sha256:<64 lower-case hex>". diff --git a/go/declaration/filereference_test.go b/go/declaration/filereference_test.go index 6bf9be9..34bbc28 100644 --- a/go/declaration/filereference_test.go +++ b/go/declaration/filereference_test.go @@ -127,10 +127,30 @@ func TestWhichFilesTravelByDigest(t *testing.T) { } func TestTheSharedNames(t *testing.T) { - if FileBucket != "declaration-files" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 { + if FileBucketPrefix != "declaration-files-" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 { t.Fatal("a name changed") } if _, err := ObjectName("sha256:zz"); err == nil { t.Fatal("bad digest named an object") } } + +// 1694/AC6: each node has a bucket of its own, so a grant names it and never needs a `*` (ADR 0486 §2). +func TestEachNodeHasABucketOfItsOwn(t *testing.T) { + b, err := FileBucketFor("g14") + if err != nil || b != "declaration-files-g14" { + t.Fatalf("%q %v", b, err) + } + s, err := FileStreamFor("g14") + if err != nil || s != "OBJ_declaration-files-g14" { + t.Fatalf("%q %v", s, err) + } + for _, bad := range []string{"", "a.b", "a*", "*", ">", "a b", "a>", "a/b", "ä"} { + if _, err := FileBucketFor(bad); err == nil { + t.Errorf("bucket for %q accepted", bad) + } + if _, err := FileStreamFor(bad); err == nil { + t.Errorf("stream for %q accepted", bad) + } + } +} -- 2.54.0 From 86f9a1a5c5088b60f694324e84dc31f682156a1b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 15:48:22 +0200 Subject: [PATCH 2/4] Cite ADR 0488 in the per-node bucket's doc comments (issue 1694) --- go/declaration/filereference.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/go/declaration/filereference.go b/go/declaration/filereference.go index a0aa172..665b0fe 100644 --- a/go/declaration/filereference.go +++ b/go/declaration/filereference.go @@ -16,7 +16,7 @@ const ( FileReferenceThreshold = 64 * 1024 // FileBucketPrefix starts the name of a node's object bucket, FileBucketFor(node), which holds the contents of the // files of that node's declaration, each under the hex of its sha256 digest (ObjectName). One bucket per node, - // so a grant names it and a literal `*` is never needed (ADR 0486 §2). The controller alone writes it; the + // so a grant names it and a literal `*` is never needed (ADR 0486 §2, changed by ADR 0488 §1 and §2). The controller alone writes it; the // node's engine reads an object by name. FileBucketPrefix = "declaration-files-" // FileStreamPrefix starts the name of the stream behind such a bucket, which the bus's grants name. @@ -27,7 +27,8 @@ const ( var nodeToken = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) -// FileBucketFor is the name of the object bucket of a node's files. The node name must be a name the bus accepts in a +// FileBucketFor is the name of the object bucket of a node's files, "declaration-files-" (novox/hq ADR 0488 +// §4, which replaces the single bucket constant of issue 1694). The node name must be a name the bus accepts in a // bucket and in a subject token: letters, digits, '_' and '-', so no '.', '*', '>' or space. func FileBucketFor(node string) (string, error) { if !nodeToken.MatchString(node) { -- 2.54.0 From 216d2b4411325a78b724ab9dceee9265afe1d777 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 16:03:55 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Check=20every=20mesh=20node's=20bucket=20an?= =?UTF-8?q?d=20stream=20name,=20not=20only=20one=20(issue=201694,=20ADR=20?= =?UTF-8?q?0488=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/declaration/filereference_test.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/go/declaration/filereference_test.go b/go/declaration/filereference_test.go index 34bbc28..be34ce1 100644 --- a/go/declaration/filereference_test.go +++ b/go/declaration/filereference_test.go @@ -137,13 +137,15 @@ func TestTheSharedNames(t *testing.T) { // 1694/AC6: each node has a bucket of its own, so a grant names it and never needs a `*` (ADR 0486 §2). func TestEachNodeHasABucketOfItsOwn(t *testing.T) { - b, err := FileBucketFor("g14") - if err != nil || b != "declaration-files-g14" { - t.Fatalf("%q %v", b, err) - } - s, err := FileStreamFor("g14") - if err != nil || s != "OBJ_declaration-files-g14" { - t.Fatalf("%q %v", s, err) + for _, n := range []string{"ace", "g14", "novox", "shanks"} { + b, err := FileBucketFor(n) + if err != nil || b != "declaration-files-"+n { + t.Errorf("bucket for %q: %q %v", n, b, err) + } + s, err := FileStreamFor(n) + if err != nil || s != "OBJ_declaration-files-"+n { + t.Errorf("stream for %q: %q %v", n, s, err) + } } for _, bad := range []string{"", "a.b", "a*", "*", ">", "a b", "a>", "a/b", "ä"} { if _, err := FileBucketFor(bad); err == nil { -- 2.54.0 From 5792c3b959d5b5f2e0bd0fe5fda5eefaebae2614 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 16:57:31 +0200 Subject: [PATCH 4/4] Keep the unit level within its bound: the counter test counts 400 not 2000, the deadline test waits 50 ms not 1 s (issue 895) Both still prove what they did: the counter test still collides every run and fails when an increment writes without its revision; the deadline test fails when the read after a failed write takes a later deadline than the ask's. --- go/cli/refusal_test.go | 6 +++--- go/state_counter_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go/cli/refusal_test.go b/go/cli/refusal_test.go index 2adbc28..d05ec87 100644 --- a/go/cli/refusal_test.go +++ b/go/cli/refusal_test.go @@ -106,7 +106,7 @@ func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) { default: } }) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() start := time.Now() _, err = Ask(ctx, socket, aLineTheSocketCannotHold()) @@ -117,7 +117,7 @@ func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) { if !errors.Is(err, os.ErrDeadlineExceeded) { t.Fatalf("the error is not the deadline: %v", err) } - if waited > 3*time.Second { - t.Fatalf("waited %s with a deadline of 1s", waited) + if waited > time.Second { + t.Fatalf("waited %s with a deadline of 50ms", waited) } } diff --git a/go/state_counter_test.go b/go/state_counter_test.go index 764a1db..fb89617 100644 --- a/go/state_counter_test.go +++ b/go/state_counter_test.go @@ -138,11 +138,11 @@ func text(t *testing.T, a map[string]any) (string, bool) { return content[0].(map[string]any)["text"].(string), isError } -// Two writers incrementing one counter a thousand times each end at two thousand: every number handed out +// Two writers incrementing one counter two hundred times each end at four hundred: every number handed out // once, none lost, each writer's numbers rising — read, then written only at the revision read. func TestTwoWritersIncrementingOneCounterLoseNoUpdate(t *testing.T) { r := &casRuntime{keys: map[string]StateEntry{}, writes: map[string]int{}} - const each = 1000 + const each = 200 // enough to collide every run (about a third of writes are lost), cheap on a slow builder got := counting(t, r, func() (any, error) { var wg sync.WaitGroup numbers := make([][]uint64, 2) @@ -179,7 +179,7 @@ func TestTwoWritersIncrementingOneCounterLoseNoUpdate(t *testing.T) { } return len(seen), nil }) - if out, isError := text(t, got); isError || out != "2000" { + if out, isError := text(t, got); isError || out != fmt.Sprint(2*each) { t.Fatalf("two writers counted %s (error: %v)", out, isError) } var final uint64 @@ -190,7 +190,7 @@ func TestTwoWritersIncrementingOneCounterLoseNoUpdate(t *testing.T) { if r.lost == 0 { t.Fatal("the two writers never collided: the test proved nothing about contention") } - t.Logf("2000 increments, %d writes lost to the other writer and tried again", r.lost) + t.Logf("%d increments, %d writes lost to the other writer and tried again", 2*each, r.lost) if r.writes["create"] != 1 || r.writes["update"] != 2*each-1 || r.writes["put"] != 0 { t.Fatalf("written by %v: one create, then updates at the revision read, never a put", r.writes) } -- 2.54.0