Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/registry-ops-client-errors.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go/internal/appproto/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions go/internal/appproto/contract_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions go/internal/appproto/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions go/internal/appproto/gencontract/gencontract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion go/internal/appproto/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down