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/mcp-setup-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

Owners can grant an assistant setup access as a separate choice. MCP reads the box's complete setup schema, resumes a draft, edits one field or list entry, validates and saves the exact draft the user confirms. The box retains its Settings checks, reports pending restarts and preserves a save receipt. A separate confirmed call can restart to apply the exact saved setup, with a live grant and revision check before the restart. Saving does not prove physical measurement or control.

Every draft write and final Settings transaction rechecks the live grant and request deadline after preparation. Revoked access and ended requests leave both settings and commit receipts unchanged.
9 changes: 9 additions & 0 deletions VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ primitives an agent or another automation needs in order to work with the
home's energy system. Home Assistant, MQTT and a database the owner runs for
their own analysis sit on top. Core does not absorb those tools.

An owner must be able to set up the full system through an authorized
assistant, starting with a new box. The conversation should ask only for
missing facts and choices, keep an unfinished draft on the box, and separate
saved settings from measured and verified operation. The owner must also be
able to share a revocable grant with another person's own assistant through
a code. Everyday status and history must stay direct, without a setup
interview. These are requirements; the [agent-led setup proposal](docs/adr/0011-agent-led-setup.md)
names the delivery and evidence still needed.

This is the product direction set by Fredrik. It guides design and review;
it does not claim that every outcome below has shipped. The
[roadmap](docs/roadmap.md) names the work and evidence needed to reach it.
Expand Down
16 changes: 14 additions & 2 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ scopes:
- { name: ftw.logs.read, desc: Log excerpts the box redacts }
- { name: ftw.config.read, desc: Configuration the box redacts }
- { name: ftw.diag.run, desc: Full technical diagnostics and read API }
- { name: ftw.setup.write, desc: Save confirmed system setup and restart to apply it after user confirmation }
- { 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.
Expand Down Expand Up @@ -173,10 +174,10 @@ roles:
# 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]
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.setup.write, 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]
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.setup.write, ftw.dispatch.hold, ftw.goals.request]
release:
label: Release check
scopes: [ftw.health.read]
Expand Down Expand Up @@ -510,3 +511,14 @@ charger_car_fields:
- { name: carCorrected, kind: bool, unit: null, desc: A better rule replaced the car the box first named }
- { name: carCorrectedFrom, kind: text, unit: null, desc: The car the box named before that }
- { name: choose, kind: text_list, unit: null, desc: While asking the cars a tap may pick. At most four }

