From b4d52904fbb02d90bf59a1146675b6fad19e9b4b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 9 Aug 2026 16:52:28 +0200 Subject: [PATCH] feat(contract): name every op a cmd may carry, and home the app's own codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry gains an ops block — site.mode.set and battery.hold, each with the scope its grant must carry. The pair was hand-written twice, here beside the dispatcher and again in the app's simulator, and nothing compared the copies. The generator now renders the block as RegistryOps and TestRegistryOpsMatchCommandTable holds defaultOps() to it, the same arrangement mode tiers already have. client_errors takes in E_NO_ACK, E_NO_ANSWER and E_BAD_BODY, the three app-raised codes that sat outside the registry. The box generates nothing from that block; the copy changes because the file is one file in two repositories, byte for byte. Contract-pair: srcfl/ftw-webapp@claude/admiring-pike-1cd1cc Co-Authored-By: Claude Fable 5 --- .changeset/registry-ops-client-errors.md | 7 +++++ contract/registry.yaml | 28 +++++++++++++++++++ go/internal/appproto/command_test.go | 3 ++ go/internal/appproto/contract_gen.go | 11 ++++++++ go/internal/appproto/contract_test.go | 18 ++++++++++++ .../appproto/gencontract/gencontract.go | 23 +++++++++++++++ go/internal/appproto/messages.go | 4 ++- 7 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 .changeset/registry-ops-client-errors.md diff --git a/.changeset/registry-ops-client-errors.md b/.changeset/registry-ops-client-errors.md new file mode 100644 index 00000000..34193c68 --- /dev/null +++ b/.changeset/registry-ops-client-errors.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +The contract registry now names the command operations. `site.mode.set` and `battery.hold` were hand-written twice — here beside the dispatcher, and in the app's simulator — with the scope each demands written twice more, and nothing compared the four. The registry's new `ops` block is the one place the pair is written down: the generator renders it as `RegistryOps`, and a test holds `defaultOps()` to it, the same arrangement mode tiers already have. Containment rather than equality, deliberately — the registry may name an op ahead of the box, and `battery.hold` is exactly that today: an app may say it, this box rejects it with `E_UNKNOWN_OP`, and nothing acts. + +The registry also takes in the last of the app's own error codes. Its header has always said every app-raised code has a home there; three did not — `E_NO_ACK`, `E_NO_ANSWER` and `E_BAD_BODY` sat in the app alone, outside the check that keeps prose and retry rules honest. They now sit in `client_errors` beside `E_RESPONSE_TOO_LARGE`. The box generates nothing from that block and must never send one of them; the copy here exists because the file is one file in two repositories, byte for byte. diff --git a/contract/registry.yaml b/contract/registry.yaml index f5df3205..71f7229a 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -100,6 +100,22 @@ roles: label: Viewer scopes: [ftw.live.read] +# --------------------------------------------------------------------------- +# Command operations. What a `cmd` frame may name, and the scope the grant +# must carry before the box acts on it. +# +# The box's own table — defaultOps() in go/internal/appproto/command.go — is +# the authority on what a given box accepts. The pair is copied here because +# both sides used to hand-write it: the box beside its dispatcher, the app in +# its simulator, and nothing compared the two. An op declared here that a box +# does not implement is answered with E_UNKNOWN_OP like any other stranger — +# battery.hold is the app's vocabulary today and no shipping box acts on it +# yet. +# --------------------------------------------------------------------------- +ops: + - { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode } + - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } + # --------------------------------------------------------------------------- # Dispatch modes. # @@ -182,6 +198,18 @@ client_errors: # into a code because half a document is not an answer and a view needs one # thing to catch. - { code: E_RESPONSE_TOO_LARGE, retryable: false, desc: The answer arrived cut off } + # The session's own command deadlines. No ack inside the window — or the + # carrier closed with the command still unacknowledged — means the box never + # took the intent, so asking again cannot make anything happen twice. + - { code: E_NO_ACK, retryable: true, desc: The command was never acknowledged } + # The wire went away or the deadline passed before any status arrived. The + # session reconnects on its own, so the same request can simply be asked + # again. + - { code: E_NO_ANSWER, retryable: true, desc: The request got no answer at all } + # An answer this app could not read: a body that is not the JSON it claimed + # to be, or one that contradicts what was asked. The box and the app + # disagree about a route, and asking again gets the same answer. + - { code: E_BAD_BODY, retryable: false, desc: The answer could not be read } # --------------------------------------------------------------------------- # Source states. Orthogonal to carrier state — see docs/protocol.md. Merging diff --git a/go/internal/appproto/command_test.go b/go/internal/appproto/command_test.go index 62a1af0d..df5c9bda 100644 --- a/go/internal/appproto/command_test.go +++ b/go/internal/appproto/command_test.go @@ -265,6 +265,9 @@ func TestAModeTheBoxDoesNotHaveIsRefused(t *testing.T) { } } +// battery.hold sits in the registry's ops, so an app may name it — and this +// box does not implement it. Unknown here means unknown to defaultOps(), and +// the answer is a rejection rather than silence. func TestAnUnknownOpIsRejectedNotIgnored(t *testing.T) { h, _, rec, _ := newRig(t) subscribe(t, h, rec) diff --git a/go/internal/appproto/contract_gen.go b/go/internal/appproto/contract_gen.go index 15d0913a..8eece120 100644 --- a/go/internal/appproto/contract_gen.go +++ b/go/internal/appproto/contract_gen.go @@ -131,6 +131,17 @@ var WriteScopes = []string{ ScopeMembersWrite, } +// RegistryOps is the app's copy of the operations a cmd frame may name, +// each mapped to the scope its grant must carry. defaultOps() in command.go +// remains the authority on what this box accepts — this exists only so +// TestRegistryOpsMatchCommandTable can catch the two drifting apart. +var RegistryOps = map[string]string{ + // site.mode.set — Change the site operating mode. + "site.mode.set": "ftw.mode.write", + // battery.hold — Hold the battery at a fixed setpoint. + "battery.hold": "ftw.dispatch.write", +} + // Error codes. The box sends the code and machine-readable args; the app // owns every word of prose, in every language it ships. const ( diff --git a/go/internal/appproto/contract_test.go b/go/internal/appproto/contract_test.go index 9308810f..9cd1e85d 100644 --- a/go/internal/appproto/contract_test.go +++ b/go/internal/appproto/contract_test.go @@ -81,6 +81,24 @@ func TestFrozenFieldIdsAreFrozen(t *testing.T) { } } +// The ops in the registry are this repository's, copied there so neither side +// hand-writes an operation name or the scope it demands. defaultOps() in +// command.go stays the authority on what this box accepts; this catches the +// two drifting apart. Containment rather than equality, on purpose: the +// registry may name an op ahead of the box — battery.hold today — and a box +// that lacks it answers ErrUnknownOp, which command_test.go pins. +func TestRegistryOpsMatchCommandTable(t *testing.T) { + for op, spec := range defaultOps() { + scope, ok := RegistryOps[op] + if !ok { + t.Fatalf("op %q is accepted but not in contract/registry.yaml", op) + } + if scope != spec.scope { + t.Fatalf("op %q: command table demands scope %q, registry says %q", op, spec.scope, scope) + } + } +} + // Everything the box advertises must be something it can answer. A capability // the app cannot use is hidden; one it can ask for and not get is a hang. func TestDefaultCapsAreAllRegistryNames(t *testing.T) { diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index a2b32387..1898a236 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -36,6 +36,11 @@ type Registry struct { Label string `yaml:"label"` Scopes []string `yaml:"scopes"` } `yaml:"roles"` + Ops []struct { + Name string `yaml:"name"` + Scope string `yaml:"scope"` + Desc string `yaml:"desc"` + } `yaml:"ops"` Modes []struct { Key string `yaml:"key"` Tier string `yaml:"tier"` @@ -71,6 +76,7 @@ func Generate(raw []byte) ([]byte, error) { writeFields(&b, reg) writeCaps(&b, reg) writeScopes(&b, reg) + writeOps(&b, reg) writeErrors(&b, reg) writeSourceStates(&b, reg) writeResolutions(&b, reg) @@ -177,6 +183,23 @@ func writeScopes(b *bytes.Buffer, reg Registry) { b.WriteString("}\n\n") } +// writeOps emits the operation table as data rather than constants. The op +// names the box uses are the hand-written ones in messages.go, read back by +// TestRegistryOpsMatchCommandTable — generating a constant for an op no code +// handles yet (battery.hold today) only invites someone to use it. +func writeOps(b *bytes.Buffer, reg Registry) { + b.WriteString("// RegistryOps is the app's copy of the operations a cmd frame may name,\n") + b.WriteString("// each mapped to the scope its grant must carry. defaultOps() in command.go\n") + b.WriteString("// remains the authority on what this box accepts — this exists only so\n") + b.WriteString("// TestRegistryOpsMatchCommandTable can catch the two drifting apart.\n") + b.WriteString("var RegistryOps = map[string]string{\n") + for _, o := range reg.Ops { + fmt.Fprintf(b, "\t// %s — %s.\n", o.Name, o.Desc) + fmt.Fprintf(b, "\t%q: %q,\n", o.Name, o.Scope) + } + b.WriteString("}\n\n") +} + // writeRoles emits the role table with '*' expanded. // // Expanded here and not at the call site, because a star that has to be diff --git a/go/internal/appproto/messages.go b/go/internal/appproto/messages.go index e6d0cde6..4792e8d4 100644 --- a/go/internal/appproto/messages.go +++ b/go/internal/appproto/messages.go @@ -29,7 +29,9 @@ const ( MsgAPIEnd = "api.end" ) -// Operations a client may ask for. Each maps to a scope. +// Operations a client may ask for, from contract/registry.yaml `ops` — the +// scope each demands lives there too, and TestRegistryOpsMatchCommandTable +// reads the pair back against defaultOps(). const ( // OpSetMode changes the site operating mode. It goes through the same // mode validation the API and Home Assistant use — a second validator