From 01d9db64bc106dd5ee2b813dedab5403b680e51e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:04:20 +0200 Subject: [PATCH 1/2] registry: agents, their scopes, refusals and push kinds An owner can let an assistant, Sourceful support for one ticket or the release check reach the box through FTW Cloud. The registry names what the box enforces for them: - roles agent, support and release, each the most a grant may carry; - scopes ftw.logs.read, ftw.config.read, ftw.diag.run, ftw.dispatch.hold, ftw.goals.request and ftw.update.request, so scopes now take the verbs run, hold and request as well as read and write; - an agent key on each op, the scope that opens it to an agent; - the capability access.agents; - eight refusals, from E_GRANT_EXPIRED to E_NOT_FOUND; - push kinds agent.added and agent.request. docs/agents.md in srcfl/ftw is the wire. Co-Authored-By: Claude Opus 5.5 --- .changeset/registry-agents.md | 5 +++ contract/push-catalogue.yaml | 11 ++++++ contract/registry.yaml | 68 +++++++++++++++++++++++++++++---- src/lib/notify/kinds.ts | 4 ++ src/lib/protocol/contract.ts | 35 ++++++++++++++++- src/lib/protocol/messages.ts | 10 +++++ tests/registry-contract.test.ts | 2 +- 7 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 .changeset/registry-agents.md diff --git a/.changeset/registry-agents.md b/.changeset/registry-agents.md new file mode 100644 index 0000000..02c2ac7 --- /dev/null +++ b/.changeset/registry-agents.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The registry names agents: assistants, Sourceful support and the release check, with their scopes, the scope that opens each command to an agent, their refusals and two push kinds. A box that sends the new kinds gets their notification toggles; nothing else in the app changes yet. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 8473df4..4dfe934 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -60,3 +60,14 @@ events: # To every owner, when FTW Cloud is turned off at either door. title: FTW Cloud is off body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy." + - kind: agent.added + # To every owner, when an owner gives an assistant, Sourceful support or + # the release check access to the box. Access from outside the house is + # news every owner should hear, as with cloud.granted. + title: New access to your box + body: "You gave an assistant or Sourceful access. See who has access in the app." + - kind: agent.request + # To every owner, when an agent asks for a durable change. It waits for + # one of them to confirm it, and runs out after twelve hours. + title: Waiting for your answer + body: "An assistant asks to change a setting. Open FTW to confirm or decline." diff --git a/contract/registry.yaml b/contract/registry.yaml index 63b4de5..50c6008 100644 --- a/contract/registry.yaml +++ b/contract/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. diff --git a/src/lib/notify/kinds.ts b/src/lib/notify/kinds.ts index 0534e26..756ea9a 100644 --- a/src/lib/notify/kinds.ts +++ b/src/lib/notify/kinds.ts @@ -19,6 +19,8 @@ export const KINDS = [ 'box.unreachable', 'cloud.granted', 'cloud.revoked', + 'agent.added', + 'agent.request', ] as const export type PushKind = (typeof KINDS)[number] @@ -46,4 +48,6 @@ export const KIND_LABELS: Record = { 'box.unreachable': 'If your box goes out of reach', 'cloud.granted': 'When FTW Cloud is turned on', 'cloud.revoked': 'When FTW Cloud is turned off', + 'agent.added': 'When an assistant or Sourceful gets access', + 'agent.request': 'When an assistant asks to change a setting', } diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts index 455a2ce..d27c4d5 100644 --- a/src/lib/protocol/contract.ts +++ b/src/lib/protocol/contract.ts @@ -12,7 +12,11 @@ * Add nothing here that is not in the registry. */ -/** 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). + */ export const SCOPES = [ 'ftw.live.read', 'ftw.history.read', @@ -24,10 +28,30 @@ export const SCOPES = [ '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', ] as const export type Scope = (typeof SCOPES)[number] +/** What an assistant or Sourceful support may carry at most. */ +const AGENT_SCOPES: readonly string[] = [ + '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', +] + /** * What each role carries, with the registry's `'*'` expanded. * @@ -39,6 +63,12 @@ export const ROLE_SCOPES: Record = { owner: SCOPES, viewer: ['ftw.live.read'], watch: ['ftw.live.read', 'ftw.plan.read', 'ftw.events.read', 'ftw.dispatch.write'], + // Agents: grants an owner gives to an assistant, to Sourceful support or to + // the release check. The most a grant of each kind may carry; each grant + // names a subset, and the box enforces it. + agent: AGENT_SCOPES, + support: AGENT_SCOPES, + release: ['ftw.health.read'], } /** The label the registry gives each role. The app owns no other wording. */ @@ -46,6 +76,9 @@ export const ROLE_LABELS: Record = { owner: 'Owner', viewer: 'Viewer', watch: 'Watch', + agent: 'Assistant', + support: 'Sourceful support', + release: 'Release check', } /** Whether a role carries a scope. An unknown role carries nothing. */ diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index f9a66b5..6a4c333 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -574,6 +574,16 @@ const RETRYABLE: Record = { // no role and no ceremony changes it. E_WHOLE_DOCUMENT: false, E_LOCAL_ONLY: false, + // Agents' refusals (docs/agents.md in srcfl/ftw). A rate limit clears once + // the grant's minute has room; every other one gets the same answer again. + 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, // Not from the box. The session layer raises this one itself out of // api.end{truncated: true} — see contract/registry.yaml, client_errors. E_RESPONSE_TOO_LARGE: false, diff --git a/tests/registry-contract.test.ts b/tests/registry-contract.test.ts index 5284125..a59f4c4 100644 --- a/tests/registry-contract.test.ts +++ b/tests/registry-contract.test.ts @@ -373,7 +373,7 @@ describe('hand-written shared names', () => { // The scan can only ban a shape it recognises, so the shape is pinned to // the registry first: a declared scope it cannot match is a scope the // scan has gone blind to. - const shape = /^ftw\.[a-z_]+\.(?:read|write)$/ + const shape = /^ftw\.[a-z_]+\.(?:read|write|run|hold|request)$/ const declared = new Set(registryScopes()) for (const scope of declared) { expect(scope, 'a registry scope the bare-name scan cannot recognise').toMatch(shape) From 31a144b13f1f2eb73714cc23f7f74562597e0725 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:08:53 +0200 Subject: [PATCH 2/2] registry: agent arguments and reads, safer holds, three security push kinds From the review of the agent contract: - agent_args: what an agent may put in each op, with a label per argument for showing a request to the owner; - reads: each read an agent may send and its scope; - battery.hold and loadpoint.boost lose their agent scope until the box can bound them; - E_CLOCK_SKEW for an approval whose time is too far from the box's, and E_NOT_FOUND worded for any id; - ftw.update.request marked reserved; - push kinds agent.expiring, approval.key.set and owner.added. Co-Authored-By: Claude Opus 5.5 --- contract/push-catalogue.yaml | 15 ++++++++ contract/registry.yaml | 67 +++++++++++++++++++++++++++++++++--- src/lib/notify/kinds.ts | 6 ++++ src/lib/protocol/messages.ts | 3 ++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 4dfe934..4c7f3b9 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -71,3 +71,18 @@ events: # one of them to confirm it, and runs out after twelve hours. title: Waiting for your answer body: "An assistant asks to change a setting. Open FTW to confirm or decline." + - kind: agent.expiring + # To every owner, a day before an agent's grant runs out, so access that + # should go on is renewed and access that should end can be left to end. + title: Access ends tomorrow + body: "An assistant's or Sourceful's access to your box runs out in a day. Renew it in the app if it should go on." + - kind: approval.key.set + # To every owner, when a phone registers or replaces the key that + # approves access. A new key is a new way to let others in. + title: A phone can now approve access + body: "A phone set up the key it uses to approve access to your box. If that was not you, remove the phone in the app." + - kind: owner.added + # To every owner, when a phone pairs as an owner. An owner can do + # anything, including give others access. + title: A new owner + body: "A phone was paired as an owner of your box. If that was not you, remove it in the app." diff --git a/contract/registry.yaml b/contract/registry.yaml index 50c6008..dfa8a85 100644 --- a/contract/registry.yaml +++ b/contract/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 } diff --git a/src/lib/notify/kinds.ts b/src/lib/notify/kinds.ts index 756ea9a..24e1b31 100644 --- a/src/lib/notify/kinds.ts +++ b/src/lib/notify/kinds.ts @@ -21,6 +21,9 @@ export const KINDS = [ 'cloud.revoked', 'agent.added', 'agent.request', + 'agent.expiring', + 'approval.key.set', + 'owner.added', ] as const export type PushKind = (typeof KINDS)[number] @@ -50,4 +53,7 @@ export const KIND_LABELS: Record = { 'cloud.revoked': 'When FTW Cloud is turned off', 'agent.added': 'When an assistant or Sourceful gets access', 'agent.request': 'When an assistant asks to change a setting', + 'agent.expiring': "When an assistant's or Sourceful's access is about to end", + 'approval.key.set': 'When a phone sets up the key that approves access', + 'owner.added': 'When a phone is paired as an owner', } diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index 6a4c333..7ada730 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -584,6 +584,9 @@ const RETRYABLE: Record = { E_BAD_SIGNATURE: false, E_NO_APPROVAL_KEY: false, E_NOT_FOUND: false, + // The approval's atMs is too far from the box's clock; the phone signs + // again with args.boxMs, so asking again can succeed. + E_CLOCK_SKEW: true, // Not from the box. The session layer raises this one itself out of // api.end{truncated: true} — see contract/registry.yaml, client_errors. E_RESPONSE_TOO_LARGE: false,