# Agent setup calls use the box's typed draft service. They do not carry an
# HTTP path or grant general configuration or membership authority.
agent_calls:
- { msg: setup.schema, const: MsgSetupSchema, scope: ftw.diag.run, desc: Read this build's complete setup schema }
- { msg: setup.status, const: MsgSetupStatus, scope: ftw.diag.run, desc: Resume the masked setup draft }
- { msg: setup.edit, const: MsgSetupEdit, scope: ftw.setup.write, desc: Patch the exact current setup draft }
- { msg: setup.reset, const: MsgSetupReset, scope: ftw.setup.write, desc: Start a new draft from current settings }
- { msg: setup.validate, const: MsgSetupValidate, scope: ftw.setup.write, desc: Check the exact setup draft without applying it }
- { msg: setup.commit, const: MsgSetupCommit, scope: ftw.setup.write, desc: Save the exact checked and confirmed draft }
- { msg: setup.restart, const: MsgSetupRestart, scope: ftw.setup.write, desc: Restart to apply the exact saved setup after user confirmation }
70 changes: 62 additions & 8 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Agents and support access

Proposed. Nothing here has shipped. This document is normative: "must" is a
This document defines the agent contract. Some operations remain proposed;
the connected box reports what its build supports. The contract is normative: "must" is a
requirement on the box, FTW Cloud or the app. It extends the app protocol
([srcfl/ftw-webapp docs/protocol.md](https://github.com/srcfl/ftw-webapp/blob/main/docs/protocol.md))
and the [FTW Cloud protocol](protocol-cloud.md). Names in `code` that are
Expand All @@ -27,10 +28,11 @@ act. Each check runs again on every call, so a revoke or an expiry bites at
the next call whatever FTW Cloud does.

**What FTW Cloud sees.** It holds the agent's key, so it can read every
answer an agent gets. Answers to agents therefore name nothing: sources,
chargers and cars go by opaque ids the box cuts with a key of its own, logs
and configuration are redacted on the box, and an agent has no `sub` stream,
whose source table carries names.
answer an agent gets. Typed routine reads use opaque source, charger and car ids and redact logs
and configuration. Full technical diagnostics preserve names, addresses,
source code, exact measurements and exports, including backups that can hold
saved credentials. The owner-approved diagnostic scope covers those reads
without extra endpoint grants. An agent has no `sub` stream.

## Principals

Expand Down Expand Up @@ -75,16 +77,18 @@ these:
|---|---|
| `ftw.logs.read` | Read a log excerpt that the box redacts before it leaves |
| `ftw.config.read` | Read the configuration, redacted on the box |
| `ftw.diag.run` | Run checks that change nothing |
| `ftw.diag.run` | Read full technical diagnostics and exports; backups can contain saved credentials |
| `ftw.setup.write` | Read and edit full setup drafts, save the exact confirmed draft and restart to apply it after confirmation |
| `ftw.dispatch.hold` | Hold a charger for a capped time; the hold ends unless renewed |
| `ftw.goals.request` | Ask for a durable change, which waits for an owner |
| `ftw.update.request` | Reserved: no op uses it yet |

The roles `agent` and `support` carry at most `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` and
`ftw.logs.read`, `ftw.config.read`, `ftw.diag.run`, `ftw.setup.write`, `ftw.dispatch.hold` and
`ftw.goals.request`. A grant names a subset. Agents read by default; an owner
ticks control to add `ftw.dispatch.hold` or `ftw.goals.request`.
ticks control to add `ftw.dispatch.hold` or `ftw.goals.request`. Setup has its
own choice, off by default. Control alone never adds setup access.

## What an agent may send

Expand Down Expand Up @@ -412,6 +416,56 @@ The registry's `reads` table names each read and the scope it needs.
lines. `beforeMs` and `afterMs` together pick the lines between them; an
`afterMs` at or past `beforeMs` gets `E_UNKNOWN_OP {field: "beforeMs"}`.

## Technical reads and setup

`api.read` carries `APIRead` from `go/protocol/agentapi.go` and requires
`ftw.diag.run`. Start with `/api/diagnostics/catalog` to get this build's
routes, source links and bug-report workflow. It carries GET reads only.
Large exports use byte pages and a digest to detect a changed answer.
Typed routine reads keep the redaction described below.

The registry's `agent_calls` table defines seven setup messages:

| Message | Right | JSON request |
|---|---|---|
| `setup.schema` | `ftw.diag.run` | `{}`; returns full typed schema, driver catalogue and price zones |
| `setup.status` | `ftw.diag.run` | `{}`; returns masked draft, revisions, digest and save receipt |
| `setup.edit` | `ftw.setup.write` | `{revision, patch}` or `{revision, operations}` |
| `setup.reset` | `ftw.setup.write` | `{revision}`; starts a new draft from current settings; does not restart Core |
| `setup.validate` | `ftw.setup.write` | `{revision, base_revision, sha256}` |
| `setup.commit` | `ftw.setup.write` | `{revision, base_revision, sha256, confirmed}` |
| `setup.restart` | `ftw.setup.write` | `{saved_revision, sha256, confirmed}`; only the exact saved setup |

Each request is `SetupCall {json: bytes}` from `go/protocol/setup.go`, at most
10 KiB within one bulk frame. Replies use `api.head/chunk/end`. A merge patch
replaces whole arrays. JSON Patch `add`, `replace` and `remove` can edit one
entry without resending a large list. Indexes refer only to the exact draft
revision; a concurrent edit conflicts. Core rejects unknown config fields.

A read never discovers equipment or applies settings. The agent asks for
missing facts in small groups, accepts unknown answers, validates the exact
draft and shows changes and restart needs before asking the user to confirm.
Commit binds both revisions and the digest, checks the live grant again at
the final save, then uses the normal Settings transaction and applier. The
receipt commits with Settings; retrying that exact save returns the receipt.
Replies distinguish saved, applied, restart pending and hardware verification.
A later draft or setup save keeps earlier restart needs until a real process
restart. A confirmed `setup.restart` checks the saved receipt, live revision
and live grant again just before signalling the existing graceful restart.
A lost reply leaves the outcome unknown; read setup status and the receipt
before retrying the same operation. A restart request never proves application.

Setup reads use the existing full diagnostic grant because they retain names,
addresses and connection details while masking credentials. They do not widen
an old redacted `ftw.config.read` grant. Replies to setup writes carry the same
masked draft under the owner's new, explicit setup grant.

Setup keeps the agent's identity. It opens no general HTTP writes, access
grants, Cloud consent, house-password changes or direct hardware commands.
Saved equipment settings can affect operation under Core's safety checks.
Physical measurement and control need separate evidence. First-run claiming,
the guided interview and sharing codes follow [ADR 0011](adr/0011-agent-led-setup.md).

## Redaction on the box

`logs.get` and `config.get` build their answer on the box. Nothing leaves it
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ of Core; new solver work belongs in Energyplan.
|---|---|---|
| Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. Core [v0.147.0-beta.1](https://github.com/srcfl/ftw/releases/tag/v0.147.0-beta.1) and the web client are published; iOS 0.4.8 build 13 is uploaded to App Store Connect. Cloud runs in Stockholm and the Frankfurt relay is stopped, with disk and address kept for rollback. Escrow remains separate. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. |
| Mixed equipment | Separate SolarEdge legacy and Pixii drivers exist. Their [published evidence](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/solaredge_legacy.lua) and [Pixii metadata](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/pixii.lua) still say experimental; the legacy header and declared curtail capability also need to agree. | Reconcile known field runs with the catalog, then verify the named inverter + battery + charger combination. Distinguish measured telemetry, verified commands and unverified control. Metadata is not evidence that no user has ever run the hardware. |
| Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. |
| Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | Complete the [agent-led setup](adr/0011-agent-led-setup.md) path from a new box, including resumable drafts, explicit setup rights, shared access by code and verified commissioning. A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. |
| Forecasts and defaults | [PV learning](../optimizer/native/README.md) needs no panel geometry or rating. [Cold-load selection](../go/cmd/ftw/forecast_tracking.go) keeps the site prior until learning, and [load/net-risk tests](../go/cmd/ftw/forecast_load_risk_test.go) cover uncertainty. Charge and discharge efficiency are included. The [minimum arbitrage spread](../go/internal/config/config.go) defaults to zero. | Verify first-day and learned performance on held-out site periods. Keep the separate opt-in wear-cost requirement open; a minimum arbitrage spread is not a general wear model. Align all persisted defaults and worker support before exposing such a setting. Do not rebuild cold-start support already delivered in #1204. |
| Daily charging | [The webapp panel](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/EvPanel.svelte) already saves SoC on slider release, changes schedules without a Save button and supports Charge now. | [Now](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/Now.svelte) normally opens that panel after a charger tap or notification link. Bring the relevant SoC action directly into the post-plug-in entry experience. Complete the goal → plan → delivered-energy flow on real chargers, including offline cars and restarts. |
| Notifications | Core and the webapp implement subscription, charging connection/completion/interruption events and device alerts. See the [shared push catalogue](../contract/push-catalogue.yaml) and [rule defaults](../go/internal/notifications/service.go). | There is no dedicated predicted-missed-departure event in that catalogue. Add an actionable goal-risk notification and make activation clear during charging setup, with user consent. Verify delivery while the app is closed; an interrupted-session alert alone does not cover a future shortfall. |
Expand Down
20 changes: 20 additions & 0 deletions go/cmd/ftw/app_link_agent_setup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import (
"net/http"

"github.com/srcfl/ftw/go/protocol"
)

// ServeAgentSetup forwards the typed setup entry through the wrapper used
// by real app sessions. The bound API keeps the agent's live authority.
func (l *lateAPI) ServeAgentSetup(w http.ResponseWriter, r *http.Request, message string, call protocol.SetupCall, authorize func() error) {
srv := l.srv.Load()
if srv == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"the box is still starting"}`))
return
}
srv.ServeAgentSetup(w, r, message, call, authorize)
}
48 changes: 48 additions & 0 deletions go/cmd/ftw/app_link_agent_setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/protocol"
)

type setupGateway interface {
ServeAgentSetup(http.ResponseWriter, *http.Request, string, protocol.SetupCall, func() error)
}

var _ setupGateway = (*lateAPI)(nil)

func TestProductionGatewayForwardsTypedSetupAndLiveAuthority(t *testing.T) {
gateway := &lateAPI{}
var production setupGateway = gateway
r := httptest.NewRequest(http.MethodGet, "/api/setup/schema", nil)
r = r.WithContext(apiauth.WithCaller(r.Context(), apiauth.Caller{Kind: apiauth.KindAgent, Role: apiauth.RoleAgent, Subject: "agent:test", Scopes: apiauth.NewScopeSet(protocol.ScopeDiagRun)}))
call := protocol.SetupCall{JSON: []byte(`{}`)}
checks := 0
authorize := func() error { checks++; return nil }
starting := httptest.NewRecorder()
production.ServeAgentSetup(starting, r, protocol.MsgSetupSchema, call, authorize)
if starting.Code != http.StatusServiceUnavailable {
t.Fatalf("before bind: %d", starting.Code)
}
gateway.bind(api.New(&api.Deps{}))
answer := httptest.NewRecorder()
production.ServeAgentSetup(answer, r, protocol.MsgSetupSchema, call, authorize)
var doc struct {
Schema map[string]any `json:"schema"`
}
if answer.Code != http.StatusOK || json.Unmarshal(answer.Body.Bytes(), &doc) != nil || len(doc.Schema) == 0 || checks == 0 {
t.Fatalf("typed setup did not reach API: %d %s checks=%d", answer.Code, answer.Body.String(), checks)
}
revoked := httptest.NewRecorder()
production.ServeAgentSetup(revoked, r, protocol.MsgSetupSchema, call, func() error { return errors.New("revoked") })
if revoked.Code != http.StatusForbidden {
t.Fatalf("live authority lost: %d", revoked.Code)
}
}
11 changes: 11 additions & 0 deletions go/cmd/ftw/beta4_audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
Expand Down Expand Up @@ -57,6 +58,16 @@ func TestBeta4AuditPlannerAndControlKeepEachKnownBatteryLimit(t *testing.T) {
// fixture with current Configure: that would stop testing an upgrade.
func TestBeta4AuditUpgradePreservesForecastLearning(t *testing.T) {
t.Setenv("TZ", "UTC")
// localzone deliberately captures the process zone once. The historical
// fixture needs UTC even when another API test has already read that zone.
if os.Getenv("FTW_BETA3_FORECAST_FIXTURE") != "1" {
cmd := exec.Command(os.Args[0], "-test.run=^TestBeta4AuditUpgradePreservesForecastLearning$", "-test.count=1")
cmd.Env = append(os.Environ(), "FTW_BETA3_FORECAST_FIXTURE=1")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("UTC fixture subprocess: %v\n%s", err, output)
}
return
}
raw, err := os.ReadFile("testdata/beta3-forecast-binding.json")
if err != nil {
t.Fatal(err)
Expand Down
Loading
Loading