Compare commits

..
Author SHA1 Message Date
jschoubben e2136ec0c9 The vault takes a handing whole once, refusals and all, and names the refused users in its last adoption (review of the adoption, ADR 0547 clause 9) 2026-10-26 12:03:14 +01:00
jschoubben f0fd53281b The vault says once why a look of the bus module's have adopted nothing, and again when it changes (ADR 0547 clause 9) 2026-10-26 11:37:14 +01:00
jschoubben fb039250f6 The vault adopts the hashes of the bus accounts it holds no value for from the bus node's handing (ADR 0547 clause 9, issue 1945)
take-over waits on a hash for every user of the loaded list, and the vault holds no value for the controller's own
account, the node accounts and the enrolled devices. The bus node's engine now hands every loaded user's hash in the
bus module's have, sealed to the vault's key (mesh-host and mesh-sdk on the same branch). The vault answers its key in
node-key, reads the haves (manifest reads node-engine.have, granted from the manifest, no controller change), watches
the bus module's have, opens the handing with its own key, and writes each user it holds no value for into
bus-accounts marked adopted, with the list and node it came from, saying account-hashed. It never touches a user whose
covered account it holds or whose key is its own hash, writes an adopted key again only for another hash, and refuses
a handing from any node the assignments do not name as holding the bus module.

Tools: account_adopt adopts now from the have as the watch does; secret_own, unnarrowed by module, lists the adopted
accounts and the last adoption.
2026-10-26 11:36:33 +01:00
23 changed files with 573 additions and 423 deletions
+9 -107
View File
@@ -15,13 +15,6 @@ package main
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
// - **A store that does not answer does not lose where a message is shown, while the desk runs.** A message
// is shown first and its notification ids kept after; when the state store does not answer then (the bus
// reloading its accounts, 2026-10-26 09:18: the very outage that raised the condition), the work is taken
// all the same and the ids are held in memory, recalled before the store, and kept at the start of the next
// work and at the minute's standing. Otherwise the clearing of that message finds no notification to
// replace and the urgent one stays on screen for good. A desk that restarts while it still holds some loses
// where those are shown; the log says so when they are held.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
@@ -100,9 +93,6 @@ type Desk struct {
pause map[string]pauseSeen
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
lost map[string]time.Time
// unkept is what was shown and not yet kept in the store, because it did not answer: recalled from here
// before the store, and kept at the next piece of work.
unkept map[string]Shown
}
func (d *Desk) init() {
@@ -117,9 +107,6 @@ func (d *Desk) init() {
if d.lost == nil {
d.lost = map[string]time.Time{}
}
if d.unkept == nil {
d.unkept = map[string]Shown{}
}
if d.Now == nil {
d.Now = time.Now
}
@@ -151,7 +138,6 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
}
d.KeepUnkept()
machines := d.Machines()
if m.Secret {
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
@@ -181,16 +167,12 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
switch {
case edit:
var err error
if before, err = d.recall(m.Handle); err != nil {
if before, err = d.Store.Get(m.Handle); err != nil {
return err
}
if before == nil {
d.Logf("[desk-channel] an edit of %s, which nothing here records as shown: shown as a new message; "+
"a notification it was to replace, if any, stays", m.Handle)
}
case !m.Secret:
var err error
if before, err = d.recall(m.Handle); err != nil {
if before, err = d.Store.Get(m.Handle); err != nil {
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
before = nil
}
@@ -236,91 +218,12 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
return nil
}
d.keep(m.Handle, s)
if err := d.Store.Put(m.Handle, s); err != nil {
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
}
return nil
}
// keep keeps what a handle shows in the store. When the store does not answer it is held in memory, where an
// edit, a reminder and a click still find it, and kept later (KeepUnkept). The work is taken either way: offered
// again it would race the message's own later edits, and a redelivery is bounded where an outage is not.
func (d *Desk) keep(handle string, s Shown) {
err := d.Store.Put(handle, s)
d.mu.Lock()
defer d.mu.Unlock()
if err == nil {
delete(d.unkept, handle)
return
}
d.unkept[handle] = s
d.Logf("[desk-channel] shown, and where it is shown could not be kept yet (%v): held in memory, so its edit "+
"and clearing still reach it, and kept when the store answers; %d held, lost if the desk restarts first",
err, len(d.unkept))
}
// KeepUnkept keeps in the store what it did not answer for before: at the start of each piece of work and at
// the minute's standing. It stops at the first write the store does not answer, so one call waits on it once.
func (d *Desk) KeepUnkept() {
d.init()
d.mu.Lock()
held := make(map[string]Shown, len(d.unkept))
for h, s := range d.unkept {
held[h] = s
}
d.mu.Unlock()
for h, s := range held {
if err := d.Store.Put(h, s); err != nil {
return
}
d.mu.Lock()
if cur, ok := d.unkept[h]; ok && sameShown(cur, s) {
delete(d.unkept, h)
}
d.mu.Unlock()
d.Logf("[desk-channel] where %s is shown is kept now", h)
}
}
func (d *Desk) unkeptOf(handle string) (Shown, bool) {
d.mu.Lock()
defer d.mu.Unlock()
s, ok := d.unkept[handle]
return s, ok
}
// recall is what a handle shows: from memory when the store did not keep it yet, else from the store.
func (d *Desk) recall(handle string) (*Shown, error) {
if s, ok := d.unkeptOf(handle); ok {
return &s, nil
}
return d.Store.Get(handle)
}
// handles is every handle shown: the store's and those held in memory.
func (d *Desk) handles() ([]string, error) {
hs, err := d.Store.Handles()
d.mu.Lock()
defer d.mu.Unlock()
seen := map[string]bool{}
for _, h := range hs {
seen[h] = true
}
for h := range d.unkept {
if !seen[h] {
hs = append(hs, h)
}
}
if err != nil && len(d.unkept) == 0 {
return nil, err
}
return hs, nil
}
func sameShown(a, b Shown) bool {
x, err1 := json.Marshal(a)
y, err2 := json.Marshal(b)
return err1 == nil && err2 == nil && string(x) == string(y)
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
urgency := "normal"
@@ -405,12 +308,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
func (d *Desk) find(ticket string) (string, *Shown) {
handles, err := d.handles()
handles, err := d.Store.Handles()
if err != nil {
return "", nil
}
for _, h := range handles {
s, err := d.recall(h)
s, err := d.Store.Get(h)
if err != nil || s == nil {
continue
}
@@ -453,9 +356,9 @@ func (d *Desk) Started(event, machine string) int {
d.lost[machine] = d.Now()
d.mu.Unlock()
n := 0
if handles, err := d.handles(); err == nil {
if handles, err := d.Store.Handles(); err == nil {
for _, h := range handles {
if s, err := d.recall(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if _, here := s.On[machine]; here {
n++
}
@@ -569,7 +472,6 @@ func (d *Desk) Status() map[string]any {
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
"a store that does not answer does not lose where a message is shown while the desk runs: it is held in memory, its edit and clearing still replace it in place, and it is kept when the store answers again; a desk restarted before then loses where those are shown, and its log said how many it held",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -364,64 +363,9 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
}
}
// downStore is a store that does not answer while down: the bus reloading its accounts (2026-10-26 09:18).
type downStore struct {
memShown
down *bool
}
func (s downStore) Get(h string) (*Shown, error) {
if *s.down {
return nil, errors.New("nats: no responders available for request")
}
return s.memShown.Get(h)
}
func (s downStore) Put(h string, v Shown) error {
if *s.down {
return errors.New("nats: no responders available for request")
}
return s.memShown.Put(h, v)
}
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed. And
// (2026-10-26 09:18, an urgent "A new build of mesh-controller cannot be delivered" left on screen for hours
// after its clearing): a message shown while the store does not answer keeps where it is shown in memory, its
// work is taken, its clearing replaces it in place, and it is kept once the store answers.
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
// The raise, then its clearing, taken while the store does not answer; the store then stays down or comes
// back. Each is taken (never offered again, where a redelivered raise would follow its clearing), the
// clearing replaces the urgent notification in place, and the screen and the store end on the clearing.
for _, back := range []bool{false, true} {
t.Run(fmt.Sprintf("the store does not answer, back %v", back), func(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
s, kept := store["m9"]
if back != kept || (kept && s.Message.Title != "Cleared: a new build can be delivered") {
t.Fatalf("store back %v, kept %v: %+v", back, kept, store)
}
if got, _ := d.recall("m9"); got == nil || got.Message.Title != "Cleared: a new build can be delivered" {
t.Fatalf("recalled %+v", got)
}
})
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
@@ -100,7 +100,6 @@ func run(d *Desk) {
}()
go func() {
for {
d.KeepUnkept()
d.SayStanding()
time.Sleep(time.Minute)
}
@@ -41,8 +41,6 @@ type PackageResult struct {
Tests int `json:"tests"`
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
NoTests bool `json:"no_tests,omitempty"`
// Skipped is how many of the package's tests skipped: a pass that skipped proved less than it says, so the checker keeps no verdict for it.
Skipped int `json:"skipped,omitempty"`
}
// TestTime is one test and the time it took.
@@ -229,7 +227,6 @@ func (r *reader) event(ev event) {
r.res.Tests++
if ev.Action == "skip" {
r.res.Skipped++
p.Skipped++
}
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
if ev.Action == "fail" {
@@ -101,25 +101,3 @@ func TestALoggedPanicIsNotTheCause(t *testing.T) { // 1901/AC4
t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", res.Failed)
}
}
// A package counts its own skipped tests, so the checker withholds a kept verdict from that package alone (issue 890).
func TestEachPackageCountsItsOwnSkippedTests(t *testing.T) {
log := `{"Action":"run","Package":"m/a","Test":"TestA"}
{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0}
{"Action":"run","Package":"m/b","Test":"TestB"}
{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0}
{"Action":"pass","Package":"m/a","Elapsed":0}
{"Action":"pass","Package":"m/b","Elapsed":0}
`
res := &TestResult{}
rd := newReader(res, ".")
rd.read(log)
rd.finish()
got := map[string]int{}
for _, p := range res.Packages {
got[p.Package] = p.Skipped
}
if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 {
t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got)
}
}
@@ -17,11 +17,11 @@ import (
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// realMachine gives the tests the real toolchain of the module's own lock and makes three workspaces holding one fixture
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1), copied into the test's
// own cache: the integration level is given its toolchains read-only and no network, so it is never installed from the
// registry here. Where no Node build module has installed one (a node without it, a desk), the test is skipped by
// name: an install from the public registry inside a shut-off level only fails, after 70 s of npm's retries.
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
// module does, which needs the network.
func realMachine(t *testing.T) (*Module, string) {
t.Helper()
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
@@ -44,19 +44,16 @@ func realMachine(t *testing.T) (*Module, string) {
run := forgekey.ExecRunnerFor(0)
m := &Module{Node: "ace", Recipe: lock, Work: work, Run: run, Ctx: context.Background(),
Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) {
return Registry{}, fmt.Errorf("the integration test installs no toolchain from a registry: it has no network")
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
}}}
withStore(t, m)
from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json"))
if from == "" {
t.Skipf("NOT RUN: no Node toolchain is installed under %s for this account to read (no Node build module on this node); "+
"the integration level has no network to install one, so this runs on a node that holds the Node build module", filepath.Join(CacheDir, "toolchain"))
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
}
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { // present: opens it, installs nothing
t.Fatalf("the toolchain copied from %s cannot be used: %v", from, err)
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
}
for _, id := range []string{"w1", "w2", "w3"} {
for _, id := range []string{"w1", "w2"} {
for p, body := range map[string]string{
"modules/m/module.json": `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`,
"modules/m/package.json": `{"name":"m","type":"module","scripts":{"test":"node -e \"process.exit(0)\""}}`,
@@ -68,36 +65,10 @@ func realMachine(t *testing.T) (*Module, string) {
return m, work
}
// 1490/AC2, 1491 and issue 1869, in one test so the toolchain is copied once: the real type-check and test script of the
// fixture pass the unit level; a package whose test imports its own build output (as mesh-sdk's do) passes the unit level,
// the test step emitting it with the real tsc, shut off, before the test script runs; and the fixture TypeScript bundle
// importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal digests, its archive holding the bundled
// .js, the .serve.mjs launcher and package.json. The unit levels run in w3, so the two builds' workspaces stay alike.
func TestTheFixturePassesTheUnitLevelAndBuildsTwiceToOneDigest(t *testing.T) {
// 1490/AC2: a fixture TypeScript bundle importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal
// digests, and its archive holds the bundled .js, the .serve.mjs launcher and package.json.
func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
m, work := realMachine(t)
out, err := m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/m", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
t.Errorf("the fixture's unit level: result = %+v", r)
}
for p, body := range map[string]string{
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
"modules/p/src/index.ts": "export const answer: number = 42;\n",
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
"test('the build is there', () => assert.equal(answer, 42));\n",
} {
write(t, filepath.Join(work, "w3", "tree", filepath.FromSlash(p)), body, 0o644)
}
out, err = m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/p", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
t.Errorf("the package importing its build: result = %+v", r)
}
var digests []string
for _, id := range []string{"w1", "w2"} {
out, err := m.build(context.Background(), map[string]any{"workspace": id, "module": "modules/m", "artifact": "tools"})
@@ -118,6 +89,43 @@ func TestTheFixturePassesTheUnitLevelAndBuildsTwiceToOneDigest(t *testing.T) {
}
}
// 1491: the real type-check and test script of the same fixture pass the unit level.
func TestTheFixturePassesTheUnitLevel(t *testing.T) {
m, _ := realMachine(t)
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
t.Errorf("result = %+v", r)
}
}
// Issue 1869: a package whose test imports its own build output (as mesh-sdk's do) passes the unit level: the test step
// emits it with the real tsc, shut off, before the test script runs.
func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)")
}
m, work := realMachine(t)
for p, body := range map[string]string{
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
"modules/p/src/index.ts": "export const answer: number = 42;\n",
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
"test('the build is there', () => assert.equal(answer, 42));\n",
} {
write(t, filepath.Join(work, "w1", "tree", filepath.FromSlash(p)), body, 0o644)
}
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/p", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
t.Errorf("result = %+v", r)
}
}
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
@@ -515,9 +515,6 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran
// judgeSet holds the whole record set to its 4 rules and keeps what fails, which the module's health answers.
func (k *KB) judgeSet(ctx context.Context) []Finding {
if _, err := k.RecomputeMarks(ctx); err != nil {
k.logf("[mesh-knowledge] %v", err)
}
all, err := k.Store.All(ctx)
if err != nil {
k.logf("[mesh-knowledge] the whole record set cannot be read to judge it: %v", err)
@@ -99,7 +99,7 @@ func serve(k *KB, status string) {
k.kick = make(chan struct{}, 1)
go hear(k)
go background(k)
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k), marksTool(k))); err != nil {
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k))); err != nil {
logf("%v", err)
os.Exit(1)
}
@@ -119,7 +119,7 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
}
h, _ := watch["health"].(map[string]any)
if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool ||
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool, MarksTool}) {
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
t.Fatalf("watch %v, tools %v", watch, m.Tools)
}
// The watch's verdict is whether the database serves, so its check names the provision it exercises: while the
@@ -1,137 +0,0 @@
package main
// The marks on a clause (changed, and by what) follow the accepted links, both ways: an acceptance that adds a
// changes link sets the mark (accept), and a later accepted version that drops the link withdraws it here. Without
// this a mark outlived its link, so holds and the whole-set rule `partial` said "changed" while links and get said
// nothing (ADR 0536 v6 changed ADR 0525 §10; v12 only cited it).
import (
"context"
"fmt"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// MarksTool is the module's tool that recomputes every mark now.
const MarksTool = "knowledge_marks"
// markedBy reads "ADR 0536 §4" or "research 045" as the record it names.
func markedBy(s string) (Key, bool) {
f := strings.Fields(s)
if len(f) < 2 {
return Key{}, false
}
n, err := strconv.Atoi(f[1])
if err != nil {
return Key{}, false
}
switch strings.ToLower(f[0]) {
case "adr":
return Key{KindDecision, n}, true
case "research":
return Key{KindResearch, n}, true
}
return Key{}, false
}
// StaleMarks drops, on every record of the set, each "changed by" mark whose changing record is accepted and no
// longer links the clause with a changes link; a clause left with no mark is in force again. A mark of a record
// not accepted (a change confirmed ahead of its acceptance) or of a record not held stays. It changes the
// records in place and answers the keys it changed with what it did.
func StaleMarks(set []*Record) map[Key][]string {
byKey := map[Key]*Record{}
for _, r := range set {
byKey[r.Key()] = r
}
did := map[Key][]string{}
for _, t := range set {
if t.Status == StatusSuperseded {
continue
}
for i := range t.Clauses {
c := &t.Clauses[i]
if c.Status != ClauseChanged {
continue
}
var kept []string
for _, by := range c.ChangedBy {
from, ok := markedBy(by)
src := byKey[from]
if !ok || src == nil || !src.Accepted() || changesClause(src, t.Key(), c.N) {
kept = append(kept, by)
continue
}
did[t.Key()] = append(did[t.Key()], fmt.Sprintf("§%d no longer changed by %s", c.N, by))
}
if len(kept) != len(c.ChangedBy) {
c.ChangedBy = kept
if len(kept) == 0 {
c.Status = ClauseInForce
}
}
}
}
return did
}
func changesClause(src *Record, to Key, clause int) bool {
for _, l := range src.Links {
if k, ok := l.To.Record(); ok && l.Kind == LinkChanges && k == to && l.To.Clause == clause {
return true
}
}
return false
}
// RecomputeMarks withdraws every stale mark of the record set now, each a new version of the record it frees, and
// says what it withdrew. judgeSet runs it first, so an acceptance, the background work every few minutes and a
// start all agree with the links.
func (k *KB) RecomputeMarks(ctx context.Context) ([]string, error) {
all, err := k.Store.All(ctx)
if err != nil {
return nil, fmt.Errorf("the record set cannot be read to recompute its marks: %v", err)
}
set := make([]*Record, len(all))
for i, e := range all {
set[i] = e.Current.Clone()
}
did := StaleMarks(set)
var said []string
for i, e := range all {
what := did[e.Current.Key()]
if len(what) == 0 {
continue
}
why := strings.Join(what, "; ") + " (its changing record's accepted links no longer change it)"
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: why, Record: set[i]}, true, false, e.Revision); err != nil {
k.logf("[mesh-knowledge] the marks of %s could not be recomputed (%v): the next judgement tries again", e.Current.Key(), err)
continue
}
k.emit(EventChanged, set[i], "the knowledge base", why, e.Versions+1)
said = append(said, fmt.Sprintf("%s: %s", e.Current.Key(), why))
}
return said, nil
}
func marksTool(k *KB) stdio.Tool {
return stdio.Tool{Name: MarksTool,
Description: "Recompute every clause's changed and changed-by marks from the accepted links now, withdrawing each mark whose " +
"changing record no longer links the clause (the clause is in force again), and answer what it withdrew. The " +
"knowledge base does this on every acceptance and every few minutes; this runs it at once.",
Input: schema(nil, map[string]any{}),
Run: func(m map[string]any) (any, error) {
if _, err := readArgs(MarksTool, m); err != nil {
return nil, err
}
k.mu.Lock()
said, err := k.RecomputeMarks(context.Background())
k.mu.Unlock()
if err != nil {
return nil, err
}
findings := k.judgeSet(context.Background())
return map[string]any{"withdrawn": said, "findings": len(findings)}, nil
}}
}
@@ -117,37 +117,6 @@ func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLo
})
}
// A mark follows the accepted links: a later accepted version that drops a changes link withdraws the clause's
// mark, so holds, get and the rule `partial` agree with links (ADR 0536 v6 changed ADR 0525 §10; v12 only cited it).
func TestACorrectionThatDropsAChangesLinkWithdrawsTheClausesMark(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
w := imported(t, s)
n := w.reserved(t, "author@g14")
a := valid(n) // changes 0276 §1
w.accepted(t, "author@g14", a)
if c := mustGet(t, s, Key{KindDecision, 276}).Current.Clause(1); c.Status != ClauseChanged || len(c.ChangedBy) != 1 {
t.Fatalf("0276 §1 is not marked changed by the acceptance: %+v", c)
}
a.Links = a.Links[:1] // only extends 0276 now
a.Insight = "it no longer changes 0276 §1"
w.accepted(t, "author@g14", a)
c := mustGet(t, s, Key{KindDecision, 276}).Current.Clause(1)
if c.Status != ClauseInForce || len(c.ChangedBy) != 0 {
t.Fatalf("0276 §1 keeps a mark no accepted link makes: %+v", c)
}
f, _ := s.Findings(context.Background())
for _, x := range f {
if x.Rule == "partial" {
t.Fatalf("a stale mark is still a finding: %+v", x)
}
}
// Recomputing again changes nothing.
if said, err := w.k.RecomputeMarks(context.Background()); err != nil || len(said) != 0 {
t.Fatalf("a second recompute withdrew %v (%v)", said, err)
}
})
}
// Each of the 4 whole-set rules, failing on a record set made to break it.
func TestEachWholeSetRuleFails(t *testing.T) {
acc := func(n int, links ...Link) *Record {
+1 -2
View File
@@ -58,8 +58,7 @@
],
"tools": [
"knowledge_health",
"knowledge_highest",
"knowledge_marks"
"knowledge_highest"
],
"emits": [
"reserved",
+11 -4
View File
@@ -14,8 +14,8 @@ package main
// bus refused is kept as owed and tried again at the next minute pass, never lost silently.
//
// The hashes of accounts the vault holds no value for (enrolment users, persons, the controller's own) are adopted once,
// before take-over, from the list the bus loaded (clause 9's adoption): such a key is marked adopted and is replaced by
// the vault's own hash the moment the vault holds the account's value, never the other way round.
// before take-over, from the list the bus loaded (clause 9's adoption, adopt.go): such a key is marked adopted and is
// replaced by the vault's own hash the moment the vault holds the account's value, never the other way round.
import (
"encoding/json"
@@ -48,14 +48,19 @@ type AccountHash struct {
Generation int `json:"generation,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
At time.Time `json:"at"`
// Adopted marks a hash taken from the list the bus loaded, for an account the vault holds no value for.
Adopted bool `json:"adopted,omitempty"`
// Adopted marks a hash taken from the list the bus loaded, for an account the vault holds no value for; From is
// that list's digest and Node the node that loaded it and handed it (adopt.go).
Adopted bool `json:"adopted,omitempty"`
From string `json:"from,omitempty"`
Node string `json:"node,omitempty"`
}
// AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone.
type AccountStore interface {
Get(user string) (*AccountHash, error)
Put(a AccountHash) error
// Users is every user with a key.
Users() ([]string, error)
}
// busAccounts is AccountStore over the vault's own state through its runtime.
@@ -73,6 +78,8 @@ func (busAccounts) Get(user string) (*AccountHash, error) {
return &a, nil
}
func (busAccounts) Users() ([]string, error) { return stdio.State(BusAccountsState).Keys() }
func (busAccounts) Put(a AccountHash) error {
_, err := stdio.State(BusAccountsState).Put(a.User, a)
return err
+276
View File
@@ -0,0 +1,276 @@
package main
// The vault adopts the hashes of the bus accounts it holds no value for (novox/hq ADR 0547 clause 9, issue 1945).
//
// The controller's own account, the node accounts and the enrolled devices were never handed to the vault, so it cannot
// hash them, and the grants part's list would leave them out. The one part that has their hashes is the node-engine of
// the node holding mesh-broker, in the list the bus loaded: it hands every loaded user's hash in the bus module's have,
// sealed to this vault's key (mesh-sdk delivery.AccountsHanded), which it learns from the vault's answer to node-key.
// The vault reads that have (its manifest `reads` node-engine.have), as a watch of the bus module's have and again by
// hand with the tool `account_adopt`, and opens it with its own key.
//
// Of the users handed it takes only those it holds no value for: a user whose covered account it holds, or whose key in
// bus-accounts is its own hash, is never touched (the hash it made from the value is the one that holds). Each other
// user's hash is written to bus-accounts.<user> marked adopted, with the list it came from, saying account-hashed; an
// adopted key is written again only when the loaded list carries another hash for the user. A handing from any node but
// the one the assignments name as holding the bus module is refused: a node-engine writes only its own node's haves, so
// that node is the one that loaded the list.
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// HaveRead is the node-engines' have bucket as the vault reads it (its manifest's `reads`).
const HaveRead = "node-engine.have"
// BusModule is the module holding mesh-broker, whose have on its node carries the handing.
const BusModule = "nats"
// HaveReader reads one key of the have bucket: nil when there is none.
type HaveReader func(key string) ([]byte, error)
// Adoption is what one adoption did: never a hash.
type Adoption struct {
Node string `json:"node"`
// From is the digest of the loaded list the handing is of; Handed how many users it holds.
From string `json:"from,omitempty"`
Handed int `json:"handed"`
// Adopted are the users whose hash was written now; Same those adopted before with the same hash; Own those the
// vault holds a value for, never adopted.
Adopted []string `json:"adopted"`
Same int `json:"same"`
Own int `json:"own"`
Refused map[string]string `json:"refused,omitempty"`
At time.Time `json:"at"`
By string `json:"by"`
}
// adoptionKept is what the vault keeps of its last adoption, for its tools and to skip a have that says the same.
type adoptionKept struct {
last *Adoption
handed string // the From and To of the handing last taken in a complete pass
failed string // why the last look found nothing to adopt, or ""
}
// busNode is the node the assignments name as holding the bus module: exactly one.
func (v *Vault) busNode() (string, error) {
if v.Assignments.Keys == nil {
return "", errors.New("the vault cannot read the assignments, so it cannot tell which node holds the bus")
}
keys, err := v.Assignments.Keys()
if err != nil {
return "", fmt.Errorf("the assignments' keys could not be read: %w", err)
}
var nodes []string
for _, k := range keys {
if node, module, ok := strings.Cut(k, "."); ok && module == BusModule {
nodes = append(nodes, node)
}
}
sort.Strings(nodes)
switch len(nodes) {
case 1:
return nodes[0], nil
case 0:
return "", fmt.Errorf("no node is assigned the bus module %s", BusModule)
}
return "", fmt.Errorf("%d nodes are assigned the bus module %s (%s): the vault adopts from one alone", len(nodes),
BusModule, strings.Join(nodes, ", "))
}
// AdoptNow is the tool account_adopt: the bus node's have read now and adopted, though it says the same as before.
func (v *Vault) AdoptNow(caller string) (Adoption, error) {
if v.Haves == nil {
return Adoption{}, errors.New("the vault cannot read the have bucket (" + HaveRead + ")")
}
node, err := v.busNode()
if err != nil {
return Adoption{}, err
}
raw, err := v.Haves(node + "." + BusModule)
if err != nil {
return Adoption{}, fmt.Errorf("%s %s.%s could not be read: %w", HaveRead, node, BusModule, err)
}
if raw == nil {
return Adoption{}, fmt.Errorf("%s holds no have of %s on %s", HaveRead, BusModule, node)
}
return v.AdoptFrom(node, raw, someoneOr(caller, "a tool"), true)
}
// AdoptFrom adopts from the have of the bus module on node: unless force, nothing when it hands what the last complete
// adoption took.
func (v *Vault) AdoptFrom(node string, raw []byte, by string, force bool) (Adoption, error) {
a, err := v.adopt(node, raw, by, force)
v.mu.Lock()
switch {
case err != nil:
v.adoption.failed = err.Error()
case a.At.IsZero():
default:
v.adoption.failed = ""
v.adoption.last = &a
}
v.mu.Unlock()
if err == nil && len(a.Adopted) > 0 {
v.log("adopted the hashes of %d bus accounts it holds no value for from %s's loaded list %s (%s), asked by %s",
len(a.Adopted), node, a.From, strings.Join(a.Adopted, ", "), by)
}
if len(a.Refused) > 0 {
v.log("ADOPTION REFUSED %d of the users %s handed: %v", len(a.Refused), node, a.Refused)
}
return a, err
}
func (v *Vault) adopt(node string, raw []byte, by string, force bool) (Adoption, error) {
if v.Accounts == nil {
return Adoption{}, errors.New("the vault has no state of bus accounts to write to")
}
bus, err := v.busNode()
if err != nil {
return Adoption{}, err
}
if node != bus {
return Adoption{}, fmt.Errorf("%s does not hold the bus (the assignments name %s): its handing is not adopted",
node, bus)
}
have, err := delivery.DecodeHave(raw)
if err != nil {
return Adoption{}, fmt.Errorf("the have of %s on %s is not a have: %w", BusModule, node, err)
}
if have.UserList == nil || have.UserList.Handed == nil {
return Adoption{}, fmt.Errorf("the have of %s on %s hands no hashes yet (its node-engine hands them once it "+
"heard the vault's key in its answer to node-key, and writes the list)", BusModule, node)
}
h := *have.UserList.Handed
handed := h.From + " " + h.To
v.mu.Lock()
seen := v.adoption.handed == handed
v.mu.Unlock()
if seen && !force {
return Adoption{}, nil
}
accounts, err := delivery.OpenHanded(h, v.Own.PublicKey, v.Own.PrivateKey)
if err != nil {
return Adoption{}, err
}
v.hashMu.Lock()
defer v.hashMu.Unlock()
held, err := v.heldUsers()
if err != nil {
return Adoption{}, err
}
a := Adoption{Node: node, From: h.From, Handed: len(accounts), Adopted: []string{}, At: v.Now().UTC(), By: by}
refuse := func(user, why string) {
if a.Refused == nil {
a.Refused = map[string]string{}
}
a.Refused[user] = why
}
for _, acc := range accounts {
if !delivery.IsBcrypt(acc.Hash) || !namePart.MatchString(acc.User) {
refuse(acc.User, "not a bus user with a bcrypt hash")
continue
}
if held[acc.User] {
a.Own++
continue
}
was, err := v.Accounts.Get(acc.User)
switch {
case err != nil:
refuse(acc.User, "its key could not be read: "+err.Error())
continue
case was != nil && !was.Adopted:
a.Own++
continue
case was != nil && was.Hash == acc.Hash:
a.Same++
continue
}
if err := v.Accounts.Put(AccountHash{User: acc.User, Hash: acc.Hash, At: a.At, Adopted: true, From: h.From,
Node: node}); err != nil {
refuse(acc.User, "its key could not be written: "+err.Error())
continue
}
a.Adopted = append(a.Adopted, acc.User)
if v.Emit != nil {
if err := v.Emit(AccountHashedEvent, map[string]any{"user": acc.User, "adopted": true, "from": h.From,
"node": node, "at": a.At.Format(time.RFC3339Nano)}); err != nil {
v.log("the adopted hash of %s is written, but %s could not be said: %v", acc.User, AccountHashedEvent, err)
}
}
}
// A complete pass, refusals and all: the same handing is not opened again at the next change of the have (a
// refused user stays refused until the list changes, and the last adoption names it).
v.mu.Lock()
v.adoption.handed = handed
v.mu.Unlock()
return a, nil
}
// heldUsers is the bus user of every covered account the vault holds a value for.
func (v *Vault) heldUsers() (map[string]bool, error) {
records, err := v.Store.List()
if err != nil {
return nil, err
}
out := map[string]bool{}
for _, r := range records {
if !Covered(r.Name) {
continue
}
value, err := openAtRest(r, v)
if err != nil {
return nil, fmt.Errorf("the vault could not open its copy of %s, so it cannot tell whose value it holds: %w",
r.Name, err)
}
if user, _, err := accountOf(r.Name, value); err == nil {
out[user] = true
}
}
return out, nil
}
// adoptedRows is every adopted key of bus-accounts, for secret_own: never a hash.
func (v *Vault) adoptedRows() (rows []map[string]any, err error) {
if v.Accounts == nil {
return nil, nil
}
users, err := v.Accounts.Users()
if err != nil {
return nil, err
}
sort.Strings(users)
for _, u := range users {
a, err := v.Accounts.Get(u)
if err != nil || a == nil || !a.Adopted {
continue
}
rows = append(rows, map[string]any{"user": a.User, "adoptedAt": a.At, "from": a.From, "node": a.Node})
if len(rows) >= ownRowsMax {
break
}
}
return rows, nil
}
// adoptionSaid is the last adoption and why the last look adopted nothing, for the tools.
func (v *Vault) adoptionSaid() map[string]any {
v.mu.Lock()
defer v.mu.Unlock()
out := map[string]any{"last": v.adoption.last}
if v.adoption.last != nil && len(v.adoption.last.Refused) > 0 {
out["refused"] = v.adoption.last.Refused
}
if v.adoption.failed != "" {
out["why"] = v.adoption.failed
}
return out
}
@@ -48,6 +48,16 @@ func (f *fakeAccounts) Put(a AccountHash) error {
return nil
}
func (f *fakeAccounts) Users() ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for u := range f.keys {
out = append(out, u)
}
return out, nil
}
// hashOf says whether the key of user is the hash of password at generation, of the vault's own value.
func (f *fakeAccounts) hashOf(user, password string, generation int) bool {
f.mu.Lock()
+46 -1
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -50,10 +51,50 @@ func build() *Env {
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say, Accounts: busAccounts{}}
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave}
return env
}
// readHave is one key of the node-engines' have bucket, read through the runtime under the vault's own account (its
// manifest `reads` it), or nil when there is none.
func readHave(key string) ([]byte, error) {
e, err := stdio.State(HaveRead).Get(key)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
}
// watchHandings adopts from the bus module's have whenever it changes (adopt.go): a watch, never a poll. It answers
// whether the watch began; one that did not is begun again at the next minute pass.
func watchHandings(env *Env) bool {
if env.Vault == nil {
return true
}
// The have changes at every look of the bus module's health: why nothing was adopted is said once, and again when
// it changes.
var said string
err := stdio.State(HaveRead).Watch("*."+BusModule, func(c stdio.StateChange) error {
if c.Op == "delete" || len(c.Value) == 0 {
return nil
}
node := strings.TrimSuffix(c.Key, "."+BusModule)
_, err := env.Vault.AdoptFrom(node, c.Value, "the watch of "+HaveRead, false)
if line := fmt.Sprint(err); err != nil && line != said {
say("adopted nothing from %s: %v", c.Key, err)
said = line
} else if err == nil {
said = ""
}
return nil
})
if err != nil {
say("the bus module's have is not watched, so no hash is adopted; begun again at the next pass: %v", err)
return false
}
return true
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It
@@ -107,7 +148,11 @@ func main() {
written, failed := env.Vault.HashHeld()
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
}
watched := watchHandings(env)
for {
if !watched {
watched = watchHandings(env)
}
env.pass()
if env.Vault != nil {
env.Vault.HashOwed()
+33 -4
View File
@@ -235,7 +235,8 @@ func definitions(env *Env) []toolDef {
Description: "State this machine's sealing key to the vault, in its own name (ADR 0372, issue 786): from the " +
"node-engine of the machine alone, at its start and whenever the key changes; the machine is the caller's, " +
"as the bus proved it. A first or changed key is recorded and `secret-changed` is raised for the machine's " +
"core secrets, so the controller asks again; the same key again changes nothing.",
"core secrets, so the controller asks again; the same key again changes nothing. Answers the vault's own " +
"public key (`vault_key`, SPKI PEM), which the bus node seals the loaded list's hashes to (ADR 0547 clause 9).",
Input: map[string]any{"type": "object", "properties": map[string]any{
"key": str("this machine's sealing key, a raw X25519 public key in base64, as move-in's sealing_key carries it")}},
run: func(caller string, a map[string]any) (any, error) {
@@ -246,7 +247,9 @@ func definitions(env *Env) []toolDef {
if err := v.StateNodeKey(caller, arg(a, "key")); err != nil {
return nil, err
}
return map[string]any{"recorded": true}, nil
// And the vault's own public key, which the bus node seals the loaded list's hashes to (ADR 0547
// clause 9, adopt.go): a public half, never the private one.
return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil
}},
{Name: Seat + ".move-asks",
Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " +
@@ -347,14 +350,40 @@ func definitions(env *Env) []toolDef {
"(sha256:<hex>), when it was made and last changed, by whom, where the value came from, and whether it is a " +
"covered bus account; for a bus account, its user and whether the hash of this generation is written in " +
"`bus-accounts` (hashed, hashedAt, or why not). Narrow by node, module or name prefix. At most 1000 rows; " +
"says when cut. Never a value or a hash.",
"says when cut. Unnarrowed by module, also `adopted`: every account whose hash the vault adopted from the " +
"bus's loaded list because it holds no value for it (user, adoptedAt, the list's digest, the node), and " +
"`adoption`: the last adoption and why the last look adopted nothing. Never a value or a hash.",
Input: map[string]any{"node": str("only this machine's secrets"), "module": str("only this module's secrets"),
"name": str("only secrets whose name (the last part) starts with this, e.g. broker")},
run: func(_ string, a map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name"))
out, err := ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name"))
if err != nil || arg(a, "module") != "" {
return out, err
}
adopted, err := env.Vault.adoptedRows()
if err != nil {
out["adoptedWhy"] = "bus-accounts could not be read: " + err.Error()
}
if adopted == nil {
adopted = []map[string]any{}
}
out["adopted"], out["adoption"] = adopted, env.Vault.adoptionSaid()
return out, nil
}},
{Name: "account_adopt",
Description: "Adopt now the hashes of the bus accounts the vault holds no value for (ADR 0547 clause 9): read " +
"the have of the bus module on the node holding it, open the loaded list's hashes its node-engine handed " +
"sealed to the vault's key, and write each user's into `bus-accounts` marked adopted, saying " +
"`account-hashed`. Never a user whose value the vault holds or whose key is its own hash. As the watch of " +
"that have does, though the handing is the one adopted before. Answers the users adopted and counted, never a hash.",
run: func(caller string, _ map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return env.Vault.AdoptNow(caller)
}},
{Name: "account_hash",
Description: "Hash one covered bus account again now (ADR 0547 clause 9): the vault writes the bcrypt hash of " +
@@ -4,6 +4,7 @@ package main
// secret_verify — none returns a value. And the seat's verb `get`, reached the way the runtime calls it.
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
)
@@ -218,4 +220,113 @@ func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) {
if _, err := hash.Run(map[string]any{"user": "ace.mesh-build-go"}); err == nil {
t.Error("account_hash hashed a value that is no account")
}
// ADR 0547 clause 9: the hashes of the accounts the vault holds no value for are adopted from the bus node's
// handing, sealed to the vault's key (which node-key answers), never over a user whose value it holds, again only
// when the loaded list carries another hash, and only from the node the assignments name as holding the bus.
defs := definitions(&Env{Vault: v.Vault})
answer, err := toolNamed(t, defs, Seat+".node-key").runAs("node.novox",
map[string]any{"key": base64.StdEncoding.EncodeToString(make([]byte, 32))})
if err != nil || answer.(map[string]any)["vault_key"] != v.Own.PublicKey {
t.Fatalf("node-key answered %v, %v: no vault key", answer, err)
}
h := func(c string) string { return "$2a$11$" + strings.Repeat(c, 53) }
handing := map[string]string{"controller": h("c"), "node.ace": h("n"), "g14.dunst": h("d")}
sealedTo := v.Own.PublicKey
have := func() []byte {
var accounts []delivery.HandedAccount
for _, u := range []string{"controller", "node.ace", "g14.dunst", "bad.user"} {
if hash, ok := handing[u]; ok {
accounts = append(accounts, delivery.HandedAccount{User: u, Hash: hash})
}
}
// The list's digest, as the node says it: another list (another hash or user) is another digest.
list, _ := json.Marshal(accounts)
handed, err := delivery.HandAccounts(accounts, sealedTo, Fingerprint(string(list)), time.Now())
if err != nil {
t.Fatal(err)
}
raw, _ := delivery.EncodeHave(delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
UserList: &delivery.UserListWritten{Source: "k", Users: 3, Handed: &handed}})
return raw
}
v.Assignments = Mirror{Keys: func() ([]string, error) { return []string{"novox.nats", "novox.mesh-vault", "ace.dunst"}, nil }}
v.Haves = func(key string) ([]byte, error) {
if key != "novox.nats" {
return nil, nil
}
return have(), nil
}
dunst := acc.keys["g14.dunst"]
adopt := toolNamed(t, defs, "account_adopt")
for _, step := range []struct {
name string
change func()
adopted []string
same, own int
wantErr, said string
}{
{name: "the users it holds no value for are adopted", adopted: []string{"controller", "node.ace"}, own: 1},
{name: "again: the same hashes are kept", adopted: []string{}, same: 2, own: 1},
{name: "another hash in the loaded list is adopted again", change: func() { handing["node.ace"] = h("m") },
adopted: []string{"node.ace"}, same: 1, own: 1},
{name: "a handing sealed to another key is refused", change: func() {
other, _ := seal.GenerateKeyPair()
sealedTo = other.PublicKey
}, wantErr: "not to this one"},
} {
if step.change != nil {
step.change()
}
before := len(v.bus.events)
got, err := adopt.Run(map[string]any{})
if step.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), step.wantErr) {
t.Fatalf("%s: %v %+v", step.name, err, got)
}
continue
}
a, _ := got.(Adoption)
if err != nil || strings.Join(a.Adopted, ",") != strings.Join(step.adopted, ",") || a.Same != step.same ||
a.Own != step.own || a.Handed != 3 || eventsNamed(v, before, AccountHashedEvent) != len(step.adopted) {
t.Fatalf("%s: %+v, %v", step.name, a, err)
}
raw, _ := json.Marshal(got)
for _, hash := range handing {
if strings.Contains(string(raw), hash) {
t.Fatalf("%s: account_adopt said a hash: %s", step.name, raw)
}
}
}
if k := acc.keys["controller"]; !k.Adopted || k.Hash != h("c") || k.Node != "novox" || acc.keys["node.ace"].Hash != h("m") {
t.Errorf("adopted keys %+v %+v", k, acc.keys["node.ace"])
}
if acc.keys["g14.dunst"] != dunst {
t.Errorf("a user whose value the vault holds was adopted over: %+v", acc.keys["g14.dunst"])
}
// A refused user is named in the last adoption, and the same handing is not opened again at the next change.
sealedTo, handing["bad.user"] = v.Own.PublicKey, "not-bcrypt"
if a, err := v.AdoptFrom("novox", have(), "the watch", false); err != nil || a.Refused["bad.user"] == "" || a.Handed != 4 {
t.Fatalf("a refused user: %+v, %v", a, err)
}
if a, err := v.AdoptFrom("novox", have(), "the watch", false); err != nil || !a.At.IsZero() {
t.Fatalf("the same handing was opened again after a refusal: %+v, %v", a, err)
}
if said := v.adoptionSaid(); said["refused"] == nil {
t.Errorf("the refusal is not said: %v", said)
}
if _, err := v.AdoptFrom("ace", have(), "the test", true); err == nil || !strings.Contains(err.Error(), "does not hold the bus") {
t.Errorf("a handing from a node not holding the bus: %v", err)
}
listed, err := own.Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
rows := listed.(map[string]any)["adopted"].([]map[string]any)
if len(rows) != 2 || rows[0]["user"] != "controller" || rows[1]["user"] != "node.ace" || rows[0]["node"] != "novox" {
t.Errorf("secret_own's adopted rows %v", rows)
}
if narrowed, _ := own.Run(map[string]any{"module": "dunst"}); narrowed.(map[string]any)["adopted"] != nil {
t.Errorf("secret_own narrowed to a module lists adopted accounts: %v", narrowed)
}
}
@@ -46,11 +46,16 @@ type Vault struct {
Accounts AccountStore
// HashCost is the bcrypt cost of those hashes: zero is the controller's, bcrypt's default.
HashCost int
// Haves reads the node-engines' have bucket, where the bus node hands the loaded list's hashes (adopt.go): nil
// adopts nothing.
Haves HaveReader
mu sync.Mutex
// hashMu keeps one hashing at a time, and hashes what is owed (accounts.go).
hashMu sync.Mutex
hashes hashing
// adoption is the last adoption (adopt.go), under mu.
adoption adoptionKept
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
callsMu sync.Mutex
pending map[string]*callKey
@@ -131,8 +131,9 @@ func filesUnder(t *testing.T, dir string) map[string]string {
// The manifest declares one bucket, and no secret is in it: the vault's only place for a secret is its own store on its
// own disk (ADR 0349 §7, ADR 0201 §13). The one bucket is bus-accounts, the bcrypt hashes of the bus accounts it holds
// (ADR 0547 clause 9), opened in accounts.go alone. It reads one bucket of another's, and only reads it: the
// controller's assignments, for what each node's plan names (askers.go, design 57 V4).
// (ADR 0547 clause 9), opened in accounts.go alone. It reads two buckets of others', and only reads them: the
// controller's assignments, for what each node's plan names (askers.go, design 57 V4), and the node-engines' haves, where
// the bus node hands the loaded list's hashes sealed to the vault (adopt.go, ADR 0547 clause 9), in main.go alone.
func TestNoBucketHoldsASecret(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
@@ -146,8 +147,8 @@ func TestNoBucketHoldsASecret(t *testing.T) {
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the one "+
"bucket is the accounts' hashes (ADR 0547 clause 9)", s)
}
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments"]` {
t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r)
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have"]` {
t.Errorf("the manifest reads %s: the vault reads the controller's assignments and the haves alone", r)
}
sources, _ := filepath.Glob("*.go")
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
@@ -164,6 +165,12 @@ func TestNoBucketHoldsASecret(t *testing.T) {
if f == "accounts.go" {
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
}
// The haves, in main.go alone: one read and one watch, never a write.
if f == "main.go" {
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
}
if m := bucket.Find(code); m != nil {
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-vault
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865 h1:VLir9NZbyi1LdH1YwWDIVRuL+lDC+AYlm6FZPlvmlm4=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936 h1:HGBoWimZXYZp+Qn9VCstYDitOP84t9EUQYd1yM9j2eg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3 -1
View File
@@ -18,7 +18,8 @@
"account-hashed"
],
"reads": [
"mesh-controller.assignments"
"mesh-controller.assignments",
"node-engine.have"
],
"state": [
{
@@ -154,6 +155,7 @@
"secret_handed",
"secret_own",
"account_hash",
"account_adopt",
"secret_asks",
"secret_vault_export",
"provisioner_retirement",