From 46b0466b743995ee9a7d0b71a105938df0baf732 Mon Sep 17 00:00:00 2001
From: Konrad Karauda
Date: Mon, 9 Nov 2026 23:39:32 +0000
Subject: [PATCH 1/4] feat: refuse a signed start whose account can change the
roster
Under the signed authority the plane now refuses to start when its own
account can write trust.roster or a directory on its path, as it already
does for the configuration, the root key, the pause file, the route, the
stop list and the run records. A test classifies every file key the signed
authority reads as checked or plane-writable, so a new one cannot be missed.
The operator commands that judge with --roster say on standard error which
roster they judged, its serial and its expiry, before they sign or list.
Doctor's signed route and stop list reads get a test, and stops init
--carry runs as a command in a test.
Signed-off-by: Konrad Karauda
---
CHANGELOG.md | 16 ++
cmd/guardana-control/pause.go | 40 +++-
cmd/guardana-control/react.go | 5 +-
cmd/guardana-control/roster_judged_test.go | 164 +++++++++++++++
cmd/guardana-control/runs.go | 46 +++--
cmd/guardana-control/runs_read.go | 4 +-
cmd/guardana-control/runs_read_test.go | 3 +-
cmd/guardana-control/runs_signed.go | 9 +-
cmd/guardana-control/runssigned_test.go | 2 +-
cmd/guardana-control/stops.go | 12 +-
.../stops_carry_command_test.go | 59 ++++++
cmd/guardana-control/stops_signed.go | 14 +-
cmd/guardana-control/stops_signed_test.go | 4 +-
cmd/guardana-control/supervise.go | 1 +
cmd/guardana-gateway/authority.go | 11 +-
cmd/guardana-gateway/doctor_runs.go | 5 +-
.../doctor_signed_unix_test.go | 117 +++++++++++
cmd/guardana-gateway/reaction_signed.go | 8 +-
.../signed_placement_unix_test.go | 192 ++++++++++++++++++
...-operators-are-named-by-a-signed-roster.md | 4 +-
...oval-counts-only-as-the-plane-judges-it.md | 11 +-
...-a-run-only-from-an-operators-signature.md | 5 +
docs/concepts/glossary.md | 2 +-
docs/concepts/threat-model.md | 8 +-
docs/guides/run-the-gateway.md | 4 +-
docs/status.md | 2 +-
...perators-are-named-by-a-signed-roster.html | 6 +-
...al-counts-only-as-the-plane-judges-it.html | 10 +-
...-run-only-from-an-operators-signature.html | 4 +
site/docs/changelog.html | 45 ++--
site/docs/concepts/glossary.html | 2 +-
site/docs/concepts/threat-model.html | 8 +-
site/docs/guides/run-the-gateway.html | 4 +-
site/docs/status.html | 2 +-
34 files changed, 734 insertions(+), 95 deletions(-)
create mode 100644 cmd/guardana-control/roster_judged_test.go
create mode 100644 cmd/guardana-control/stops_carry_command_test.go
create mode 100644 cmd/guardana-gateway/doctor_signed_unix_test.go
create mode 100644 cmd/guardana-gateway/signed_placement_unix_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eee9fb1d..27403c8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,22 @@ verify one.
## [Unreleased]
+### Changed
+
+- **Breaking:** under `approvals.authority: signed` a plane whose own account
+ may write the `trust.roster` file, or a directory on its path, refuses to
+ start, and `doctor` fails its `trust` check: that account could put an
+ older roster back. Place the roster where only its signer's account
+ writes, as `/etc/guardana` is.
+- Every operator command that judges with `--roster` and `--root-key`
+ (`pause`, `stops`, `runs open` and `runs close`, `supervise`, `react`)
+ says on standard error, before it signs or lists, `judged with roster
+ serial , expires
+within that roster's lifetime of at most 7 days, and that is the whole limit.
+Amended in 0.17: the start refuses a roster file that account can write
+too, so the rollback also takes a restore of the roster file.
Alternatives considered
One pinned key per operator in the configuration: no expiry, no grants,
diff --git a/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html b/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html
index 4f78e40a..6ef87462 100644
--- a/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html
+++ b/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html
@@ -195,6 +195,11 @@
Decision
roster's first read to a timeout. This corrects ADR-0071, which listed the
approvals directory: the plane owns that directory by design, so the list is
the configuration file, the trust root key file and the environment.
+
Amended in 0.17: the list also holds trust.roster, judged the same way. An
+account that could write the roster file could put an older unexpired roster
+back, and a revoked operator with it. A test holds every file key of the
+configuration either to this list or to a named reason the plane may write
+it or does not read it under signed.
Security / compatibility impact
Every configuration breaks once. A directory or memory plane keeps the
approver as an unauthenticated claim, and doctor and /healthz say so. The
@@ -203,7 +208,10 @@
Security / compatibility impact
NFS and SMB answer for access as their server does; SSL_CERT_FILE and
SSL_CERT_DIR still feed the decision point's roots, which can only veto.
The plane's account still raises the roster's floor, so it can withhold a
-newer roster within the roster's lifetime, as ADR-0071 says.
+newer roster within the roster's lifetime, as ADR-0071 says. Amended in
+0.17: the roster file is out of that account's reach, so keeping an older
+roster in force takes write access to the floor directory and a restore of
+the roster file.
Alternatives considered
Two keys, one for the store and one for the authority: an operator could
diff --git a/site/docs/adr/0076-a-signed-plane-takes-a-run-only-from-an-operators-signature.html b/site/docs/adr/0076-a-signed-plane-takes-a-run-only-from-an-operators-signature.html
index 288aae79..36bd943f 100644
--- a/site/docs/adr/0076-a-signed-plane-takes-a-run-only-from-an-operators-signature.html
+++ b/site/docs/adr/0076-a-signed-plane-takes-a-run-only-from-an-operators-signature.html
@@ -190,6 +190,10 @@
Security / compatibility impact
after a crash before the write. supervise and react
read records/ without state/, so they do not see a carried close. Records are
group-readable, so the plane's account belongs to the records' group.
+
Amended in 0.17: a command that judges with --roster and --root-key has
+no roster floor, so its judgement is a pre-check, and the plane's, with its
+floored roster, is the one that counts. Each says on standard error, before
+it signs or lists, the roster id, serial and expiry it judged with.
Alternatives considered
A serial floor over the directory: records are written one run at a time,
diff --git a/site/docs/changelog.html b/site/docs/changelog.html
index 428fbf02..23e379ee 100644
--- a/site/docs/changelog.html
+++ b/site/docs/changelog.html
@@ -122,6 +122,21 @@
Changelog
signed and attested from its tag; RELEASING.md shows how to
verify one.
Breaking: under approvals.authority: signed a plane whose own account
+may write the trust.roster file, or a directory on its path, refuses to
+start, and doctor fails its trust check: that account could put an
+older roster back. Place the roster where only its signer's account
+writes, as /etc/guardana is.
+
Every operator command that judges with --roster and --root-key
+(pause, stops, runs open and runs close, supervise, react)
+says on standard error, before it signs or lists, judged with roster <id> serial <n>, expires <time>. Its judgement has no roster floor; the
+plane's is the one that counts.
+
runs open --key reads the roster and the key before it makes an empty
+directory a signed layout, so a roster that does not read leaves the
+directory as it was.
Upgrading from 0.15 changes four things an operator must act on.
approvals.authority (memory, directory or signed) replaces
@@ -281,7 +296,7 @@
Added
removes its socket when it stops.
doctor warns of every TCP export.endpoint, naming the unix form.
-
Changed
+
Changed
Breaking: under approvals.authority: signed a v1alpha1 route, a
1.0 stop list, a lift of the route's lift key and reaction.public_key
@@ -528,7 +543,7 @@
Breaking:trail <file> refuses a file in a log's directory, named
@@ -1065,7 +1080,7 @@
Fixed
point's published metadata gives its identifier or evaluation endpoint as
JSON null, as it does for any other value that is not a string.
-
Changed
+
Changed
The built-in hook profile is
qualified on a newer release of its runtime too: all 20 cases passed on the
@@ -1175,7 +1190,7 @@
Fixed
Built for Solaris, illumos or AIX, which have no flock, the receiver now
refuses to listen, and doctor checks the lock file as run would.
-
Changed
+
Changed
A call is handed out only while the policy bundle it was decided under is
still installed and fresh. A bundle replaced, or past its staleness budget,
@@ -1230,7 +1245,7 @@
Added
back. The dry run uploads a development release to TestPyPI the same way,
from main.
-
Changed
+
Changed
A change reaches main as one squash-merged pull request whose gate is
CI's make quality, a maintainer's own included; only a release commit is
@@ -1340,7 +1355,7 @@
Added
simulating candidate policies and reviewing findings with an incident
export. None is built.
-
Changed
+
Changed
On an enforce_api plane /metrics writes the enforcement API adapter's
counts (enforce_*) and the flag listener_enforce_api, and leaves out the
@@ -1486,7 +1501,7 @@
Added
reads and its stability, held to the code's constants by a test. The wire
contract's 1.0 is the one stable format; the product is still 0.x.
-
Changed
+
Changed
The website names the published release, which docs/docs.json sets once a
release is out, rather than the newest dated section of this file, and each
@@ -1596,7 +1611,7 @@
Added
or with --view-all every call up to a bound
(run view).
-
Changed
+
Changed
Every reader of a route (route sign, react, a plane at start, doctor and
the stops commands) refuses one that names a rule or a rule version outside
@@ -1685,7 +1700,7 @@
Added
report the route and the list; examples/refund-supervision/ stops one of
two runs on a live plane and lifts the stop.
-
Changed
+
Changed
The policy key and the freshness key, in the configuration and in
guardana-control policy renew, are compared as points up to their sign:
@@ -1837,7 +1852,7 @@
Added
finding, and no finding stops a run. examples/refund-supervision/ runs it
on a live plane.
-
Changed
+
Changed
An upstream's answer, wire error, listed item or tool definition that quotes
a credential the plane sends reaches the agent as a fixed answer, never with
@@ -1906,7 +1921,7 @@
Added
runtime's traces. A missing or malformed value leaves both empty and changes
nothing else.
-
Changed
+
Changed
The plan follows ADR-0039:
MCP is one channel of several. The next milestone is one task under
@@ -2054,7 +2069,7 @@
Added
guardana-control policy explain prints a clock line: usable, or why
the clock stopped the decision.
-
Changed
+
Changed
Breaking: a plane's configuration needs five more keys:
@@ -2294,7 +2309,7 @@
Added
answered with HTTP 401 or the JSON-RPC error -31102, and the refusals are
counted by cause. A plane without runs.dir is unchanged.
-
Changed
+
Changed
The release job waits for a maintainer to approve it in the release
environment before it builds; RELEASING.md has the step.
@@ -2316,7 +2331,7 @@
docs/status.md says what is implemented and what is
experimental. Nothing here is a security boundary yet.
-
Changed
+
Changed
mode has no default: a configuration that names no enforcement mode is
refused at load instead of starting a plane in OBSERVE, which stops nothing
@@ -2414,7 +2429,7 @@
A stdio upstream no longer inherits the plane's environment. It gets
PATH, HOME, LANG, LC_ALL, TMPDIR and USER, each where the
diff --git a/site/docs/concepts/glossary.html b/site/docs/concepts/glossary.html
index 35c21dd5..87f6268e 100644
--- a/site/docs/concepts/glossary.html
+++ b/site/docs/concepts/glossary.html
@@ -347,7 +347,7 @@
Glossary
signed authority
-
approvals.authority: signed; a plane under it, a signed plane, takes an approval, a pause, a run and a stop list only as a roster operator signed it for the plane's tenant and project, refuses to start when its own account may write the pause file, records/, the route or the stops directory, and holds the roster, the pause file, the route and the stop list each to a serial floor
+
approvals.authority: signed; a plane under it, a signed plane, takes an approval, a pause, a run and a stop list only as a roster operator signed it for the plane's tenant and project, refuses to start when its own account may write the roster, the pause file, records/, the route or the stops directory, and holds the roster, the pause file, the route and the stop list each to a serial floor
(ADR-0016); only the plane's ledger marks an approval spent.
Under signed, approvals, pauses, runs and stop lists count only signed by
roster operators, and the plane refuses to start when its account may write
-the pause file, the run records or the stop list (ADR-0075, ADR-0076,
-ADR-0077). The plane's account still owns the roster, pause, route and stop list
-floors: it can lower one, or withhold a newer roster until its current one
-expires.
+the roster, the pause file, the run records or the stop list (ADR-0073,
+ADR-0075, ADR-0076, ADR-0077). The plane's account still owns the roster,
+pause, route and stop list floors: it can lower one, which brings back an
+older roster only with the file restored.
The gateway binary holds none of the approver's code, no private-key
parser and no code that makes or lowers a floor, so the process that serves
agents cannot answer an approval, read a signing key or reset its own floor
diff --git a/site/docs/guides/run-the-gateway.html b/site/docs/guides/run-the-gateway.html
index 64456ab8..69a357f6 100644
--- a/site/docs/guides/run-the-gateway.html
+++ b/site/docs/guides/run-the-gateway.html
@@ -291,8 +291,8 @@
8. Let a person answer a held callComponents
Trust roster, authentication and authorization ports
experimental
-
internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane's account still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime
+
internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file; that account still raises the roster's floor, so withholding a newer roster takes the floor directory and a restore of the roster file
Policy refresh on a plane
From 6e903f2b3416a69521ada0d6903f1ded68b7bdf2 Mon Sep 17 00:00:00 2001
From: Konrad Karauda
Date: Tue, 10 Nov 2026 09:11:33 +0000
Subject: [PATCH 2/4] fix: state the roster withhold limit, and test doctor and
react off the seam
The plane's account still writes trust.floor_dir: raising the roster floor
withholds a newer roster until the held one expires. The pages now say
so, and that what the placement check adds is that the plane's account can
no longer put an older roster in place.
Doctor's signed stop list read is a method with its own test run with the
plane-owned switch off, so a doctor that takes a list the plane's own
account owns fails a test. react's guard against the plane's account is a
variable, so a test on one account reaches the roster line it prints. The
classification test also runs over a minimal signed tree, so a check that
depends on another key being set fails it.
Signed-off-by: Konrad Karauda
---
cmd/guardana-control/react.go | 6 +++-
cmd/guardana-control/roster_judged_test.go | 28 +++++++++++++++
.../doctor_signed_unix_test.go | 28 +++++++++++++++
cmd/guardana-gateway/reaction_signed.go | 14 +++++---
.../signed_placement_unix_test.go | 36 ++++++++++++++++---
...-operators-are-named-by-a-signed-roster.md | 4 ++-
...oval-counts-only-as-the-plane-judges-it.md | 6 ++--
docs/concepts/threat-model.md | 6 ++--
docs/status.md | 2 +-
...perators-are-named-by-a-signed-roster.html | 4 ++-
...al-counts-only-as-the-plane-judges-it.html | 6 ++--
site/docs/concepts/threat-model.html | 6 ++--
site/docs/status.html | 2 +-
13 files changed, 123 insertions(+), 25 deletions(-)
diff --git a/cmd/guardana-control/react.go b/cmd/guardana-control/react.go
index 9e85e515..c8448c8b 100644
--- a/cmd/guardana-control/react.go
+++ b/cmd/guardana-control/react.go
@@ -87,7 +87,7 @@ var beforeStopsLock = func() {}
func react(ctx context.Context, a reactArgs, clock func() time.Time, stdout, stderr io.Writer) int {
now := clock().UTC().Truncate(time.Second)
if a.signed() {
- if err := notPlaneAccount(a.approvals); err != nil {
+ if err := reactPlaneAccount(a.approvals); err != nil {
return fail(stderr, reactName, err)
}
}
@@ -344,6 +344,10 @@ func projectOf(route reaction.Route, fr *findingv1alpha1.FindingRecord) string {
return fr.GetProjectId()
}
+// reactPlaneAccount is react's guard against running as the plane's account,
+// a variable so a test on one account reaches what follows it.
+var reactPlaneAccount = notPlaneAccount
+
// notPlaneAccount refuses this process when it owns the approvals directory
// at dir, which makes it the plane's account: the signal the console takes.
func notPlaneAccount(dir string) error {
diff --git a/cmd/guardana-control/roster_judged_test.go b/cmd/guardana-control/roster_judged_test.go
index c333bdde..2d965159 100644
--- a/cmd/guardana-control/roster_judged_test.go
+++ b/cmd/guardana-control/roster_judged_test.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/guardana/control/internal/canon"
+ "github.com/guardana/control/internal/findinglog"
"github.com/guardana/control/internal/policy/bundle"
"github.com/guardana/control/internal/policykey"
"github.com/guardana/control/internal/trust"
@@ -162,3 +163,30 @@ func TestSuperviseSaysWhichRosterItJudgedARunWith(t *testing.T) {
t.Fatalf("supervise of a signed layout said %q; want %q first", stderr, judged)
}
}
+
+// TestReactUnderASignedRouteSaysWhichRosterItJudgedWith: past its guard
+// against the plane's account, which every directory trips on one account,
+// react under a v1alpha2 route says the roster's line once, first.
+func TestReactUnderASignedRouteSaysWhichRosterItJudgedWith(t *testing.T) {
+ saved := reactPlaneAccount
+ reactPlaneAccount = func(string) error { return nil }
+ t.Cleanup(func() { reactPlaneAccount = saved })
+ tr := newSignedTree(t)
+ judged := resignRoster(t, tr.judge[1], seeded(0x7e))
+ if code, _, stderr := tr.run(t, "stops init", "--key", tr.keys["alice"], tr.stops); code != exitOK {
+ t.Fatalf("stops init: %d, %q", code, stderr)
+ }
+ findings := ownerDir(t, filepath.Join(tr.dir, "findings"))
+ if err := os.WriteFile(filepath.Join(findings, findinglog.FileName), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ runsAt := runsDir(t)
+ openRun(t, runsAt, who, "1h")
+ code, stdout, stderr := invoke(t, "react", "--findings", findings, "--runs", runsAt, "--route", tr.route,
+ "--roster", tr.judge[1], "--root-key", tr.judge[3], "--approvals", filepath.Join(tr.dir, "approvals"), "--stops", tr.stops)
+ first, _, _ := strings.Cut(stderr, "\n")
+ if code != exitOK || !strings.Contains(stdout, "stops 0, covered 0") || !judgedOnce(stderr, reactName, judged, "") ||
+ !strings.Contains(first, judged) {
+ t.Fatalf("react under a signed route answered %d: %q %q; want %q first", code, stdout, stderr, judged)
+ }
+}
diff --git a/cmd/guardana-gateway/doctor_signed_unix_test.go b/cmd/guardana-gateway/doctor_signed_unix_test.go
index d91f0cf0..1586300a 100644
--- a/cmd/guardana-gateway/doctor_signed_unix_test.go
+++ b/cmd/guardana-gateway/doctor_signed_unix_test.go
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/ed25519"
+ "errors"
"path/filepath"
"strings"
"testing"
@@ -14,6 +15,8 @@ import (
"github.com/guardana/control/internal/canon"
"github.com/guardana/control/internal/policy/bundle"
"github.com/guardana/control/internal/policykey"
+ "github.com/guardana/control/internal/reaction/stoplist"
+ "github.com/guardana/control/internal/runs"
"github.com/guardana/control/internal/trust"
)
@@ -115,3 +118,28 @@ func writeAdminOnlyRoster(t *testing.T, path string) {
}
writeTestFile(t, path, raw)
}
+
+// TestDoctorRefusesWhatThePlanesOwnAccountOwnsUnderSigned: off the one-account
+// test switch, where this account is the plane's and owns every file, doctor
+// fails at the runs check naming records/ the plane's account owns, its
+// reaction check names the route that account owns, and the stop list read as
+// doctor reads it is refused as that account's.
+func TestDoctorRefusesWhatThePlanesOwnAccountOwnsUnderSigned(t *testing.T) {
+ placedApart(t)
+ tr := signedStopsTree(t)
+ status, out, stderr, line := doctorRun(t, tr, "fail runs ")
+ if status != exitFail || !strings.HasPrefix(stderr, "doctor: runs: ") || !strings.Contains(line, runs.ErrPlaneOwned.Error()) {
+ t.Fatalf("doctor on one account = %d, stderr %q:\n%s", status, stderr, out)
+ }
+ d := &examination{cfg: tr.load(t)}
+ if verdict, _, found := d.reaction(t.Context()); verdict != verdictFail || !strings.Contains(found, "reaction.route: ") ||
+ !strings.Contains(found, errPlaneOwnedRoute.Error()) {
+ t.Errorf("doctor's reaction check on one account = %s, %q; want the route the plane's account owns", verdict, found)
+ }
+ doc := writeLeveledRoster(t, filepath.Join(tr.dir, "trust", "roster.json"))
+ route := rosterRouteOf(t, doc, filepath.Join(tr.dir, "route.json"), filepath.Join(tr.dir, "stops"))
+ if _, err := openStopList(d.cfg, route, nil, d.signedListRead()); !errors.Is(err, stoplist.ErrNotServable) ||
+ !strings.Contains(err.Error(), string(stoplist.CausePlaneOwned)) {
+ t.Errorf("the stop list read as doctor reads it on one account = %v; want it refused as the plane's own", err)
+ }
+}
diff --git a/cmd/guardana-gateway/reaction_signed.go b/cmd/guardana-gateway/reaction_signed.go
index dac8236b..ce47057d 100644
--- a/cmd/guardana-gateway/reaction_signed.go
+++ b/cmd/guardana-gateway/reaction_signed.go
@@ -137,8 +137,7 @@ func notPlaneOwned(path string) error {
// signedRoute is what doctor reads under the signed authority: the route
// judged against the roster file verified under the trust root, and the
-// list read as the plane reads it, held to the stop list floor, which is
-// read and never raised.
+// list read as signedListRead says.
func (d *examination) signedRoute() (reaction.Route, stopListRead, error) {
root, err := readRouteKey(d.cfg.Resolve(d.cfg.Trust.RootPublicKey))
if err != nil {
@@ -160,16 +159,23 @@ func (d *examination) signedRoute() (reaction.Route, stopListRead, error) {
if err != nil {
return reaction.Route{}, stopListRead{}, err
}
+ return route, d.signedListRead(), nil
+}
+
+// signedListRead reads the stop list as a signed plane does, refusing one
+// its own account owns, held to the stop list floor, which is read and never
+// raised.
+func (d *examination) signedListRead() stopListRead {
owner := stoplist.OwnedByAnother
if doctorPlaneOwned {
owner = stoplist.AnyOwner
}
dir := d.cfg.Resolve(d.cfg.Reaction.StopsFloorDir)
- return route, stopListRead{owner: owner, take: func(l reaction.List, content []byte) error {
+ return stopListRead{owner: owner, take: func(l reaction.List, content []byte) error {
stored, err := policystate.ReadStops(dir, d.cfg.ListenerTenant(), d.cfg.ProjectID)
if err != nil {
return fmt.Errorf("reaction.stops_floor_dir: %w: %w", stoplist.ErrFloor, err)
}
return signedstops.Hold(stored, l, content)
- }}, nil
+ }}
}
diff --git a/cmd/guardana-gateway/signed_placement_unix_test.go b/cmd/guardana-gateway/signed_placement_unix_test.go
index 7e620710..5053dc31 100644
--- a/cmd/guardana-gateway/signed_placement_unix_test.go
+++ b/cmd/guardana-gateway/signed_placement_unix_test.go
@@ -3,6 +3,7 @@
package main
import (
+ "crypto/ed25519"
"errors"
"os"
"path/filepath"
@@ -93,10 +94,7 @@ func startRefusedNaming(err error, key string) bool {
// key in neither fails, so a file key added later cannot go unjudged.
func TestEveryFileKeyIsCheckedAtASignedStartOrNamedWithItsReason(t *testing.T) {
cfg := fullSignedTree(t).load(t)
- values := map[string]string{}
- for _, s := range cfg.Settings() {
- values[s.Path] = s.Value
- }
+ values := settingsOf(cfg)
files := map[string]bool{}
for _, f := range gatewayconfig.Fields() {
if !f.File {
@@ -119,6 +117,36 @@ func TestEveryFileKeyIsCheckedAtASignedStartOrNamedWithItsReason(t *testing.T) {
}
}
+// TestEveryFileKeyOfAMinimalSignedTreeIsChecked: the same classification
+// over a tree that sets only what a signed start needs, so a check that
+// holds only while another key is set fails here.
+func TestEveryFileKeyOfAMinimalSignedTreeIsChecked(t *testing.T) {
+ cfg := signedPlaneTree(t, "roster-a", []ed25519.PublicKey{signedTestPublic("alice")}, "roster-a").load(t)
+ values := settingsOf(cfg)
+ judged := 0
+ for _, f := range gatewayconfig.Fields() {
+ if f.File && values[f.Path] != "" {
+ judged++
+ judgeFileKey(t, cfg, f.Path, values[f.Path])
+ }
+ }
+ if values["trust.roster"] == "" || values["pause.file"] != "" || values["reaction.route"] != "" || values["runs.dir"] != "" {
+ t.Errorf("the minimal tree sets roster %q, pause %q, route %q, runs %q; want the roster alone of them",
+ values["trust.roster"], values["pause.file"], values["reaction.route"], values["runs.dir"])
+ }
+ if judged == 0 {
+ t.Error("the minimal tree sets no file key")
+ }
+}
+
+func settingsOf(cfg *gatewayconfig.Config) map[string]string {
+ values := map[string]string{}
+ for _, s := range cfg.Settings() {
+ values[s.Path] = s.Value
+ }
+ return values
+}
+
// judgeFileKey makes the entry of key, whose value the configuration holds,
// plane-writable alone and holds what the signed start answers to the
// classification above.
diff --git a/docs/adr/0071-operators-are-named-by-a-signed-roster.md b/docs/adr/0071-operators-are-named-by-a-signed-roster.md
index 39c12c2a..088fd2d0 100644
--- a/docs/adr/0071-operators-are-named-by-a-signed-roster.md
+++ b/docs/adr/0071-operators-are-named-by-a-signed-roster.md
@@ -123,7 +123,9 @@ that floor itself, so its account writes it. That account can therefore roll
the roster back only to an older or equal serial of the same roster id,
within that roster's lifetime of at most 7 days, and that is the whole limit.
Amended in 0.17: the start refuses a roster file that account can write
-too, so the rollback also takes a restore of the roster file.
+too, so it can no longer put an older roster in place; raising the floor it
+writes still keeps the held roster against a newer one until the held one
+expires.
## Alternatives considered
diff --git a/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.md b/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.md
index f53289f6..cd41a44b 100644
--- a/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.md
+++ b/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.md
@@ -112,9 +112,9 @@ NFS and SMB answer for access as their server does; `SSL_CERT_FILE` and
`SSL_CERT_DIR` still feed the decision point's roots, which can only veto.
The plane's account still raises the roster's floor, so it can withhold a
newer roster within the roster's lifetime, as ADR-0071 says. Amended in
-0.17: the roster file is out of that account's reach, so keeping an older
-roster in force takes write access to the floor directory and a restore of
-the roster file.
+0.17: the roster file is out of that account's reach, so it can no longer put
+an older roster in place; raising the floor it writes still withholds a newer
+one until the held roster expires.
## Alternatives considered
diff --git a/docs/concepts/threat-model.md b/docs/concepts/threat-model.md
index 4c7bd2df..2b15bb19 100644
--- a/docs/concepts/threat-model.md
+++ b/docs/concepts/threat-model.md
@@ -354,9 +354,9 @@ What bounds it:
- Under `signed`, approvals, pauses, runs and stop lists count only signed by
roster operators, and the plane refuses to start when its account may write
the roster, the pause file, the run records or the stop list (ADR-0073,
- ADR-0075, ADR-0076, ADR-0077). The plane's account still owns the roster,
- pause, route and stop list floors: it can lower one, which brings back an
- older roster only with the file restored.
+ ADR-0075, ADR-0076, ADR-0077). That account still owns every floor: it can
+ lower one, or withhold a newer roster until the held one expires, but no
+ longer put an older roster in place.
- The gateway binary holds none of the approver's code, no private-key
parser and no code that makes or lowers a floor, so the process that serves
agents cannot answer an approval, read a signing key or reset its own floor
diff --git a/docs/status.md b/docs/status.md
index 08301790..dccb93c8 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -70,7 +70,7 @@ Nothing here is a security boundary. Do not deploy it as one.
| Canonical action digest | `implemented` | `internal/canon/`, goldens in `testdata/digest/`, [ADR-0005](adr/0005-canonical-action-digest.md) and [ADR-0010](adr/0010-digest-domain-separation.md). Cross-language parity is unproved until a second implementation exists |
| Policy documents, signed bundles and the matcher | `implemented` | `internal/policy/rules/` parses `agent-policy/v1alpha1` strictly, `internal/policy/bundle/` signs and verifies bundle bytes (Ed25519 over the DSSE PAE); `internal/policy/match/` evaluates three-valued; `internal/policy/` loads a bundle into a snapshot and installs it in order; [ADR-0003](adr/0003-policy-model-and-external-pdp.md), [ADR-0012](adr/0012-policy-kernel-semantics.md). A holder pinned to the bundle id it serves refuses every other. On a plane a snapshot is confirmed only by a freshness statement bound to its bundle and signed under a key apart from the bundle's, and its serial floor is ordered by serial and then `issuedAt` (`internal/policy/statement.go`, `floor.go`, `experimental`, [ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md)); an unconfirmed snapshot decides `POLICY_STALE` |
| Policy serial floor on disk | `experimental` | `internal/policystate/`. One strict-JSON file per bundle id in an owner-only directory whose marker names a plane's or a signer's kind; raised under the directory's lock against the file's value; `Init` and `Reset` are the operator's, and the gateway binary holds neither ([ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md)). The commands use it (`policy renew`, `policy state init` and `reset`), and so does every plane, through `policy.state_dir`. A floor protects only while its directory outlives the plane, and an account that can write the directory can lower it |
-| Trust roster, authentication and authorization ports | `experimental` | `internal/trust/` (the `Roster` port and its `file` kind: one DSSE envelope of `trust-roster/v1alpha1` signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the `authority` floor of `internal/policystate/`), `internal/operators/` (the `Authenticator` and `Authorizer` ports and their `roster` kinds, a veto that only denies, a timeout and a bound on calls in flight), `operators roster sign` and `operators floor init` in `cmd/guardana-control/`, the conformance cases in `testdata/ports/` listed in `scripts/ports.txt`, which a test holds to the tree, [ADR-0066](adr/0066-each-decision-about-authority-is-a-port.md), [ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md). A plane reads a roster only under `approvals.authority: signed`, where `doctor` and `/healthz` report `trust`, `operators.authenticator` and `operators.authorizer`; the plane refuses to start when its account may write the roster file; that account still raises the roster's floor, so withholding a newer roster takes the floor directory and a restore of the roster file |
+| Trust roster, authentication and authorization ports | `experimental` | `internal/trust/` (the `Roster` port and its `file` kind: one DSSE envelope of `trust-roster/v1alpha1` signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the `authority` floor of `internal/policystate/`), `internal/operators/` (the `Authenticator` and `Authorizer` ports and their `roster` kinds, a veto that only denies, a timeout and a bound on calls in flight), `operators roster sign` and `operators floor init` in `cmd/guardana-control/`, the conformance cases in `testdata/ports/` listed in `scripts/ports.txt`, which a test holds to the tree, [ADR-0066](adr/0066-each-decision-about-authority-is-a-port.md), [ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md). A plane reads a roster only under `approvals.authority: signed`, where `doctor` and `/healthz` report `trust`, `operators.authenticator` and `operators.authorizer`; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime |
| Policy refresh on a plane | `experimental` | `internal/policywatch/`, wired by `cmd/guardana-gateway/planepolicy.go`, [ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md). At start judges the floor, the bundle and the statement: a bundle below or beside the floor, or above it without its statement, refuses the start, and a missing or expired statement starts the plane unconfirmed. Then reads both files every `policy.poll_interval`, installs a newer bundle only with its statement, refuses and counts every bad replacement without moving the confirmation, and withdraws the confirmation while the wall clock, measured against the monotonic clock, has fallen back more than a second plus 100 parts per million of the time since its highest reading, or reads too far from the first reading to judge, and until a statement issued after that arrives. A clock reading outside 1970 to 9999, or behind the verified time, the latest `issuedAt` the plane verified, decides nothing: every call is `POLICY_STALE`, reads included. `/healthz`, `/metrics` and `doctor` report it. Freshness trusts the plane's clock at start, and the operator schedules `policy renew`; nothing in the repository schedules it outside `dev` |
| External policy decision point (PDP) over AuthZEN, able to veto | `experimental` | [ADR-0017](adr/0017-an-external-decision-point-can-veto.md). The kernel takes the answer and the policy format reads it (`internal/core/`, `internal/policy/`); `adapters/authzen/` asks and reads the answer strictly; `internal/gateway/` asks only when the answer can change the decision; `cmd/guardana-gateway` configures it from the `pdp.` keys, checks its published metadata in `doctor` and counts the asks on `/healthz`. The end-to-end tests reach a decision point on the loopback over plaintext; TLS is exercised in the adapter's own tests, against the roots a test hands it, and the command has no setting for a private certificate authority |
| Approval binding to one action digest and one bundle digest | `implemented` | `internal/core/approval/` (`Bind`), golden in `testdata/digest/binding.json`. `internal/gateway/` holds a request for approval, keeps the record of what it minted itself, and spends the approval exactly once on the retry, in its own ledger ([ADR-0013](adr/0013-mcp-interception-approvals-and-modes.md)). An approver outside the plane answers through the approval authority below; a hold does not survive a restart, and a plane that keeps a hold journal closes the trail of one it lost at its next start, and counts one it cannot close as left open ([ADR-0016](adr/0016-approval-providers-and-the-lost-hold.md)) |
diff --git a/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html b/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html
index b1a37241..59aa18ab 100644
--- a/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html
+++ b/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html
@@ -222,7 +222,9 @@
Security / compatibility impact
the roster back only to an older or equal serial of the same roster id,
within that roster's lifetime of at most 7 days, and that is the whole limit.
Amended in 0.17: the start refuses a roster file that account can write
-too, so the rollback also takes a restore of the roster file.
+too, so it can no longer put an older roster in place; raising the floor it
+writes still keeps the held roster against a newer one until the held one
+expires.
Alternatives considered
One pinned key per operator in the configuration: no expiry, no grants,
diff --git a/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html b/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html
index 6ef87462..00911677 100644
--- a/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html
+++ b/site/docs/adr/0073-an-approval-counts-only-as-the-plane-judges-it.html
@@ -209,9 +209,9 @@
Security / compatibility impact
SSL_CERT_DIR still feed the decision point's roots, which can only veto.
The plane's account still raises the roster's floor, so it can withhold a
newer roster within the roster's lifetime, as ADR-0071 says. Amended in
-0.17: the roster file is out of that account's reach, so keeping an older
-roster in force takes write access to the floor directory and a restore of
-the roster file.
+0.17: the roster file is out of that account's reach, so it can no longer put
+an older roster in place; raising the floor it writes still withholds a newer
+one until the held roster expires.
Alternatives considered
Two keys, one for the store and one for the authority: an operator could
diff --git a/site/docs/concepts/threat-model.html b/site/docs/concepts/threat-model.html
index e366d58d..48004a34 100644
--- a/site/docs/concepts/threat-model.html
+++ b/site/docs/concepts/threat-model.html
@@ -472,9 +472,9 @@
The same local account
Under signed, approvals, pauses, runs and stop lists count only signed by
roster operators, and the plane refuses to start when its account may write
the roster, the pause file, the run records or the stop list (ADR-0073,
-ADR-0075, ADR-0076, ADR-0077). The plane's account still owns the roster,
-pause, route and stop list floors: it can lower one, which brings back an
-older roster only with the file restored.
+ADR-0075, ADR-0076, ADR-0077). That account still owns every floor: it can
+lower one, or withhold a newer roster until the held one expires, but no
+longer put an older roster in place.
The gateway binary holds none of the approver's code, no private-key
parser and no code that makes or lowers a floor, so the process that serves
agents cannot answer an approval, read a signing key or reset its own floor
diff --git a/site/docs/status.html b/site/docs/status.html
index 51498471..4c7ffbc7 100644
--- a/site/docs/status.html
+++ b/site/docs/status.html
@@ -251,7 +251,7 @@
Components
Trust roster, authentication and authorization ports
experimental
-
internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file; that account still raises the roster's floor, so withholding a newer roster takes the floor directory and a restore of the roster file
+
internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime
Policy refresh on a plane
From d884366cfa7603a80123a2a36fda6810992329f4 Mon Sep 17 00:00:00 2001
From: Konrad Karauda
Date: Tue, 10 Nov 2026 09:31:04 +0000
Subject: [PATCH 3/4] docs: B13 states what a receiver enforces, and doctor
names the read grant
Reading is not a roster capability: the health socket answers a peer by
its uid against health.socket_uid, and a file is read by whoever its
modes let read it. The roster's read level parses and is reserved; it
grants nothing yet. doctor now says so when a roster holds a read grant,
and its trust line names every file the signed start checked, from the
same list the start uses, the roster included.
B13 keeps only open work: cross-project reads and operations refused at
every receiver, a stop naming its agent or principal scope, and
adversarial tests of two users and overlapping runs.
docs/contracts.md gains a table of every signed file and answer a plane
takes in, with its tenant and project check and the test that pins it;
a docscheck test fails when a cited test, file or function does not
exist, or the table is empty.
ADR-0071 is amended for the reserved read level.
Signed-off-by: Konrad Karauda
---
CHANGELOG.md | 4 +
cmd/guardana-gateway/authority.go | 31 +-
cmd/guardana-gateway/doctor_authority.go | 37 ++-
.../doctor_authority_unix_test.go | 103 +++++++
...-operators-are-named-by-a-signed-roster.md | 4 +
docs/backlog.md | 2 +-
docs/contracts.md | 22 ++
docs/status.md | 2 +-
internal/docscheck/receivers_test.go | 264 ++++++++++++++++++
...perators-are-named-by-a-signed-roster.html | 6 +-
site/docs/backlog.html | 2 +-
site/docs/changelog.html | 4 +
site/docs/contracts.html | 90 ++++++
site/docs/status.html | 2 +-
14 files changed, 554 insertions(+), 19 deletions(-)
create mode 100644 cmd/guardana-gateway/doctor_authority_unix_test.go
create mode 100644 internal/docscheck/receivers_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27403c8e..80991c9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,10 @@ verify one.
- `runs open --key` reads the roster and the key before it makes an empty
directory a signed layout, so a roster that does not read leaves the
directory as it was.
+- `doctor`'s `trust` line names every file the signed start checked, the
+ roster among them, and counts the roster's `read` grants, which grant
+ nothing yet: a plane is read through `health.socket` by peer uid and
+ through files by their modes.
## [0.16.0] - 2026-11-07
diff --git a/cmd/guardana-gateway/authority.go b/cmd/guardana-gateway/authority.go
index c251b045..070e65b8 100644
--- a/cmd/guardana-gateway/authority.go
+++ b/cmd/guardana-gateway/authority.go
@@ -99,28 +99,37 @@ func checkSignedStart(cfg *gatewayconfig.Config) error {
return fmt.Errorf("approvals.authority: signed, and the environment sets %s; the plane's own account sets its environment, so under signed every key is read from the configuration file alone (ADR-0073)",
strings.Join(keys, ", "))
}
- checked := []struct{ key, path string }{
+ for _, f := range signedStartFiles(cfg) {
+ if err := notPlaneWritable(f.path); err != nil {
+ return fmt.Errorf("%s: under the signed authority the plane's own account may not be able to change it: %w (ADR-0073)", f.key, err)
+ }
+ }
+ return nil
+}
+
+// signedFile is one path a signed start checks, under the key that names it.
+type signedFile struct{ key, path string }
+
+// signedStartFiles is every path checkSignedStart holds out of the plane's
+// account; a key the configuration leaves unset names none.
+func signedStartFiles(cfg *gatewayconfig.Config) []signedFile {
+ checked := []signedFile{
{"the configuration file", cfg.File()},
{"trust.root_public_key", cfg.Resolve(cfg.Trust.RootPublicKey)},
{"trust.roster", cfg.Resolve(cfg.Trust.Roster)},
}
if cfg.Pause.File != "" {
- checked = append(checked, struct{ key, path string }{"pause.file", cfg.Resolve(cfg.Pause.File)})
+ checked = append(checked, signedFile{"pause.file", cfg.Resolve(cfg.Pause.File)})
}
if cfg.Reaction.Configured() {
checked = append(checked,
- struct{ key, path string }{"reaction.route", cfg.Resolve(cfg.Reaction.Route)},
- struct{ key, path string }{"reaction.stops", cfg.Resolve(cfg.Reaction.Stops)})
+ signedFile{"reaction.route", cfg.Resolve(cfg.Reaction.Route)},
+ signedFile{"reaction.stops", cfg.Resolve(cfg.Reaction.Stops)})
}
if cfg.Runs.Dir != "" {
- checked = append(checked, struct{ key, path string }{"runs.dir", filepath.Join(cfg.Resolve(cfg.Runs.Dir), runs.RecordsDir)})
- }
- for _, f := range checked {
- if err := notPlaneWritable(f.path); err != nil {
- return fmt.Errorf("%s: under the signed authority the plane's own account may not be able to change it: %w (ADR-0073)", f.key, err)
- }
+ checked = append(checked, signedFile{"runs.dir", filepath.Join(cfg.Resolve(cfg.Runs.Dir), runs.RecordsDir)})
}
- return nil
+ return checked
}
// pinnedKey is one key the plane pins for an authority other than the trust
diff --git a/cmd/guardana-gateway/doctor_authority.go b/cmd/guardana-gateway/doctor_authority.go
index acf4535a..2a31a7ea 100644
--- a/cmd/guardana-gateway/doctor_authority.go
+++ b/cmd/guardana-gateway/doctor_authority.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"path/filepath"
+ "strings"
"time"
"github.com/guardana/control/internal/approvals/inbox"
@@ -63,9 +64,39 @@ func (d *examination) trust(context.Context) (string, string, string) {
if floor.Serial > doc.Serial() {
return verdictFail, doctorTrust, fmt.Sprintf("trust.roster: serial %d is below the floor's %d (%s)", doc.Serial(), floor.Serial, trust.CauseBelowFloor)
}
- return verdictOK, doctorTrust, fmt.Sprintf(
- "roster %s serial %d names %d key(s) and verifies under the pinned root; the floor holds serial %d; %d other pinned key(s) no roster may name; the configuration file and the root key file are not the plane's to change, and the environment sets no key",
- doc.ID(), doc.Serial(), len(doc.Keys()), floor.Serial, len(others))
+ found := fmt.Sprintf(
+ "roster %s serial %d names %d key(s) and verifies under the pinned root; the floor holds serial %d; %d other pinned key(s) no roster may name; %s are not the plane's to change, and the environment sets no key",
+ doc.ID(), doc.Serial(), len(doc.Keys()), floor.Serial, len(others), checkedKeys(d.cfg))
+ if n := readGrants(doc); n > 0 {
+ found += fmt.Sprintf("; %d read grant(s) grant nothing yet: reading is by the health socket's peer uid and by file modes (ADR-0071)", n)
+ }
+ return verdictOK, doctorTrust, found
+}
+
+// checkedKeys names the keys of every path the signed start checked, as one
+// list.
+func checkedKeys(cfg *gatewayconfig.Config) string {
+ files := signedStartFiles(cfg)
+ keys := make([]string, 0, len(files))
+ for _, f := range files {
+ keys = append(keys, f.key)
+ }
+ last := len(keys) - 1
+ return strings.Join(keys[:last], ", ") + " and " + keys[last]
+}
+
+// readGrants counts the roster's grants of level read, which no receiver
+// reads by: a read grant is reserved (ADR-0071).
+func readGrants(doc trust.Document) int {
+ n := 0
+ for _, k := range doc.Keys() {
+ for _, g := range k.Grants() {
+ if g.Level == trust.LevelRead {
+ n++
+ }
+ }
+ }
+ return n
}
// roster reads the roster file and verifies it under root, of the configured
diff --git a/cmd/guardana-gateway/doctor_authority_unix_test.go b/cmd/guardana-gateway/doctor_authority_unix_test.go
new file mode 100644
index 00000000..7ce6d91b
--- /dev/null
+++ b/cmd/guardana-gateway/doctor_authority_unix_test.go
@@ -0,0 +1,103 @@
+//go:build unix
+
+package main
+
+import (
+ "crypto/ed25519"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/guardana/control/internal/canon"
+ "github.com/guardana/control/internal/policy/bundle"
+ "github.com/guardana/control/internal/policykey"
+ "github.com/guardana/control/internal/trust"
+)
+
+// trustFound runs doctor's trust check over tr placed apart and returns what
+// it found, failing the test unless the verdict is ok.
+func trustFound(t *testing.T, tr tree) string {
+ t.Helper()
+ placedApart(t)
+ verdict, key, found := (&examination{cfg: tr.load(t)}).trust(t.Context())
+ if verdict != verdictOK || key != doctorTrust {
+ t.Fatalf("doctor's trust check = %s %s, %q; want ok", verdict, key, found)
+ }
+ return found
+}
+
+// TestDoctorNamesEveryFileTheSignedStartChecked: the trust line names the
+// roster beside the configuration file and the root key, and the route, the
+// stop list and the run records only where the configuration sets them.
+func TestDoctorNamesEveryFileTheSignedStartChecked(t *testing.T) {
+ bare := trustFound(t, signedPlaneTree(t, "roster-a", []ed25519.PublicKey{signedTestPublic("alice")}, "roster-a"))
+ if want := "; the configuration file, trust.root_public_key and trust.roster are not the plane's to change, and the environment sets no key"; !strings.HasSuffix(bare, want) {
+ t.Errorf("the trust line over a tree with no pause, route or runs = %q; want it to end %q", bare, want)
+ }
+ for _, unset := range []string{"pause.file", "reaction.route", "reaction.stops", "runs.dir"} {
+ if strings.Contains(bare, unset) {
+ t.Errorf("the trust line names %s, which the configuration leaves unset: %q", unset, bare)
+ }
+ }
+ full := trustFound(t, signedStopsTree(t))
+ if want := "; the configuration file, trust.root_public_key, trust.roster, reaction.route, reaction.stops and runs.dir are not the plane's to change"; !strings.Contains(full, want) {
+ t.Errorf("the trust line over a tree with a route, a stop list and runs = %q; want %q", full, want)
+ }
+}
+
+// TestDoctorSaysAReadGrantGrantsNothingYet: a roster holding read grants,
+// one beside an operate grant of the same key, gets one clause counting
+// them; a roster of operate and admin grants alone gets none.
+func TestDoctorSaysAReadGrantGrantsNothingYet(t *testing.T) {
+ const clause = "grant nothing yet: reading is by the health socket's peer uid and by file modes (ADR-0071)"
+ tr := signedPlaneTree(t, "roster-a", nil, "roster-a")
+ roster := filepath.Join(tr.dir, "trust", "roster.json")
+
+ writeLeveledRoster(t, roster)
+ if found := trustFound(t, tr); strings.Contains(found, clause) || strings.Contains(found, "read grant") {
+ t.Errorf("a roster of admin and operate grants: %q names a read grant", found)
+ }
+
+ writeGrantsRoster(t, roster, map[string][]string{"alice": {"operate", "read"}, "bob": {"read"}, "dave": {"admin"}})
+ found := trustFound(t, tr)
+ if want := "; 2 read grant(s) " + clause; !strings.HasSuffix(found, want) {
+ t.Errorf("a roster with two read grants: %q; want it to end %q", found, want)
+ }
+}
+
+// writeGrantsRoster writes a roster of roster-a at serial 1 in which each
+// named key holds one grant per level, each over its own project of acme.
+func writeGrantsRoster(t *testing.T, path string, levels map[string][]string) {
+ t.Helper()
+ var keys []any
+ for name, of := range levels {
+ k := signedTestPublic(name)
+ grants := make([]any, 0, len(of))
+ for i, level := range of {
+ grants = append(grants, map[string]any{"tenant": "acme", "project": "p" + string(rune('a'+i)), "level": level})
+ }
+ keys = append(keys, map[string]any{
+ "keyId": policykey.KeyID(k), "alg": "ed25519", "role": "operator", "subject": name,
+ "publicKey": policykey.FormatPublic(k), "grants": grants,
+ })
+ }
+ now := time.Now().UTC().Truncate(time.Second)
+ body, err := canon.Canonicalize(map[string]any{
+ "kind": trust.DocumentKind, "rosterId": "roster-a", "serial": 1,
+ "issuedAt": now.Add(-time.Hour).Format(time.RFC3339), "expiresAt": now.Add(time.Hour).Format(time.RFC3339), "keys": keys,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sig, err := bundle.SignBytesAs(trust.PayloadType, body, signedTestKey("root"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := trust.MarshalFile(trust.Envelope{PayloadType: trust.PayloadType, Payload: body,
+ Signatures: []trust.Signature{{KeyID: policykey.KeyID(signedTestPublic("root")), Signature: sig}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(t, path, raw)
+}
diff --git a/docs/adr/0071-operators-are-named-by-a-signed-roster.md b/docs/adr/0071-operators-are-named-by-a-signed-roster.md
index 088fd2d0..8ec0e008 100644
--- a/docs/adr/0071-operators-are-named-by-a-signed-roster.md
+++ b/docs/adr/0071-operators-are-named-by-a-signed-roster.md
@@ -21,6 +21,10 @@ route file and the stops directory. A signed start refuses those four, the
configuration file and the trust root key file wherever the plane's account
owns or may write one or any directory on its path, and any key the
environment set. Amended in 0.17 by ADR-0073: the roster file too.
+Amended in 0.17: the `read` level parses and is reserved. It grants nothing
+until a receiver reads by it; a plane is read through the health socket by
+peer uid and through files by their modes, and `doctor` counts the roster's
+read grants as granting nothing yet.
## Context
diff --git a/docs/backlog.md b/docs/backlog.md
index eb4267c8..c425525e 100644
--- a/docs/backlog.md
+++ b/docs/backlog.md
@@ -66,7 +66,7 @@ mappings.
| ID | Work | Depends on | Acceptance |
| --- | --- | --- | --- |
-| B13 | Authenticate operators and scope authority | none | Reader/operator/admin capabilities are receiver-enforced. Cross-project reads and operations are refused; a stop names its actual agent or principal scope. Two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials. The collector takes evidence only on its unix socket, and the TCP export is removed |
+| B13 | Authenticate operators and scope authority | none | Under `approvals.authority: signed`, operate and admin are enforced by each receiver from the signed roster ([ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md), [ADR-0073](adr/0073-an-approval-counts-only-as-the-plane-judges-it.md)); reading is by peer uid against `health.socket_uid` on the health socket and by file modes, and the roster's `read` grant is reserved. Open: reads and operations of another project are refused at every receiver, not only at the signed ones [contracts.md](contracts.md#signed-files-and-their-project-checks) lists; a stop names its actual agent or principal scope; two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials |
| B14 | Add retention and recovery | none | Rotation, disk budget, cursor expiry, duplicates, interrupted export and backup/restore are tested for evidence and observations. A restored process cannot reuse spent approval authority. Read-only inspection cannot mutate recovery state without saying so. The findings and observation logs take the RecordLog port, which then moves to its consumer with a read interface, a kind selector, its bounds and its diagnostics ([ADR-0072](adr/0072-the-collector-keeps-bounded-segments-of-one-project-behind-a-credential.md)) |
| B26 | Review findings and export an incident package | B13, B14 | A separate review log records scoped author, time, reason and revision for open/in-review/closed and disposition changes without changing the finding's verdict. Grouped occurrences retain source finding ids, rule versions, tenant/project and run references. Reopening, cross-project access, concurrent edits, export gaps and hostile text have cases. A metadata-only package joins the findings, decisions, approvals, policy/procedure digests, coverage and actual reaction results, marks missing inputs, and is read by an external consumer. Closing a case neither lifts a stop nor grants an approval |
| B28 | Run continuous supervision and authorized reaction | B12, B13, B14, B21, B35 | An operator-configured service follows versioned evidence and observation exports with durable cursors, checks procedures and delivers findings through bounded queues. Source silence, export gaps, sampling, lag and unreadable inputs remain visible and never look like completed work. Restart and replay do not duplicate an authority-bearing reaction. Every stop uses the existing confirmed-finding bar and a signed, scoped, expiring route; unknown or observation-only findings cannot stop a run. Tests exercise concurrent runs, source outage, partial exports, expired authority, replay and notifier failure. A notifier failure cannot grant authority or change a verdict; no reaction cancels an effect already running |
diff --git a/docs/contracts.md b/docs/contracts.md
index a59a4180..2d7f33ba 100644
--- a/docs/contracts.md
+++ b/docs/contracts.md
@@ -1473,6 +1473,28 @@ class before an external decision point is asked, in the order
[ADR-0046](adr/0046-a-finding-stops-one-run-through-a-signed-route.md) gives.
A decision names no stop's entry id.
+## Signed files and their project checks
+
+Each file or answer a plane takes on a signature, where it holds that to the
+plane's own `tenant_id` and `project_id`, and the test that refuses another
+project's. A row with no project check says so. Outside
+`approvals.authority: signed`, answers, pauses and runs are claims of whoever
+may write where they are read ([status.md](status.md)). A test in
+`internal/docscheck` fails when a function or test cited here is gone.
+
+| Receiver | What it takes | Tenant and project check | Where | Pinned by |
+| --- | --- | --- | --- | --- |
+| Trust roster | `trust.roster`, signed by the root key | none: one roster serves every project and each grant names one; its id has to be `trust.roster_id` | `internal/trust/file.go` `fileRoster.candidate` | `internal/trust` `TestARestartNeverTakesARosterOfAnotherID` |
+| Approval answer | an operator's answer in `approvals.inbox_dir` | the authorizer is asked about the held call's own tenant and project, and a grant matches both exactly | `internal/gateway/authority.go` `Pipeline.judgeEnvelope` `internal/operators/authorize.go` `rosterAuthorizer.Authorize` | `internal/gateway` `TestBobsAnswerResumesNothingAndAlicesOnce` `internal/operators` `TestTwoOperatorsEachActOnlyWhereTheirGrantsReach` |
+| Pause file | `pause.file` of version `3` | its tenant and project are the plane's | `internal/pause/signedpause/judge.go` `Authority.Judge` | `internal/pause/signedpause` `TestNoOneButAPermittedOperatorPausesOrLifts` |
+| Run record | a `3.0` record in `records/` of `runs.dir` | its tenant and project are the plane's | `internal/runs/signedruns/judge.go` `Authority.Judge` | `internal/runs/signedruns` `TestBothJudgesTakeOnlyAnOperatorOfThePlanesProject` |
+| Route | `reaction.route` of `v1alpha2` | its tenant, project and file are the plane's | `internal/reaction/signed.go` `vouchedWithin` | `internal/reaction` `TestASignedRouteIsRefusedWithItsCause` |
+| Stop list header | the `2.0` header of `reaction.stops` | its tenant and project are the route's | `internal/reaction/header2.go` `judgeSignedHeader` | `internal/reaction` `TestASignedListIsRefusedAndNeverReadAsNoStops` |
+| Stop and covered lines | each line of that list | a stop's project, and a covered line's tenant and project, are the route's | `internal/reaction/permits.go` `Route.Permits` `internal/reaction/judge.go` `judging.coveredLine` | `internal/reaction` `TestASignedRoutePermitsOnlyAStopOfItsProjectAndScope` `TestASignedListIsRefusedAndNeverReadAsNoStops` |
+| Lift | a `v1alpha2` lift line | its tenant and project are the route's | `internal/reaction/lift2.go` `verifySignedLiftLine` | `internal/reaction` `TestASignedListIsRefusedAndNeverReadAsNoStops` |
+| Policy bundle and statement | `policy.bundle_file` and `policy.statement_file`, signed by the pinned policy and freshness keys | none: a bundle names no tenant or project; its id has to be `policy.bundle_id` | `internal/policy/holder.go` `Holder.loadPinned` | `internal/policy` `TestInstallUnconfirmedHoldsToTheFloor` |
+| Route under a route key | outside `signed`, a `v1alpha1` route signed by `reaction.public_key`, and its lifts | tenant only: such a route names no project, and a stop's tenant is the route's | `internal/reaction/permits.go` `Route.Permits` | `internal/reaction` `TestJudgeHoldsEveryStopToTheRoute` |
+
## The enforcement API
How a tool outside MCP asks before it acts and reports what it did: the
diff --git a/docs/status.md b/docs/status.md
index dccb93c8..21e61884 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -70,7 +70,7 @@ Nothing here is a security boundary. Do not deploy it as one.
| Canonical action digest | `implemented` | `internal/canon/`, goldens in `testdata/digest/`, [ADR-0005](adr/0005-canonical-action-digest.md) and [ADR-0010](adr/0010-digest-domain-separation.md). Cross-language parity is unproved until a second implementation exists |
| Policy documents, signed bundles and the matcher | `implemented` | `internal/policy/rules/` parses `agent-policy/v1alpha1` strictly, `internal/policy/bundle/` signs and verifies bundle bytes (Ed25519 over the DSSE PAE); `internal/policy/match/` evaluates three-valued; `internal/policy/` loads a bundle into a snapshot and installs it in order; [ADR-0003](adr/0003-policy-model-and-external-pdp.md), [ADR-0012](adr/0012-policy-kernel-semantics.md). A holder pinned to the bundle id it serves refuses every other. On a plane a snapshot is confirmed only by a freshness statement bound to its bundle and signed under a key apart from the bundle's, and its serial floor is ordered by serial and then `issuedAt` (`internal/policy/statement.go`, `floor.go`, `experimental`, [ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md)); an unconfirmed snapshot decides `POLICY_STALE` |
| Policy serial floor on disk | `experimental` | `internal/policystate/`. One strict-JSON file per bundle id in an owner-only directory whose marker names a plane's or a signer's kind; raised under the directory's lock against the file's value; `Init` and `Reset` are the operator's, and the gateway binary holds neither ([ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md)). The commands use it (`policy renew`, `policy state init` and `reset`), and so does every plane, through `policy.state_dir`. A floor protects only while its directory outlives the plane, and an account that can write the directory can lower it |
-| Trust roster, authentication and authorization ports | `experimental` | `internal/trust/` (the `Roster` port and its `file` kind: one DSSE envelope of `trust-roster/v1alpha1` signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the `authority` floor of `internal/policystate/`), `internal/operators/` (the `Authenticator` and `Authorizer` ports and their `roster` kinds, a veto that only denies, a timeout and a bound on calls in flight), `operators roster sign` and `operators floor init` in `cmd/guardana-control/`, the conformance cases in `testdata/ports/` listed in `scripts/ports.txt`, which a test holds to the tree, [ADR-0066](adr/0066-each-decision-about-authority-is-a-port.md), [ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md). A plane reads a roster only under `approvals.authority: signed`, where `doctor` and `/healthz` report `trust`, `operators.authenticator` and `operators.authorizer`; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime |
+| Trust roster, authentication and authorization ports | `experimental` | `internal/trust/` (the `Roster` port and its `file` kind: one DSSE envelope of `trust-roster/v1alpha1` signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the `authority` floor of `internal/policystate/`), `internal/operators/` (the `Authenticator` and `Authorizer` ports and their `roster` kinds, a veto that only denies, a timeout and a bound on calls in flight), `operators roster sign` and `operators floor init` in `cmd/guardana-control/`, the conformance cases in `testdata/ports/` listed in `scripts/ports.txt`, which a test holds to the tree, [ADR-0066](adr/0066-each-decision-about-authority-is-a-port.md), [ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md). A plane reads a roster only under `approvals.authority: signed`, where `doctor` and `/healthz` report `trust`, `operators.authenticator` and `operators.authorizer`; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime; a `read` grant parses and grants nothing yet, which `doctor` says; [contracts.md](contracts.md#signed-files-and-their-project-checks) lists each signed file a plane takes with its project check |
| Policy refresh on a plane | `experimental` | `internal/policywatch/`, wired by `cmd/guardana-gateway/planepolicy.go`, [ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md). At start judges the floor, the bundle and the statement: a bundle below or beside the floor, or above it without its statement, refuses the start, and a missing or expired statement starts the plane unconfirmed. Then reads both files every `policy.poll_interval`, installs a newer bundle only with its statement, refuses and counts every bad replacement without moving the confirmation, and withdraws the confirmation while the wall clock, measured against the monotonic clock, has fallen back more than a second plus 100 parts per million of the time since its highest reading, or reads too far from the first reading to judge, and until a statement issued after that arrives. A clock reading outside 1970 to 9999, or behind the verified time, the latest `issuedAt` the plane verified, decides nothing: every call is `POLICY_STALE`, reads included. `/healthz`, `/metrics` and `doctor` report it. Freshness trusts the plane's clock at start, and the operator schedules `policy renew`; nothing in the repository schedules it outside `dev` |
| External policy decision point (PDP) over AuthZEN, able to veto | `experimental` | [ADR-0017](adr/0017-an-external-decision-point-can-veto.md). The kernel takes the answer and the policy format reads it (`internal/core/`, `internal/policy/`); `adapters/authzen/` asks and reads the answer strictly; `internal/gateway/` asks only when the answer can change the decision; `cmd/guardana-gateway` configures it from the `pdp.` keys, checks its published metadata in `doctor` and counts the asks on `/healthz`. The end-to-end tests reach a decision point on the loopback over plaintext; TLS is exercised in the adapter's own tests, against the roots a test hands it, and the command has no setting for a private certificate authority |
| Approval binding to one action digest and one bundle digest | `implemented` | `internal/core/approval/` (`Bind`), golden in `testdata/digest/binding.json`. `internal/gateway/` holds a request for approval, keeps the record of what it minted itself, and spends the approval exactly once on the retry, in its own ledger ([ADR-0013](adr/0013-mcp-interception-approvals-and-modes.md)). An approver outside the plane answers through the approval authority below; a hold does not survive a restart, and a plane that keeps a hold journal closes the trail of one it lost at its next start, and counts one it cannot close as left open ([ADR-0016](adr/0016-approval-providers-and-the-lost-hold.md)) |
diff --git a/internal/docscheck/receivers_test.go b/internal/docscheck/receivers_test.go
new file mode 100644
index 00000000..f62c757e
--- /dev/null
+++ b/internal/docscheck/receivers_test.go
@@ -0,0 +1,264 @@
+package docscheck
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "path"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+const (
+ receiversPage = "docs/contracts.md"
+ receiversHeading = "## Signed files and their project checks"
+)
+
+// receiversColumns is the table's header; the last two columns hold only
+// code spans: a Go file followed by functions declared in it, and a package
+// directory followed by tests of that directory.
+var receiversColumns = []string{"Receiver", "What it takes", "Tenant and project check", "Where", "Pinned by"}
+
+var citedSpan = regexp.MustCompile("`([^`]+)`")
+
+// receiverProblems holds the receiver table of receiversPage in fsys to the
+// tree: present, of at least one row, and every function and test a row
+// cites declared where it says.
+func receiverProblems(fsys fs.FS) []string {
+ rows, problem := receiverRows(fsys)
+ if problem != "" {
+ return []string{problem}
+ }
+ var problems []string
+ for _, row := range rows {
+ name := row[0]
+ if strings.TrimSpace(row[2]) == "" {
+ problems = append(problems, fmt.Sprintf("%s: row %q says nothing of its tenant and project check", receiversPage, name))
+ }
+ for _, p := range citedFunctions(fsys, row[3]) {
+ problems = append(problems, fmt.Sprintf("%s: row %q: %s", receiversPage, name, p))
+ }
+ for _, p := range citedTests(fsys, row[4]) {
+ problems = append(problems, fmt.Sprintf("%s: row %q: %s", receiversPage, name, p))
+ }
+ }
+ return problems
+}
+
+// receiverRows returns the cells of each row of the first table under
+// receiversHeading, or why there is no such table.
+func receiverRows(fsys fs.FS) ([][]string, string) {
+ data, err := fs.ReadFile(fsys, receiversPage)
+ if err != nil {
+ return nil, err.Error()
+ }
+ _, section, found := strings.Cut(string(data), "\n"+receiversHeading+"\n")
+ if !found {
+ return nil, fmt.Sprintf("%s holds no section %q", receiversPage, receiversHeading)
+ }
+ if next := strings.Index(section, "\n## "); next >= 0 {
+ section = section[:next]
+ }
+ var table [][]string
+ for _, line := range strings.Split(section, "\n") {
+ if strings.HasPrefix(line, "|") {
+ table = append(table, tableCells(line))
+ } else if len(table) > 0 {
+ break
+ }
+ }
+ switch {
+ case len(table) == 0:
+ return nil, fmt.Sprintf("%s: the section %q holds no table", receiversPage, receiversHeading)
+ case !slices.Equal(table[0], receiversColumns):
+ return nil, fmt.Sprintf("%s: the receiver table's header is %q, want %q", receiversPage, table[0], receiversColumns)
+ case len(table) < 3:
+ return nil, fmt.Sprintf("%s: the receiver table holds no row", receiversPage)
+ }
+ for _, row := range table[2:] {
+ if len(row) != len(receiversColumns) {
+ return nil, fmt.Sprintf("%s: a receiver row has %d cells, want %d: %q", receiversPage, len(row), len(receiversColumns), row)
+ }
+ }
+ return table[2:], ""
+}
+
+// citedFunctions holds each function span of cell to the Go file span
+// before it: a non-test file of fsys declaring that function, or that method
+// as "Type.Method".
+func citedFunctions(fsys fs.FS, cell string) []string {
+ var problems []string
+ file, functions := "", 0
+ var decls map[string]bool
+ for _, m := range citedSpan.FindAllStringSubmatch(cell, -1) {
+ span := m[1]
+ if strings.HasSuffix(span, ".go") {
+ file = span
+ var err error
+ if decls, err = declaredFunctions(fsys, file); err != nil {
+ problems = append(problems, err.Error())
+ }
+ continue
+ }
+ functions++
+ switch {
+ case file == "":
+ problems = append(problems, fmt.Sprintf("function %s is cited before any file", span))
+ case decls != nil && !decls[span]:
+ problems = append(problems, fmt.Sprintf("%s declares no function %s", file, span))
+ }
+ }
+ if functions == 0 {
+ problems = append(problems, "cites no function that makes the check")
+ }
+ return problems
+}
+
+// declaredFunctions names every function of the Go file rel, a method as
+// "Type.Method".
+func declaredFunctions(fsys fs.FS, rel string) (map[string]bool, error) {
+ if strings.HasSuffix(rel, "_test.go") {
+ return nil, fmt.Errorf("%s is a test file, not the code that makes the check", rel)
+ }
+ src, err := fs.ReadFile(fsys, rel)
+ if err != nil {
+ return nil, err
+ }
+ file, err := parser.ParseFile(token.NewFileSet(), rel, src, parser.SkipObjectResolution)
+ if err != nil {
+ return nil, err
+ }
+ names := map[string]bool{}
+ for _, decl := range file.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok {
+ continue
+ }
+ name := fn.Name.Name
+ if fn.Recv != nil && len(fn.Recv.List) == 1 {
+ name = receiverType(fn.Recv.List[0].Type) + "." + name
+ }
+ names[name] = true
+ }
+ return names, nil
+}
+
+func receiverType(e ast.Expr) string {
+ switch t := e.(type) {
+ case *ast.StarExpr:
+ return receiverType(t.X)
+ case *ast.IndexExpr:
+ return receiverType(t.X)
+ case *ast.IndexListExpr:
+ return receiverType(t.X)
+ case *ast.Ident:
+ return t.Name
+ }
+ return ""
+}
+
+// citedTests holds each test span of cell to the package directory span
+// before it: one of that directory's _test.go files declares it.
+func citedTests(fsys fs.FS, cell string) []string {
+ var problems []string
+ dir, tests := "", 0
+ for _, m := range citedSpan.FindAllStringSubmatch(cell, -1) {
+ span := m[1]
+ if !strings.HasPrefix(span, "Test") {
+ dir = span
+ continue
+ }
+ tests++
+ if dir == "" {
+ problems = append(problems, fmt.Sprintf("test %s is cited before any package", span))
+ continue
+ }
+ if !declaresTest(fsys, dir, span) {
+ problems = append(problems, fmt.Sprintf("no _test.go file of %s declares %s", dir, span))
+ }
+ }
+ if tests == 0 {
+ problems = append(problems, "cites no test that pins the check")
+ }
+ return problems
+}
+
+func declaresTest(fsys fs.FS, dir, name string) bool {
+ entries, err := fs.ReadDir(fsys, dir)
+ if err != nil {
+ return false
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), "_test.go") {
+ continue
+ }
+ src, err := fs.ReadFile(fsys, path.Join(dir, e.Name()))
+ if err == nil && strings.Contains(string(src), "\nfunc "+name+"(") {
+ return true
+ }
+ }
+ return false
+}
+
+func TestTheReceiverTableCitesCodeAndTestsThatExist(t *testing.T) {
+ for _, problem := range receiverProblems(repoFS(t)) {
+ t.Error(problem)
+ }
+ rows, problem := receiverRows(repoFS(t))
+ if problem != "" || len(rows) < 8 {
+ t.Errorf("the receiver table holds %d row(s), %s; the roster, the answer, the pause, the run record, the route, the header, the lines and the lift each need one", len(rows), problem)
+ }
+}
+
+// receiverStage is a tree of one page and the two files its table cites.
+func receiverStage(table string) fstest.MapFS {
+ return fstest.MapFS{
+ receiversPage: {Data: []byte("# Wire contracts\n\n" + receiversHeading + "\n\nWhat a plane takes.\n\n" + table + "\nAfter.\n\n## Next\n")},
+ "internal/p/judge.go": {Data: []byte("package p\n\ntype A struct{}\n\nfunc (a *A) Judge() {}\n\nfunc vouched() {}\n")},
+ "internal/p/judge_test.go": {Data: []byte("package p\n\nimport \"testing\"\n\nfunc TestAnotherProjectIsRefused(t *testing.T) {}\n")},
+ }
+}
+
+const receiverHeader = "| Receiver | What it takes | Tenant and project check | Where | Pinned by |\n| --- | --- | --- | --- | --- |\n"
+
+func TestTheReceiverCheckRefusesWhatTheTreeDoesNotHold(t *testing.T) {
+ good := receiverHeader + "| Pause | a file | the plane's | `internal/p/judge.go` `A.Judge`, `vouched` | `internal/p` `TestAnotherProjectIsRefused` |\n"
+ if problems := receiverProblems(receiverStage(good)); len(problems) != 0 {
+ t.Fatalf("the control, a table whose citations all exist: %q", problems)
+ }
+ for _, c := range []struct{ name, table, want string }{
+ {"a test the package does not declare", strings.Replace(good, "`TestAnotherProjectIsRefused`", "`TestAnotherProjectIsTaken`", 1), "no _test.go file of internal/p declares TestAnotherProjectIsTaken"},
+ {"a test of another package", strings.Replace(good, "`internal/p` `Test", "`internal/q` `Test", 1), "no _test.go file of internal/q declares TestAnotherProjectIsRefused"},
+ {"no test", strings.Replace(good, "`internal/p` `TestAnotherProjectIsRefused`", "none", 1), "cites no test that pins the check"},
+ {"a method the file does not declare", strings.Replace(good, "`A.Judge`", "`B.Judge`", 1), "internal/p/judge.go declares no function B.Judge"},
+ {"a function the file does not declare", strings.Replace(good, "`vouched`", "`vouchedWithin`", 1), "internal/p/judge.go declares no function vouchedWithin"},
+ {"a file the tree does not hold", strings.Replace(good, "judge.go", "gone.go", 1), "internal/p/gone.go"},
+ {"a test file cited as the check", strings.Replace(good, "judge.go", "judge_test.go", 1), "is a test file"},
+ {"no function", strings.Replace(good, "`internal/p/judge.go` `A.Judge`, `vouched`", "none", 1), "cites no function that makes the check"},
+ {"no rows", receiverHeader, "the receiver table holds no row"},
+ {"another header", strings.Replace(good, "| Where |", "| File |", 1), "the receiver table's header is"},
+ {"a row of four cells", receiverHeader + "| Pause | a file | `internal/p/judge.go` `A.Judge` | `internal/p` `TestAnotherProjectIsRefused` |\n", "a receiver row has 4 cells"},
+ {"an empty check", strings.Replace(good, "the plane's", "", 1), "says nothing of its tenant and project check"},
+ {"no table", "", "holds no table"},
+ } {
+ problems := receiverProblems(receiverStage(c.table))
+ if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, c.want) }) {
+ t.Errorf("%s: problems %q, want one saying %q", c.name, problems, c.want)
+ }
+ }
+ stage := receiverStage(good)
+ delete(stage, receiversPage)
+ stage["docs/other.md"] = &fstest.MapFile{Data: []byte(receiversHeading + "\n\n" + good)}
+ if problems := receiverProblems(stage); len(problems) == 0 {
+ t.Error("a tree without the page passes")
+ }
+ stage[receiversPage] = &fstest.MapFile{Data: []byte("# Wire contracts\n\n## Another section\n\n" + good)}
+ if problems := receiverProblems(stage); len(problems) != 1 || !strings.Contains(problems[0], "holds no section") {
+ t.Errorf("a page whose table sits under another heading: %q", problems)
+ }
+}
diff --git a/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html b/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html
index 59aa18ab..3dd894a1 100644
--- a/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html
+++ b/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html
@@ -132,7 +132,11 @@
Context
Today an approver is whoever can write the approvals directory, and the
approver id is a claim. ADR-0066 says operators are authenticated against a
diff --git a/site/docs/backlog.html b/site/docs/backlog.html
index 18ac3344..41749b0d 100644
--- a/site/docs/backlog.html
+++ b/site/docs/backlog.html
@@ -253,7 +253,7 @@
Milestone 3: a small team
B13
Authenticate operators and scope authority
none
-
Reader/operator/admin capabilities are receiver-enforced. Cross-project reads and operations are refused; a stop names its actual agent or principal scope. Two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials. The collector takes evidence only on its unix socket, and the TCP export is removed
+
Under approvals.authority: signed, operate and admin are enforced by each receiver from the signed roster (ADR-0071, ADR-0073); reading is by peer uid against health.socket_uid on the health socket and by file modes, and the roster's read grant is reserved. Open: reads and operations of another project are refused at every receiver, not only at the signed ones contracts.md lists; a stop names its actual agent or principal scope; two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials
runs open --key reads the roster and the key before it makes an empty
directory a signed layout, so a roster that does not read leaves the
directory as it was.
+
doctor's trust line names every file the signed start checked, the
+roster among them, and counts the roster's read grants, which grant
+nothing yet: a plane is read through health.socket by peer uid and
+through files by their modes.
Upgrading from 0.15 changes four things an operator must act on.
diff --git a/site/docs/contracts.html b/site/docs/contracts.html
index 36cf529c..99936c06 100644
--- a/site/docs/contracts.html
+++ b/site/docs/contracts.html
@@ -1873,6 +1873,96 @@
The codes
class before an external decision point is asked, in the order
ADR-0046 gives.
A decision names no stop's entry id.
+
Signed files and their project checks
+
Each file or answer a plane takes on a signature, where it holds that to the
+plane's own tenant_id and project_id, and the test that refuses another
+project's. A row with no project check says so. Outside
+approvals.authority: signed, answers, pauses and runs are claims of whoever
+may write where they are read (status.md). A test in
+internal/docscheck fails when a function or test cited here is gone.
+
+
+
+
Receiver
+
What it takes
+
Tenant and project check
+
Where
+
Pinned by
+
+
+
+
+
Trust roster
+
trust.roster, signed by the root key
+
none: one roster serves every project and each grant names one; its id has to be trust.roster_id
How a tool outside MCP asks before it acts and reports what it did: the
package guardana.control.enforce.v1alpha1, in
diff --git a/site/docs/status.html b/site/docs/status.html
index 4c7ffbc7..00737ebe 100644
--- a/site/docs/status.html
+++ b/site/docs/status.html
@@ -251,7 +251,7 @@
Components
Trust roster, authentication and authorization ports
experimental
-
internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime
+
internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime; a read grant parses and grants nothing yet, which doctor says; contracts.md lists each signed file a plane takes with its project check
Policy refresh on a plane
From 983b20edb33afd8291f6a3473a18d38477550a96 Mon Sep 17 00:00:00 2001
From: Konrad Karauda
Date: Tue, 10 Nov 2026 19:46:38 +0000
Subject: [PATCH 4/4] docs: the receiver table names the tenant each check
holds a file to
Answers and pauses are judged under tenant_id; runs, routes and stops
under the listener's tenant, which is listener.principal.tenant_id where
set. The table names it per row and says an operator needs a grant over
each tenant where the two differ. The route rows cite the route's tenant
check, the floors are named as unsigned state, and the page covers every
file the table cites, so changing one of those checks flags it.
Signed-off-by: Konrad Karauda
---
docs/contracts.md | 25 ++++++++++++++++---------
site/docs/contracts.html | 30 ++++++++++++++++++------------
2 files changed, 34 insertions(+), 21 deletions(-)
diff --git a/docs/contracts.md b/docs/contracts.md
index 2d7f33ba..ff4b05ab 100644
--- a/docs/contracts.md
+++ b/docs/contracts.md
@@ -2,7 +2,7 @@
title: Wire contracts
summary: What the v1 messages are versioned by, what a receiver refuses them for, and how the evidence events chain.
type: spec
-covers: [api/proto/guardana/control/v1/**, api/gen/go/guardana/control/v1/**, api/proto/guardana/control/enforce/**, api/gen/go/guardana/control/enforce/**, pkg/contract/**, internal/canon/**, internal/evidence/chain.go, internal/evidence/version.go, internal/trailfile/export.go, internal/trailfile/export_records.go, internal/trailfile/cursor.go, internal/lineexport/**, internal/runs/record.go, internal/runs/state.go, internal/reaction/route.go, internal/reaction/line.go, internal/reaction/lift.go, internal/reaction/envelope.go, internal/reaction/judge.go, internal/reaction/signed.go, internal/reaction/route2.go, internal/reaction/header2.go, internal/reaction/lift2.go, testdata/contracts/**, testdata/digest/**, testdata/enforce/**, testdata/export/**]
+covers: [api/proto/guardana/control/v1/**, api/gen/go/guardana/control/v1/**, api/proto/guardana/control/enforce/**, api/gen/go/guardana/control/enforce/**, pkg/contract/**, internal/canon/**, internal/evidence/chain.go, internal/evidence/version.go, internal/trailfile/export.go, internal/trailfile/export_records.go, internal/trailfile/cursor.go, internal/lineexport/**, internal/runs/record.go, internal/runs/state.go, internal/reaction/*.go, testdata/contracts/**, testdata/digest/**, testdata/enforce/**, testdata/export/**, internal/trust/file.go, internal/operators/authorize.go, internal/gateway/authority.go, internal/pause/signedpause/**, internal/runs/signedruns/**, internal/policy/holder.go, internal/gatewayconfig/reaction.go, cmd/guardana-gateway/runs.go, cmd/guardana-gateway/reaction_signed.go, cmd/guardana-gateway/build_pipeline.go]
stability: stable
---
@@ -1475,9 +1475,12 @@ A decision names no stop's entry id.
## Signed files and their project checks
-Each file or answer a plane takes on a signature, where it holds that to the
-plane's own `tenant_id` and `project_id`, and the test that refuses another
-project's. A row with no project check says so. Outside
+The table lists each file or answer a plane accepts on a signature, the check
+that holds it to a tenant of the plane's, named per row, and to its
+`project_id`, and the test that refuses another project's. The listener's
+tenant is `listener.principal.tenant_id` where it is set, and `tenant_id`
+otherwise. A row with no project check says so. The floors are unsigned
+state the plane writes itself, so none is a row. Outside
`approvals.authority: signed`, answers, pauses and runs are claims of whoever
may write where they are read ([status.md](status.md)). A test in
`internal/docscheck` fails when a function or test cited here is gone.
@@ -1485,15 +1488,19 @@ may write where they are read ([status.md](status.md)). A test in
| Receiver | What it takes | Tenant and project check | Where | Pinned by |
| --- | --- | --- | --- | --- |
| Trust roster | `trust.roster`, signed by the root key | none: one roster serves every project and each grant names one; its id has to be `trust.roster_id` | `internal/trust/file.go` `fileRoster.candidate` | `internal/trust` `TestARestartNeverTakesARosterOfAnotherID` |
-| Approval answer | an operator's answer in `approvals.inbox_dir` | the authorizer is asked about the held call's own tenant and project, and a grant matches both exactly | `internal/gateway/authority.go` `Pipeline.judgeEnvelope` `internal/operators/authorize.go` `rosterAuthorizer.Authorize` | `internal/gateway` `TestBobsAnswerResumesNothingAndAlicesOnce` `internal/operators` `TestTwoOperatorsEachActOnlyWhereTheirGrantsReach` |
-| Pause file | `pause.file` of version `3` | its tenant and project are the plane's | `internal/pause/signedpause/judge.go` `Authority.Judge` | `internal/pause/signedpause` `TestNoOneButAPermittedOperatorPausesOrLifts` |
-| Run record | a `3.0` record in `records/` of `runs.dir` | its tenant and project are the plane's | `internal/runs/signedruns/judge.go` `Authority.Judge` | `internal/runs/signedruns` `TestBothJudgesTakeOnlyAnOperatorOfThePlanesProject` |
-| Route | `reaction.route` of `v1alpha2` | its tenant, project and file are the plane's | `internal/reaction/signed.go` `vouchedWithin` | `internal/reaction` `TestASignedRouteIsRefusedWithItsCause` |
+| Approval answer | an operator's answer in `approvals.inbox_dir` | the authorizer is asked about the held call's tenant, `tenant_id`, and its project, and a grant matches both exactly | `internal/gateway/authority.go` `Pipeline.judgeEnvelope` `internal/operators/authorize.go` `rosterAuthorizer.Authorize` | `internal/gateway` `TestBobsAnswerResumesNothingAndAlicesOnce` `internal/operators` `TestTwoOperatorsEachActOnlyWhereTheirGrantsReach` |
+| Pause file | `pause.file` of version `3` | its tenant is `tenant_id` and its project the plane's | `internal/pause/signedpause/judge.go` `Authority.Judge` | `internal/pause/signedpause` `TestNoOneButAPermittedOperatorPausesOrLifts` |
+| Run record | a `3.0` record in `records/` of `runs.dir` | its tenant is the listener's and its project the plane's | `internal/runs/signedruns/judge.go` `Authority.Judge` | `internal/runs/signedruns` `TestBothJudgesTakeOnlyAnOperatorOfThePlanesProject` |
+| Route | `reaction.route` of `v1alpha2` | its tenant is the listener's, and its project and file the plane's | `internal/gatewayconfig/reaction.go` `Config.CheckRoute` `internal/reaction/signed.go` `vouchedWithin` | `internal/gatewayconfig` `TestARouteIsOfTheListenersTenant` `TestARouteIsOfTheAuthoritysFormatAndThePlanesProject` `internal/reaction` `TestASignedRouteIsRefusedWithItsCause` |
| Stop list header | the `2.0` header of `reaction.stops` | its tenant and project are the route's | `internal/reaction/header2.go` `judgeSignedHeader` | `internal/reaction` `TestASignedListIsRefusedAndNeverReadAsNoStops` |
| Stop and covered lines | each line of that list | a stop's project, and a covered line's tenant and project, are the route's | `internal/reaction/permits.go` `Route.Permits` `internal/reaction/judge.go` `judging.coveredLine` | `internal/reaction` `TestASignedRoutePermitsOnlyAStopOfItsProjectAndScope` `TestASignedListIsRefusedAndNeverReadAsNoStops` |
| Lift | a `v1alpha2` lift line | its tenant and project are the route's | `internal/reaction/lift2.go` `verifySignedLiftLine` | `internal/reaction` `TestASignedListIsRefusedAndNeverReadAsNoStops` |
| Policy bundle and statement | `policy.bundle_file` and `policy.statement_file`, signed by the pinned policy and freshness keys | none: a bundle names no tenant or project; its id has to be `policy.bundle_id` | `internal/policy/holder.go` `Holder.loadPinned` | `internal/policy` `TestInstallUnconfirmedHoldsToTheFloor` |
-| Route under a route key | outside `signed`, a `v1alpha1` route signed by `reaction.public_key`, and its lifts | tenant only: such a route names no project, and a stop's tenant is the route's | `internal/reaction/permits.go` `Route.Permits` | `internal/reaction` `TestJudgeHoldsEveryStopToTheRoute` |
+| Route under a route key | outside `signed`, a `v1alpha1` route signed by `reaction.public_key`, and its lifts | tenant only: the route's tenant is the listener's, and a stop's the route's; such a route names no project | `internal/gatewayconfig/reaction.go` `Config.CheckRoute` `internal/reaction/permits.go` `Route.Permits` | `internal/gatewayconfig` `TestARouteIsOfTheListenersTenant` `internal/reaction` `TestJudgeHoldsEveryStopToTheRoute` |
+
+Where `listener.principal.tenant_id` differs from `tenant_id`, an operator
+needs a grant over each: answers and pauses are judged under `tenant_id`, runs
+and stops under the listener's tenant.
## The enforcement API
diff --git a/site/docs/contracts.html b/site/docs/contracts.html
index 99936c06..6348b64b 100644
--- a/site/docs/contracts.html
+++ b/site/docs/contracts.html
@@ -1874,9 +1874,12 @@
The codes
ADR-0046 gives.
A decision names no stop's entry id.
Signed files and their project checks
-
Each file or answer a plane takes on a signature, where it holds that to the
-plane's own tenant_id and project_id, and the test that refuses another
-project's. A row with no project check says so. Outside
+
The table lists each file or answer a plane accepts on a signature, the check
+that holds it to a tenant of the plane's, named per row, and to its
+project_id, and the test that refuses another project's. The listener's
+tenant is listener.principal.tenant_id where it is set, and tenant_id
+otherwise. A row with no project check says so. The floors are unsigned
+state the plane writes itself, so none is a row. Outside
approvals.authority: signed, answers, pauses and runs are claims of whoever
may write where they are read (status.md). A test in
internal/docscheck fails when a function or test cited here is gone.
@@ -1901,30 +1904,30 @@
Signed files and their project ch
Approval answer
an operator's answer in approvals.inbox_dir
-
the authorizer is asked about the held call's own tenant and project, and a grant matches both exactly
+
the authorizer is asked about the held call's tenant, tenant_id, and its project, and a grant matches both exactly
Where listener.principal.tenant_id differs from tenant_id, an operator
+needs a grant over each: answers and pauses are judged under tenant_id, runs
+and stops under the listener's tenant.
The enforcement API
How a tool outside MCP asks before it acts and reports what it did: the
package guardana.control.enforce.v1alpha1, in