Skip to content

feat(agents): enforce agent and support access in Core - #1646

Merged
frahlg merged 5 commits into
masterfrom
claude/agents-core
Oct 15, 2026
Merged

frahlg merged 5 commits into
masterfrom
claude/agents-core

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

What

Core enforces agent access as docs/agents.md specifies: an assistant through MCP at FTW Cloud, Sourceful support for one ticket, and the release check. The box decides everything; FTW Cloud can admit an agent but only the box lets it act.

Built on the contract in #1642, which has merged. The branch is rebased onto master: one squashed commit for the work up to the second review, then the review's fixes as their own commits.

Enrolment (appenroll)

  • Agent rows beside phones: role agent/support/release, scopes, ops, caps, ExpiresAtMs, principal, and who approved it. They survive a restart; a row whose approving phone is gone is dropped, as a watch is.
  • AddAgent/WidenAgent check the grant against the role, the registry's agent scope per op and the box's limits, and verify the owner phone's approval signature (P-256, go/protocol vectors) with a 10-minute window and a replay memory. The box page approves without a signature.
  • approval.key.set registers an owner phone's key with proof of possession.
  • Expiry is wall clock, from cloudlink.ClockSynced. Without NTP an agent row may do nothing and nothing is swept.
  • Check (relay) refuses agent keys; CheckTunnel admits them. The watch door refuses them too.
  • Removing a phone removes the agents it approved; demoting it narrows them at the next call.

The session (appproto)

  • An agent's frames go through dispatchAgent: the live grant (revoke → E_GRANT_REVOKED + terminate, expiry → E_GRANT_EXPIRED + terminate expired, no clock → E_UNAVAILABLE {clock}), by on every support call, a scope per read, 60 reads a minute. api.req is refused.
  • Commands meet, in Core's order after the op and the grant: the op's agent scope (ftw.goals.request → E_NEEDS_OWNER), the per-grant allowlist (E_OP_NOT_GRANTED), perMin (E_RATE_LIMITED, box-wide across sessions), then the caps: hold_s 1..holdS (never the persistent 0), power_w ≤ maxW, boost duration ≤ holdS (E_CAP_EXCEEDED, refused, never clipped).
  • agent.request stores a pending change (4 per agent, 16 per box, 12 h) and pushes agent.request to owners. agent.confirm approves with a signature and runs the op as a command req:<id> on the owner's own session under the owner's scopes; declining needs no signature.
  • Its own command-log root (agent:<id>), an audit row for every call and every grant change, audit.get for owners.
  • logs.get and config.get answer from internal/redact: names become opaque ids; addresses, hosts, URLs, e-mail, MACs, serial-like words, long numbers, tokens and secrets go; attribute values stay only when they are numbers, flags, durations or short code words; a config number stays only under a key that names its unit or a known setting. logs.get pages with beforeMs/afterMs and says more. health.get counts drivers and names no device. events.get, diag.run and cars.get answer E_UNKNOWN_OP until built.
  • An open agent session ends on the tick after its grant ends.

The rest

  • appuplink: one callerOf for both doors; AgentAccess ends a removed row's sessions; the roster carries Device.agent, leaves out expired rows.
  • state: agent_audit table, 10,000 rows and 90 days, written off the frame path.
  • api: /api/agents and /api/agent-requests on the box page, tier local. Widening, approving, replacing a key and resetting the clock's mark need the house password, from any address; revoking and declining are open to the LAN. With api.lan_auth on, every one of these routes needs the sign-in. The reads join protectedReadPath.
  • notifications: the security notices (agent.added, agent.request, agent.expiring, approval.key.set, owner.added) go whatever the notification settings say; FTW Cloud gets each as a data-free notice {kind}, copy or not.

The box page's sign-in, as checked

With api.lan_auth off (the default) every LAN client is an owner with no sign-in, so widening refuses until the house password is on. With it on: argon2id, ≥10 characters, five wrong guesses lock sign-in box-wide for 30 s, a 12-hour HttpOnly SameSite=Strict cookie; but plain HTTP on the LAN and one shared password with no person behind it.

