From 09a85385e7a64cb82c2014a702f07e8051d2e12d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:05:11 +0200 Subject: [PATCH 1/2] registry: copy the agents' names from srcfl/ftw-webapp The registry now names agents: roles agent, support and release, six scopes with the verbs run, hold and request, an agent scope on each op, the capability access.agents, eight refusals and two push kinds. Contract.swift takes the scopes, the capability and the refusals, which ContractTests reads back. The app does not use them yet. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Protocol/Contract.swift | 20 +++++- protocol/registry.yaml | 68 ++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 06966d2..c68d1d3 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -7,7 +7,9 @@ import Foundation /// and `ContractTests` reads the registry back and fails when anything has /// stopped matching. Add nothing here that is not in the registry. public enum Contract { - /// One object axis, two verb axes: `.`. + /// An object and a verb: `.`. + /// read and write are a phone's; run, hold and request are an agent's + /// (docs/agents.md in srcfl/ftw). public static let scopes = [ "ftw.live.read", "ftw.history.read", @@ -19,6 +21,12 @@ public enum Contract { "ftw.mode.write", "ftw.members.read", "ftw.members.write", + "ftw.logs.read", + "ftw.config.read", + "ftw.diag.run", + "ftw.dispatch.hold", + "ftw.goals.request", + "ftw.update.request", ] public static let roleOwner = "owner" @@ -47,7 +55,7 @@ public enum Contract { "cmd.lease", "cmd.precondition", "cmd.readback", "der.battery", "der.ev", "der.v2x", "plan.dispatch", "net.webrtc", "price.spot", "api.passthrough", - "cloud.link", "cloud.tun", "cmd.exchange", + "cloud.link", "cloud.tun", "cmd.exchange", "access.agents", ] public static let capHistory5m = "history.5m" @@ -138,6 +146,14 @@ public enum Contract { "E_UNSUPPORTED_MEDIA": false, "E_WHOLE_DOCUMENT": false, "E_LOCAL_ONLY": false, + "E_GRANT_EXPIRED": false, + "E_OP_NOT_GRANTED": false, + "E_CAP_EXCEEDED": false, + "E_RATE_LIMITED": true, + "E_NEEDS_OWNER": false, + "E_BAD_SIGNATURE": false, + "E_NO_APPROVAL_KEY": false, + "E_NOT_FOUND": false, "E_RESPONSE_TOO_LARGE": false, "E_NO_ACK": true, "E_NO_ANSWER": true, diff --git a/protocol/registry.yaml b/protocol/registry.yaml index 63b4de5..50c6008 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -111,9 +111,16 @@ capabilities: # watch codes. Absent means the phone offers no Add watch. The wire is # docs/watch-exchange.md in srcfl/ftw-webapp. - cmd.exchange + # The box takes agents: assistants, Sourceful support and the release + # check, each an owner's grant with scopes, ops, caps and an expiry, and it + # answers the agent.* messages and approval.key.set (docs/agents.md in + # srcfl/ftw). Absent means the app offers no Access list. + - access.agents # --------------------------------------------------------------------------- -# Scopes. One object axis, two verb axes: .. +# Scopes. One object axis and a verb: .. +# read and write are a phone's. run changes nothing, hold acts for a capped +# time and ends unless renewed, and request waits for an owner to confirm. # Roles are a UI projection over these, never a replacement for them. # --------------------------------------------------------------------------- scopes: @@ -127,6 +134,12 @@ scopes: - { name: ftw.mode.write, desc: Change site operating mode } - { name: ftw.members.read, desc: See who has access } - { name: ftw.members.write, desc: Invite and revoke } + - { name: ftw.logs.read, desc: Log excerpts the box redacts } + - { name: ftw.config.read, desc: Configuration the box redacts } + - { name: ftw.diag.run, desc: Checks that change nothing } + - { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time } + - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } + - { name: ftw.update.request, desc: Ask an owner to install a version } roles: owner: @@ -142,6 +155,23 @@ roles: watch: label: Watch scopes: [ftw.live.read, ftw.plan.read, ftw.events.read, ftw.dispatch.write] + # Agents: grants an owner gives to something other than a paired device. + # A role here is the most a grant of that kind may carry. Each grant names + # a subset, and each check keeps only what the approving owner holds at + # that moment, as for a watch. An agent reaches the box only through FTW + # Cloud's tunnel. See docs/agents.md in srcfl/ftw. + # + # agent is a personal assistant. support is Sourceful for one ticket. release + # is the release check, which reads a health summary and nothing else. + agent: + label: Assistant + scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request] + support: + label: Sourceful support + scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read, ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request] + release: + label: Release check + scopes: [ftw.health.read] # --------------------------------------------------------------------------- # FTW Cloud. What an owner can let the box share with FTW Cloud, and why. @@ -170,15 +200,21 @@ cloud_purposes: [serve, stats] # 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. +# +# agent is the scope that opens the op to an agent, and its verb says how. +# ftw.dispatch.hold runs now as a cmd, within the grant's caps, and must end +# by itself. ftw.goals.request never runs as a cmd: it waits as an +# agent.request until an owner confirms it. An op without agent is never +# open to an agent. # --------------------------------------------------------------------------- ops: - - { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode } - - { name: planner.prefs.set, scope: ftw.mode.write, desc: Set household planner preferences } - - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } - - { name: loadpoint.hold, scope: ftw.dispatch.write, desc: Charge the car now at a fixed current } - - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } - - { name: loadpoint.soc.set, scope: ftw.dispatch.write, desc: Correct the car's current charge level } - - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, desc: Charge the car from surplus PV only } + - { name: site.mode.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Change the site operating mode } + - { name: planner.prefs.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Set household planner preferences } + - { name: battery.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Hold the battery at a fixed setpoint } + - { name: loadpoint.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Charge the car now at a fixed current } + - { name: loadpoint.boost, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Boost the car from the house battery } + - { name: loadpoint.soc.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Correct the car's current charge level } + - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Charge the car from surplus PV only } # --------------------------------------------------------------------------- # Dispatch modes. @@ -240,6 +276,22 @@ errors: # or doing it needs somebody standing at the box. Not a permission the owner # is missing, so neither a role nor a ceremony changes the answer. - { code: E_LOCAL_ONLY, retryable: false, desc: Route is served only on the box's own page } + # Agents' refusals (docs/agents.md in srcfl/ftw). Each is a different + # sentence to an owner reading the audit log or an assistant reading its + # answer, so none reuses a code above. + # + # E_RATE_LIMITED is retryable because the same command goes through once + # the grant's minute has room. E_CAP_EXCEEDED is not: the box refuses an + # argument past a cap rather than clipping it, and the same argument gets + # the same answer. + - { code: E_GRANT_EXPIRED, retryable: false, desc: Access ran out at its expiry } + - { code: E_OP_NOT_GRANTED, retryable: false, desc: The grant does not list this operation } + - { code: E_CAP_EXCEEDED, retryable: false, desc: An argument or a count is past its cap } + - { code: E_RATE_LIMITED, retryable: true, desc: Too many commands for the grant's rate } + - { code: E_NEEDS_OWNER, retryable: false, desc: A durable change waits for an owner as agent.request } + - { code: E_BAD_SIGNATURE, retryable: false, desc: The approval signature is missing or does not verify } + - { code: E_NO_APPROVAL_KEY, retryable: false, desc: This phone has no approval key on the box } + - { code: E_NOT_FOUND, retryable: false, desc: No such agent or request } # --------------------------------------------------------------------------- # Error codes the client raises for itself. From e1988dfb52148ae80caef35f01d5526f889f0f0b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:09:08 +0200 Subject: [PATCH 2/2] registry: copy the agent arguments, reads and safer holds from srcfl/ftw-webapp Contract.swift takes E_CLOCK_SKEW, which ContractTests reads back. The other new blocks (agent_args, reads) and the ops' agent scopes are not names this app spells yet. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Protocol/Contract.swift | 1 + protocol/registry.yaml | 67 +++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index c68d1d3..02ebad7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -154,6 +154,7 @@ public enum Contract { "E_BAD_SIGNATURE": false, "E_NO_APPROVAL_KEY": false, "E_NOT_FOUND": false, + "E_CLOCK_SKEW": true, "E_RESPONSE_TOO_LARGE": false, "E_NO_ACK": true, "E_NO_ANSWER": true, diff --git a/protocol/registry.yaml b/protocol/registry.yaml index 50c6008..dfa8a85 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -139,6 +139,7 @@ scopes: - { name: ftw.diag.run, desc: Checks that change nothing } - { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time } - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } + # Reserved: no op asks for an update yet, and no role but owner carries it. - { name: ftw.update.request, desc: Ask an owner to install a version } roles: @@ -205,14 +206,16 @@ cloud_purposes: [serve, stats] # ftw.dispatch.hold runs now as a cmd, within the grant's caps, and must end # by itself. ftw.goals.request never runs as a cmd: it waits as an # agent.request until an owner confirms it. An op without agent is never -# open to an agent. +# open to an agent. battery.hold and loadpoint.boost have none until the box +# can bound them: battery.hold has no handler yet, and a boost has no power +# of its own to hold to maxW. agent_args below says what an agent may send. # --------------------------------------------------------------------------- ops: - { name: site.mode.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Change the site operating mode } - { name: planner.prefs.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Set household planner preferences } - - { name: battery.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Hold the battery at a fixed setpoint } + - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } - { name: loadpoint.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Charge the car now at a fixed current } - - { name: loadpoint.boost, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Boost the car from the house battery } + - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } - { name: loadpoint.soc.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Correct the car's current charge level } - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Charge the car from surplus PV only } @@ -291,7 +294,10 @@ errors: - { code: E_NEEDS_OWNER, retryable: false, desc: A durable change waits for an owner as agent.request } - { code: E_BAD_SIGNATURE, retryable: false, desc: The approval signature is missing or does not verify } - { code: E_NO_APPROVAL_KEY, retryable: false, desc: This phone has no approval key on the box } - - { code: E_NOT_FOUND, retryable: false, desc: No such agent or request } + - { code: E_NOT_FOUND, retryable: false, desc: Nothing by that id or name } + # An approval whose atMs is too far from the box's wall clock. args.boxMs + # is the box's clock, so the phone can sign again with the right time. + - { code: E_CLOCK_SKEW, retryable: true, desc: The approval's time is too far from the box's } # --------------------------------------------------------------------------- # Error codes the client raises for itself. @@ -340,3 +346,56 @@ carrier_states: [webrtc, relay, tunnel, cloud, cache, none] resolutions: '5m': { tile_span_ms: 43200000, retention_days: 30, source: energy_ledger } '1h': { tile_span_ms: 604800000, retention_days: 730, source: hourly_rollup } + +# --------------------------------------------------------------------------- +# What an agent may put in each op it can name (docs/agents.md in srcfl/ftw, +# Operations). The box refuses any other key, a value of another kind and one +# outside its range, in a cmd and in an agent.request, as it arrives. label is +# the words the app shows an owner for the value. cap names the grant's cap +# that bounds it as well. kind says how to read the value: +# loadpoint the opaque charger id the agent read from loadpoints.get +# mode a key in modes +# enum one of values +# number, power, duration a number in unit +# ratio a fraction from 0 to 1 +# bool true or false +# --------------------------------------------------------------------------- +agent_args: + site.mode.set: + - { name: mode, kind: mode, required: true, label: Operating mode } + planner.prefs.set: + - { name: safety_k, kind: number, min: 0, max: 2, label: How cautious the plan is } + - { name: battery_export, kind: enum, values: [unknown, not_allowed, allowed], label: Whether the battery may sell to the grid } + loadpoint.hold: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: power_w, kind: power, unit: W, min: 0, cap: maxW, label: Charging power } + - { name: hold_s, kind: duration, unit: s, min: 1, cap: holdS, label: For how long } + - { name: phase_mode, kind: enum, values: [auto, 1p, 3p], label: Phases } + - { name: clear, kind: bool, label: Stop the agent's own hold } + loadpoint.soc.set: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: soc, kind: ratio, required: true, min: 0, max: 1, label: The car's charge level } + loadpoint.surplus_only.set: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: surplus_only, kind: bool, required: true, label: Charge from solar only } + +# --------------------------------------------------------------------------- +# Reads an agent may send, and the scope each needs (docs/agents.md in +# srcfl/ftw, Reads). Their answers name nothing: sources, chargers and cars +# go by opaque ids the box cuts with a key of its own. An agent has no sub; +# live.get is its view of now. A phone may send these too, under the same +# scope. +# --------------------------------------------------------------------------- +reads: + - { msg: live.get, scope: ftw.live.read, desc: Readings and freshness now } + - { msg: loadpoints.get, scope: ftw.live.read, desc: Each charger and the car on it } + - { msg: cars.get, scope: ftw.live.read, desc: Each car's level and goal } + - { msg: plan.get, scope: ftw.plan.read, desc: The plan and its reasons } + - { msg: price.get, scope: ftw.plan.read, desc: Electricity prices } + - { msg: hist.query, scope: ftw.history.read, desc: Stored history } + - { msg: health.get, scope: ftw.health.read, desc: The health summary } + - { msg: drivers.get, scope: ftw.health.read, desc: Each driver's state } + - { msg: events.get, scope: ftw.events.read, desc: Recent events } + - { msg: logs.get, scope: ftw.logs.read, desc: A log excerpt the box redacts } + - { msg: config.get, scope: ftw.config.read, desc: A configuration section the box redacts } + - { msg: diag.run, scope: ftw.diag.run, desc: A check that changes nothing }