Tests (the asked-for ones)

  • Revoke fails the next call: TestARevokeFailsTheNextCall, TestARevokeBitesAtOnce, TestAnAgentComesThroughTheTunnelAndNotTheRelay.
  • Expired fails at the box even when the cloud admits it: TestAnExpiredAgentFailsAtTheBoxEvenWhenTheCloudAdmitsIt (open session, read, command, tick, no clock), TestAnExpiredAgentIsRefusedAndSwept, TestTheRosterCarriesAnAgentsGrant.
  • Op outside the allowlist: TestAnOpOutsideTheAllowlistIsRefused.
  • Hold past its cap: TestAHoldPastItsCapIsRefused (also hold_s 0 and no end), TestTheGrantsRateHoldsAcrossSessions.
  • A durable change waits; a confirmation without a valid signature fails: TestADurableChangeWaitsForTheOwner (unsigned, a stranger's key, a signature over another request, a viewer, replay), TestARequestRunsOutAndADeclineNeedsNoSignature.
  • Support ends when its ticket closes or expires: TestASupportGrantEndsWhenItsTicketCloses.
  • Redaction holds: internal/redact (20 hostile lines, attributes, config) and TestLogsReachAnAgentRedacted.
  • Also: TestAnAgentNeedsAnOwnersSignature, TestAGrantStaysWithinTheBoxsLimits, TestNoClockNoAgents, TestAnAgentHasACommandLogOfItsOwn, TestWhoMayGiveAnAgentAccessOnTheBoxPage, TestNarrowingIsOpenOnTheLANAndApprovingIsNot, TestTheAgentAuditPagesNewestFirstAndForgetsOldRows.

Local: go test -p 2 on protocol, redact, appenroll, appproto, appuplink, cloudlink, state, api, notifications and cmd/ftw, then one make verify (with -p 2): clean.

Paired PRs

Contract-pair: srcfl/ftw-webapp@claude/registry-agents

Not here

  • events.get, diag.run and cars.get; planner timing in health.
  • FTW Cloud turning a notice into a push. Until it does, it drops the class, as it drops any box message it does not know.
  • No offer keys are pinned. cloudlink.OfferKeys is empty, so the box page opens no signed offer until a release sets it from FTW Cloud's offer key (feat(keys): ftwkeysd, the one part that can open an agent's session ftw-cloud#18). Phones grant through agent.add as before.
  • Pending requests live in memory; a restart drops them.
  • The Access list, the approval key and the offer screen in the apps.

The changeset is minor (a visible capability), which needs Fredrik's yes.

After the review

Core side of the contract review (the contract changes are in #1642):

  • Holds (blocker 2). Agent args meet agent_args as they arrive; a charger is named by the agent id the agent read, which the box resolves. loadpoint.ManualHold.SetBy records the setter; an owner's hold wins (E_NEEDS_OWNER); an agent clears only its own; revoke, expiry and the expiry tick release an agent's holds. Tests: TestAHoldPastItsCapIsRefused (now also max_amps_per_phase, voltage, the real id, a bad phase mode), TestAnOwnersHoldWinsAndAnAgentsEndsWithItsGrant.
  • The box page (blocker 3). Widening needs the house password from any address, loopback included; offers open only with a pinned key (cloudlink.OfferKeys, empty until FTW Cloud mints one, so no offer opens yet); POST /api/agents/approval-key replaces a lost key.
  • Drivers. A driver's HTTP, TCP and WebSocket dials to the box's own API port, on loopback or the box's own addresses, are refused (drivers.SetSelfAPIPorts, TestADriverNeverReachesTheBoxsOwnAPI). Narrower than a full loopback ban, since FTW lets a driver reach a loopback service its config names (configuredProbeLoopbackHost). The doc now says so (see B3 below).
  • Approvals. Replay memory keyed by the signed bytes (TestTheSameBytesAreTakenOnce uses the (r, N−s) form), atMs from before the start refused, E_CLOCK_SKEW {boxMs}, a replaced key needs prevSig (TestReplacingAnApprovalKeyNeedsTheOldOne), one id per row.
  • Requests. Args checked on arrival, the owner signs over the resolved args, the agent sees its own, agent.decided to the agent's session (TestARequestsArgsAreCheckedAndTheAgentHearsTheEnd).
  • by, the roster (tun: false, no copy scopes, noise), typed reads with agent ids and no sub for agents (TestTypedReadsCarryAgentIDs), redaction with allowed attribute keys and no driver free text (go/protocol/testdata/redaction-vectors.json), a persisted clock high-water mark, folded read audit rows with their own budget, and the pushes agent.expiring, approval.key.set, owner.added.

After the second review

  • NB1. An agent's hello carries no sub, and the box ignores one (applySub returns for agents). Test: TestAnAgentGetsNoSubThroughHello.
  • S1, audit flood. An agent's refused commands, requests and reads fold into one row per agent, message, by, result and minute, with a count. Agent rows have their own budget and never push out an owner's or the box's; when the write queue is full, an agent row is dropped and counted, an owner's is written at once. Tests: TestAnAgentCannotWriteIntoTheAuditLog (50 junk messages, one row), TestAnAgentsRowsNeverPushOutAnOwners.
  • S2. Removing an owner phone, or demoting it, ends the holds of the agents it approved, and agentGone forgets their requests and writes agent.remove.
  • S3. Every answer to an agent goes through forAgent: error args keep a fixed list of keys, charger and source names become agent ids, other text goes. Test: TestAPhoneNamesNobody and the hold tests' error args.
  • S4. A support grant needs principal.clientId to be Sourceful's support client, on agent.add and on the box page.
  • S5. An approval with atMs from before this start is refused (sinceMs is the start), so the replay memory needs no disk.
  • Item 9. An agent.request meets its op's own argument check as it arrives: site.mode.set, planner.prefs.set (at least one pref, a planner on the box), loadpoint.soc.set and loadpoint.surplus_only.set share one function with their handlers. An op with no check ahead is refused. Test: TestARequestMeetsItsOpsOwnChecksAsItArrives.
  • Item 14. Security notices pass the master switch and the rules, and go to FTW Cloud as notice {kind} (cloudwire allowlist, docs/protocol-cloud.md), queued while the link is down. Tests: TestSecurityNoticesPassTheMasterSwitch, TestANoticeGoesWithTheCopyOff.
  • B3. Kept the guard to the API's ports and fixed the doc to match: other ports stay open for helpers a driver's config names, and a proxy or Home Assistant ingress on another port is not covered; the house password still guards every widening route, and forwarding headers make them refuse.
  • Item 12. Config numbers stay only under a key ending in a unit or a known setting, so ports, unit ids and account numbers go. Hosts with any two-letter ending or a common longer one are scrubbed (.me, .ai, .xyz). Vectors regenerated. The car store's names join with feat(cars): the car apart from the charger, in Core #1650.
  • Typed reads. logs.get cursors and more (TestLogsPageWithCursors). Phones may send live.get, loadpoints.get, drivers.get and cars.get under their scopes (TestAPhoneReadsTheTypedReads). cars.get, events.get and diag.run stay E_UNKNOWN_OP; carId in loadpoints.get comes with feat(cars): the car apart from the charger, in Core #1650.
  • B1. No offer keys are pinned yet; see Not here.
  • Notes. N1 the box page grants no more than the offer. N2 widening pushes, and narrowing or demoting ends current holds. N3 an agent's hold is set and cleared in one locked step in the controller (SetManualHoldIfOwn, ClearManualHoldIfOwn, TestAnAgentsHoldNeverReplacesAnOwners). N4 POST /api/agents/clock-reset, with the house password, makes now the mark and ends every agent's grant. N5 approving needs a trusted clock, on the box page and from a phone (TestApprovingNeedsAClock). N6 perMin 0 means the default. N7 guards need ftw.live.read. N8 already held: both reads are in protectedReadPath, so with lan_auth on a LAN client that has not signed in gets 401 (TestTheAgentRoutesNeedSignInWithLANAuthOn); a wrong password is also refused on the narrowing routes. N9 the sweep ends expired requests and tells the agent. N10 one agent hold per charger every 30 s. N11 promoting a phone to owner sends owner.added.

With #1650 (cars)

Whoever merges last does these, as agreed with the car session (also listed in #1650):

  • Cut car ids in cloudlink.ProjectLP (carId, carCorrectedFrom, choose) and in one exported car projection for cars.get, so agents get them under the box's agent key. The copy cuts no car ids today.
  • agent_args kind car in appproto/agentargs.go: resolve the agent's car id against each car id cut with h.cfg.AgentKey, as resolveLoadpoint does for chargers. Cars live in internal/cars (Runtime.Cars()).
  • Add the car store's names, VINs, identifiers and sources to agentPorts.names() in cmd/ftw/agents.go, so logs.get and config.get redact them.
  • Registry: car.goal.set and car.soc.set get agent: ftw.goals.request and agent_args; both use E_NOT_FOUND as worded here. Rebase and regenerate the Go files; no hand-merging.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:40:46.316796Z 47c8daf PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47c8daf8c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +925 to +929
var res *CmdResult
if rec, ok := h.cfg.Commands.lookup(h.cmdRoot(), cmdID); ok {
res = rec.result
}
return protocol.RequestApproved, res, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return rejected results from box-page approvals

When the confirmed command is rejected before acceptCmd—for example because a loadpoint disappeared or the request contains invalid arguments—sendCmdResult writes only to discardSender, and no command-log record is created. This lookup therefore leaves res nil and the HTTP route reports {"state":"approved","result":null}, hiding that the requested change never happened. Capture the emitted result or persist early rejections so the box page can distinguish approval from command failure.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

Comment on lines +644 to +647
for _, row := range h.cfg.Access.Agents() {
if row.ID == req.Agent {
return slices.Contains(row.Ops, req.Op) && slices.Contains(row.Scopes, protocol.ScopeGoalsRequest)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject confirmations after the agent grant expires

During the interval after ExpiresAtMs but before the 30-second expiry sweep, Access.Agents() still returns the row and this check considers only its raw scopes and operations. An owner can therefore approve and execute a pending durable request from an already-expired agent; whether expiry cancels the request becomes timing-dependent. Check the row's current expiry/effective grant here rather than relying on the asynchronous sweep.

AGENTS.md reference: AGENTS.md:L28-L30

Useful? React with 👍 / 👎.

Comment on lines +111 to +113
protocol.MsgAgentRequest: true,
protocol.MsgAgentList: true,
protocol.MsgAgentRevoke: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rate-limit and audit agent list calls

Because agent.list is classified as agentOther, dispatchAgent skips both allowRead and the read audit path before serving it. A connected agent can consequently issue unlimited list requests despite the per-agent 60-reads-per-minute limit, and those calls leave no audit rows even though they expose the agent row and pending requests. Route this no-scope read through the same rate and audit checks as the other agent reads.

Useful? React with 👍 / 👎.

Comment on lines +370 to +373
for key, meta := range i.authorised {
if len(meta.approvalKey) > 0 {
out = append(out, protocol.ApprovalKeyInfo{DeviceID: deviceID(key), SetAtMs: meta.approvalKeySetMs})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter demoted phones from approval keys

If an owner phone registers an approval key and is later demoted to viewer, the key remains stored and this method continues advertising it in agent.list and /api/agents. However, approveLocked rejects approvals from that phone because it is no longer an owner, so clients are told an unusable key can approve grants. Include only current owner-phone rows in this list.

Useful? React with 👍 / 👎.

Agents and support reach the box only through FTW Cloud's tunnel, with a
grant the owner approved: scopes, a per-grant op allowlist, caps, an end
time, holds that end, durable changes as owner-confirmed requests, an
audit log the owner reads, and logs and config redacted on the box.

Built on the contract in #1642. Squashed from claude/agents-core onto
master after #1642 merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 4 commits October 15, 2026 22:55
Typed reads: logs.get pages with beforeMs and afterMs over the whole
ring and says more; phones may send live.get, loadpoints.get,
drivers.get and cars.get under their scopes.
N4: the box page may reset the clock's mark, with the house password;
every agent's grant ends with it.
N5: approving a request needs a wall clock the box trusts.
Doc: agent hello and sub, safe error args, holds in one step, the hold
gap, security notices, audit folding, the clock reset, offer limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
handler.go: keep both. An agent subscribes to its decided requests and
never joins the plan feed; it reads the plan by asking, so its reads stay
counted and in the audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit f024251 into master Oct 15, 2026
18 checks passed
frahlg added a commit that referenced this pull request Oct 16, 2026
The agents test from #1646 builds a tunnel with the new arrivals argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
#1646 landed first, so this does the wiring left to whichever came
second. An agent names a car in car.goal.set and car.soc.set by the id
cars.get gave it (agent_args kind car), and the box checks the car and
the goal as the request arrives. cars.get answers with the car class
under the agent key: levels and goals, nothing that names a car. The
redactor takes the cars' names, VINs, identifiers and sources out of
logs and config. loadpoints.get and FTW Cloud's lp carry each charger's
car by an opaque id, and an agent's plan.get and command outcomes name
chargers and cars by its own ids.

The plan's chargers carry the session's car. A change of car replans
before it tells listeners, so a phone sees the new car on its charger at
once, with the plan pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
#1646 landed first, so this does the wiring left to whichever came
second. An agent names a car in car.goal.set and car.soc.set by the id
cars.get gave it (agent_args kind car), and the box checks the car and
the goal as the request arrives. cars.get answers with the car class
under the agent key: levels and goals, nothing that names a car. The
redactor takes the cars' names, VINs, identifiers and sources out of
logs and config. loadpoints.get and FTW Cloud's lp carry each charger's
car by an opaque id, and an agent's plan.get and command outcomes name
chargers and cars by its own ids.

The plan's chargers carry the session's car. A change of car replans
before it tells listeners, so a phone sees the new car on its charger at
once, with the plan pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
* feat(appuplink): send a reading through FTW Cloud as it arrives

WIP: arrivals from the driver registry, Push and Beat on tunnel sessions,
the readable copy's live on arrival while watched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(appuplink): pace pushes with a token bucket, gather 5 ms; live box rig for the iPhone tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(telemetry): arrivals reach every reader and merge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(appuplink): review fixes for send on arrival

A frame on every poll, so timing shows the poll rhythm and not which value
moved. A dropped frame is not recorded as sent (appproto.ErrDropped). A
source table too big for lane 0 goes in a snapshot, sent in line with
lane 0 so no delta overtakes it. Pushes stop at 60 % of the tunnel budget,
beats at 90 %. The beat runs and arrivals are heard only while a session
is live, and the copy hears them only while watched. OCPP readings signal
arrivals. Lane 0's invariant: fixed length, fixed cadence over the relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(appuplink): a push's cost, the rig's poll period; tunnel comments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(appuplink): merge master; lane 0's rule as Fredrik approved it

The agents test from #1646 builds a tunnel with the new arrivals argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
#1646 landed first, so this does the wiring left to whichever came
second. An agent names a car in car.goal.set and car.soc.set by the id
cars.get gave it (agent_args kind car), and the box checks the car and
the goal as the request arrives. cars.get answers with the car class
under the agent key: levels and goals, nothing that names a car. The
redactor takes the cars' names, VINs, identifiers and sources out of
logs and config. loadpoints.get and FTW Cloud's lp carry each charger's
car by an opaque id, and an agent's plan.get and command outcomes name
chargers and cars by its own ids.

The plan's chargers carry the session's car. A change of car replans
before it tells listeners, so a phone sees the new car on its charger at
once, with the plan pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
* feat(cars): the car apart from the charger, in Core

The box keeps its owners' cars in one state.db row and decides which car
each charging session is: the charger's identity, the car's own report
within a minute, the only or usual car, one tap after car.which, or a
visitor. The session's car gives the loadpoint its capacity, goal,
surplus choice and level, and the planner reads them. A car reading
stays exact until 1 kWh has reached it. The search-based pairing and its
refusals on ambiguity are gone. The loadpoint routes and ops act on the
session's car for one release. Migration makes Car 1 from the loadpoint
and a car from each vehicles[] profile; nothing the owner set is lost.

docs/cars.md is the contract; two reviews shaped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cars): agents read and ask by car, and the plan push names the car

#1646 landed first, so this does the wiring left to whichever came
second. An agent names a car in car.goal.set and car.soc.set by the id
cars.get gave it (agent_args kind car), and the box checks the car and
the goal as the request arrives. cars.get answers with the car class
under the agent key: levels and goals, nothing that names a car. The
redactor takes the cars' names, VINs, identifiers and sources out of
logs and config. loadpoints.get and FTW Cloud's lp carry each charger's
car by an opaque id, and an agent's plan.get and command outcomes name
chargers and cars by its own ids.

The plan's chargers carry the session's car. A change of car replans
before it tells listeners, so a phone sees the new car on its charger at
once, with the plan pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cars): the reserve holds to the session's car, and a car's goal has a deadline before the replan

#1657's reserve took its power from the charger and its target from the
goal alone: a Volvo whose own charger takes 7.4 kW reserved 11 kW on an
Easee. LimitPower moves into loadpoint, and DeadlineNeed and the plan
both hold to the lower of the charger and the car, keeping the charger's
lowest step, and to the car's charge limit while its level is exact. The
car's binding carries that limit; its phases bound nothing beyond its
own rating, so the unread CarPhases goes.

A goal the car brings cleared the loadpoint's rolled deadline until the
next control tick, and the replan right after a tap planned without the
car. The runtime rolls the schedules before every replan and after its
first sync, and replans when a sync hands a loadpoint a goal or a
surplus choice.

A visitor's goal ends its surplus-only charging: someone who asks for a
goal wants it met, and the plan and the reserve plan from empty while
the level stays unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cars): the plan is pending before a charger shows a new car

The tap test failed in CI with the Volvo's first plan not pending. The
order was right: the feed hears of a change only from the planner, after
RequestReplan marks the plan pending. But the feed folds changes within
its 100 ms settle, and the test's replan failed at once without prices,
so it had ended before the folded push was written. The test now holds
the tap's replan in its read of the loadpoints, as a real solve takes
seconds, and still asks that the first plan naming the Volvo be pending.

A real gap remained: the loadpoint showed the new car a moment before
the replan request marked the plan pending, and a push written then
showed the car beside the old plan as current. The runtime now asks for
a replan before it hands the loadpoints a change and again after the
schedules roll; the second supersedes the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant