M2-20: Heartbeat, presence and desired versus actual mode¶
Status: ready | Depends on: M2-07 | Milestone: M2
Goal¶
After this ticket an agent adapter reports the state of its runtime with sendHeartbeat (POST /api/v1/agents/me/heartbeat) or with the member agent of the gateway op heartbeat, in the schema agent_heartbeat.v1 of 09 "Heartbeat and reported state": state, runtime version, actual billing mode (reported_billing_mode) with its evidence (billing_evidence, never a key value), the modes it can prove, the tool class it actually loaded, capabilities, active runs and project readiness. The server keeps the presence of every agent in memory, derives online, degraded or offline (offline once agent.presence_timeout has passed without a heartbeat), stores the heartbeat fields at most once per agent.heartbeat_persist_interval, stores and audits a change of the actual billing mode or tool class at once (action agent.heartbeat), and sends the ephemeral event agent.presence_changed {agent_id, state, reported_billing_mode} to every principal with agent.read when state or actual mode change. The snapshot (getSnapshot, ready) and the agent card (getAgent, listAgents) show this presence instead of the placeholder unknown of D-G-280. When actual and desired mode differ, the agent card carries the warnings of 10 "Expected and reported", and a gauge lets the alarm BothyBillingModeMismatch fire in the local observability stack once the difference has lasted billing.mismatch_alert_after. This is the server part of BR-05: the heartbeat's actual mode is evidence for display, warning, audit and alarm and never a permission; the binding checks stay in the adapter (I3) and at run.start (BR-06, M2-09), so a lying heartbeat cannot weaken any check.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal; Scope "10 with I2 to I4 and I6, configured and reported mode"; Out of scope "fencing across several nodes (M7)"; Exit criteria items 2 and 10; Test strategy; Red team "faking the reported mode"; Risks "fail closed onunknown"), block "agents and extensions" rows M2-03, M2-07, M2-08, M2-09, M2-20, M2-21, M2-29, M2-31, M2-33, M2-34; section 7 rows M1-04, M1-51, M1-63, M1-64, M1-66, M1-68; section 12 row M6-14; section 13 rows M7-09, M7-17, M7-20; section 18.3 row "Presence of agents".- Dependencies. Header: M2-07 (agent consumers,
internal/wire/configkeys_agents.go). Implied through it and therefore not in the header: M1-10 (internal/platform/otel, meter provider), M1-11 (internal/platform/lifecycle:Component,StopStep,PhaseConnections), M1-13 (unit of work), M1-16 (event log and envelope), M1-18 (HTTP chain, problem details, closed request schemas), M1-19 (rate limits), M1-24 (auditing.Recorder), M1-25 (action registry), M1-28 (authorize,AgentSelf,AgentPolicySourcewithReportedToolClass, filter and delivery mode), M1-44 (agent API tokens), M1-51 (agent records,Agent.presence,internal/agents/adapters/authzsource), M1-63 (snapshot with agents in stateunknown), M1-64 (gateway opheartbeatthat ignores memberagentuntil this ticket, rate limiter port,wstest), M1-65 (hub,authorize.Delivery(ctx), two server instances in one test binary). Needed but outside the closure of the header (reported as plan gaps; see Boundaries): M1-66 (kernel/eventlog.EphemeralPublisherand the ephemeral path of the hub), M2-03 (ApplyProjectReadinesson thechatport), M1-68 (metric registryinternal/platform/otel/metrics, signal check) and M1-04 (Prometheus rule tests and thealert-thresholdscheck). docs/spec/09-agents.md: 2.2 "Features" (capability names of the heartbeat), 2.4 "Tool class" (warning on a different actual class), 4.1 "Overview", 4.2 "Transports", 4.7 "Adapter operations" (row Heartbeat: "last one wins"), 4.8 "Heartbeat and reported state" (all), 5.5 "Stall, interruption, orphaned runs" (M2-09 reads what this ticket stores), 15 "Reliability", 16.1 "Test doubles", 17.8 "Reported mode", 18.3 "Reported mode per run" (last paragraph), 19 (R-AG-01), 21 "Configuration values of this chapter".docs/spec/10-backend-policy.md: 3 "Invariants" (I3, I5), 7 "Adapter check (I3)" (strength of the evidence sources; an undeterminable mode counts as unequal to every desired mode), 8 "Expected and reported" (both tables and the audit paragraph), 9.2 "What happens when switching" point 5, 13 "Display in the app" (row Agent card (console)), 14 "Audit trail" (row "Reported mode changed"), 15 "Tests" (BR-05, BR-06, BR-13), 17 "Configuration values of this chapter" (billing.mismatch_alert_after).docs/spec/04-sync-protocol.md(core contract, read only): 2.1 "Two kinds", 2.2 "Envelope", 6.2 "Ops" (rowheartbeat;errorkeeps the connection), 6.3 "Lifecycle" steps 4 and 5, 6.4 "Close codes" (4002,4009), 7.2 "Flow" (ephemeral frames only aftercatchup.complete), 9 "Delivery guarantee", 12 "Typing and agent presence" items 2 and 3, 17 (R-SY-25), 18 "Configuration values of this chapter" (agent.presence_timeout,sync.heartbeat_interval).docs/spec/03-data-model.md(core contract, read only): tableagents(columnsreported_tool_class,reported_runtime_state,reported_billing_mode,reported_runtime_version,reported_details,last_heartbeat_at,version; index(last_heartbeat_at); the paragraph onagent.heartbeat_persist_intervalbelow the table),audit_events.details, 6.3 "Lock order", 9 "Configuration values of this chapter" (agent.heartbeat_persist_interval).docs/spec/05-actions.md(core contract, read only): 1 "Binding rules" rules 1 to 4; 2.1 "Column "Permission"" (agent-self); 2.4 (column Audit); 2.5 "Decision" (rate limit beforeauthorize); 3 "Rate-limit classes" (rl.agent_runtime); 4.10 rowsagent.readandagent.heartbeat; 6, table "Ephemeral events", rowagent.presence_changed; 7 rowheartbeat; 8 R-AZ-04.docs/spec/02-architecture.md: 3.2 "Roles of a process"; 4.1 "Rules"; 4.3 (rowagentswith portPresence; rowgatewayimplementingeventlog.EphemeralPublisher); 4.4 (agentsmay useidentity,authz,chat;gatewayandsyncmay useagents); 4.5 (kernel/eventlog,kernel/auditing,kernel/clock,kernel/errs); 4.6 and 4.7 (owners ofagent.heartbeat,agent.presence_changed, opheartbeat); 5 "Layers within a module"; 8.1 "Flow"; 11.6 "Ephemeral events"; 11.7 "Cost of serialization"; 15.3 "Shutdown"; 16 "Scaling path" ("Presence and typing: viaNOTIFY ephemeral").docs/spec/06-api.md: 2 "Basic rules" (row Unknown values), 5 "Errors" (point 3:errors[]withnot_enabled; point 6), 7 "Idempotency" (sendHeartbeatexempt), 9 "Rate limits", 11 "Real-time gateway", 16 "Endpoints" (rowsendHeartbeat), 17 "WebSocket ops and events" (rowsheartbeatandagent.presence_changed).docs/spec/07-permissions.md: 2.9 "Agents as principals", 2.11 "Tool class of an agent", 4.2 "Flow" (filter and delivery mode write no audit), 8.1 "Delivery filter" (row "Ephemeral events"), 11.2 "Required tests" (AZ-P1, AZ-P2, AZ-P5, AZ-P13).docs/spec/01-product.md: feature rows "Presence of agents" and "Presence of people", wireframe A5, R-PR-02.docs/spec/20-privacy.mdrow "Presence".docs/spec/17-observability.md: 2 rules 3, 4, 8, 9; 6 "SLOs and alerts".docs/spec/18-release.md9 point 5.docs/spec/19-security.mdSEC-AUD-5, SEC-IN-1.docs/api/openapi.yaml: operationsendHeartbeat; schemasHeartbeat,HeartbeatAck,BillingEvidence,BillingMode,ToolClass,AgentRateLimitState,ProjectReadiness,JsonObject,AgentRuntimeStatus,AgentRuntimeState,Agent,AgentPresence,Snapshot,GatewayReady,GatewayHeartbeat,GatewayHeartbeatAck,GatewayError,AgentPresenceChangedEvent,AgentPresencePayload,EventEnvelope,AuditVerifyResult(pattern of awarningsmember),Problemwithx-problem-codes.docs/api/asyncapi.yaml: operationreceive.heartbeat, messageevent.agent.presence_changed.- ADRs 0008, 0013, 0017, 0022, 0024, 0034, 0035, 0050, 0052, 0053.
docs/DECISIONS.md: D-L8, D-K-A13, D-K-D29, D-V08 (fieldrate_limit, built by M6-14), D-G-280 (this ticket ends its presence part), D-W-100; section 10 for the format of new rows.docs/security/threat-model.md: 5.10 "Agent adapters and runtime", register rows T-AI-08 and T-TK-02.docs/RISKS.md: RK-13, RK-37.docs/OPEN-QUESTIONS.md: OQ-06 (default "leave it as it is": humans have no presence), OQ-07 (default M7; "The code stays node-neutral from M1 on").docs/observability/signals.md: 1 "Names and units" item 4, 2 "Cardinality", 4.3 rowsbothy.agent.heartbeat.ageandbothy.agent.billing_mode_mismatch, 5.3 rowbothy.agent.handle, 7 "Logs".docs/observability/alerting.md: 3.2 rowsBothyAgentOfflineandBothyBillingModeMismatch, 4 anchors.docs/observability/dashboards.mdrowagent-runs.json. Filesinfra/observability/prometheus/rules/bothy-alerts.yamlandinfra/observability/grafana/dashboards/agent-runs.json(panels "Heartbeat age", "Billing mode mismatches").docs/operations/configuration.md:agent.presence_timeout,agent.heartbeat_persist_interval,billing.mismatch_alert_after,sync.heartbeat_interval,shutdown.total_timeout,http.max_json_body_bytes,ratelimit.rl.agent_runtime.per_minute,ratelimit.rl.agent_runtime.burst,cluster.mode.- M1 briefs whose interfaces this ticket uses:
M1-11-lifecycle-health.md,M1-13-unit-of-work.md,M1-24-audit-chain.md,M1-28-authz-core.md(AgentPolicy, "agent policy facts are read fresh per call"),M1-51-agents-records.md("presence view",billing_mode_matches),M1-63-sync-catch-up.md(note 9, bulletagents),M1-64-gateway-connection.md(note 14),M1-65-gateway-delivery.md(candidates of an event with empty scope),M1-66-gateway-focus-typing.md(EphemeralEvent,EphemeralPublisher, ephemeral rules of the hub),M1-68-core-metrics.md(registry and recording pattern),M1-04-ops-config-checks.md(rule tests,alert-thresholds). M2 briefs for the borders:M2-03-project-binding.md(note 7,ApplyProjectReadiness),M2-07-agent-consumers.md(config key file, rate limit of a gateway op),M2-09-run-lifecycle.md(note 10, readslast_heartbeat_atandreported_details.active_runs). - Merged code on branch
main(read withgit show main:<path>, no checkout):services/server/architecture/modules.yaml(moduleagents: actionagent.heartbeat, ephemeral eventagent.presence_changed; modulegateway: opheartbeat),services/server/internal/agents/README.md("Notes for the build": heartbeat state in memory),services/server/internal/wire/configchapters.go,configkeys_messaging.go,configchecks.go. - Code:
internal/agents(M1-51, M2-07),internal/gateway(M1-64 to M1-66, M2-07),internal/sync(snapshot of M1-63),internal/chat(port of M2-03),internal/authz,internal/kernel/authorize,internal/kernel/auditing,internal/kernel/eventlog,internal/kernel/clock,internal/kernel/ids,internal/kernel/errs,internal/platform/lifecycle,internal/platform/otel,internal/platform/pg/pgtest,internal/identity/adapters/identitytest,internal/gateway/adapters/wstest,internal/wire,services/server/api/operations.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- The adapter sends the heartbeat over REST (
sendHeartbeat) or as memberagentof the gateway opheartbeat, with the same effect; actionagent.heartbeat(agent-self), rate-limit classrl.agent_runtime, exempt fromIdempotency-Key, the last heartbeat wins (05 4.10 and 7; 06 7 and 16; 09 4.7 and 4.8; openapisendHeartbeat,GatewayHeartbeat). - Content after
agent_heartbeat.v1: state, versions, actual billing mode, provable modes, evidence, actual tool class, capabilities, active runs, project readiness; never secrets, contents or paths, never a key value or prefix (09 4.8; 03agents.reported_details; 10 7; I5). - Presence is derived by the server:
onlineordegradedfrom the heartbeat,offlineafteragent.presence_timeoutwithout heartbeat,unknownbefore the first one; humans have no presence (04 12 item 2; 09 4.8 rowstate; 01 R-PR-02; 20 row "Presence"; OQ-06). - On a change the server sends the ephemeral
agent.presence_changed {agent_id, state, reported_billing_mode}to every principal withagent.read; noseq, at most once, never beforecatchup.complete(04 2.1, 7.2, 9, 12 item 2; 05 6 "Ephemeral events"; 02 11.6; openapiAgentPresenceChangedEvent). - Heartbeat fields are stored at most once per
agent.heartbeat_persist_interval; a change ofreported_billing_modeorreported_tool_classis stored at once and audited with actionagent.heartbeat, decisionallow, old and new values and the evidencemethod(03agents; 04 12 item 2; 05 4.10 column Audit; 10 8 and 14). - No rights version and no
access.changed(05 4.10: Permissionsno);authorizecounts the higher oftool_classandreported_tool_class(03agents.reported_tool_class; 07 2.11; 09 2.4). - The snapshot holds the presence of the agents the principal may read, and
readycarries the current state (04 6.3 step 4 and 12 item 2; openapiSnapshot.agents,AgentPresence). - The server compares actual and desired mode at each heartbeat and shows the warnings of 10 "Expected and reported" on the agent card; an undeterminable actual mode counts as unequal to every desired mode (09 4.8; 10 7, 8 and 13; 09 2.4 for the tool class).
- A difference that lasts longer than
billing.mismatch_alert_afterraises an alarm (10 8 row 1; 17 6; alerting.mdBothyBillingModeMismatch). - Telemetry: only catalogue names, closed attribute sets, no person attributes, never blocking (17 2 rules 3, 4, 8, 9; signals.md 2).
- No JSON
nullin any body, frame or stored detail (ADR 0053); every body and frame conforms to its schema (06 15).
Scope¶
All paths under services/server/ unless noted. No migration: M1-51 created the table agents with every column this ticket writes. No new top-level Makefile target.
Create:
internal/agents/presence.go(port, doc comment on every symbol):type Presence interface { Heartbeat(ctx context.Context, body json.RawMessage) (PresenceStatus, error); Statuses(ctx context.Context, agentIDs []ids.ID) ([]PresenceStatus, error) }.Heartbeattakes the JSON object of schemaHeartbeat(the REST body or the memberagentof a gateway frame), so both transports share one decoder (Design note 1); the actor comes from the context.type PresenceStatus struct { AgentID ids.ID; State string; ReportedBillingMode string; RuntimeVersion string; LastHeartbeatAt time.Time; BillingModesSupported []string; ReportedToolClass string; BillingModeMatches bool; BillingEvidence *BillingEvidence; Warnings []string }(empty string and zero time mean "not reported"),type BillingEvidence struct { Provider, KeyType, Method string }, and the constants of the states and warning codes.
internal/agents/domain/heartbeat.go:DecodeHeartbeat(raw []byte) (Report, []FieldError)with the rules of Design note 3;ReportedChange(stored, next Reported) (AuditDetails, bool)(Design note 4); thereported_detailsform of Design note 6.internal/agents/domain/presence.go:StateAt(lastHeartbeat time.Time, reported string, now time.Time, timeout time.Duration) string;StartRule(stored StoredPresence, start time.Time, timeout, persistInterval time.Duration) (offlineNow bool, deadline time.Time)(Design note 7);Assess(target Target, report Report, state string) Assessmentwith the mismatch kinds and warnings of Design note 8.internal/agents/app/presence.go: the process-wide tracker (entries, per-agent lock, deadline timers onkernel/clock, last published pair, mismatch start times), implementingagents.Presence;internal/agents/app/heartbeat.go: the use case of Design note 2;internal/agents/app/presence_flush.go: start load, flush loop and shutdown flush (Design notes 6 and 7);internal/agents/app/metrics.go: outbound metrics port with a no-op default (pattern of M1-68).internal/agents/adapters/postgres/queries/presence.sql(sqlc):GetPresenceRow(desiredbilling_mode,tool_classand the stored reported columns of one agent),LockPresenceRow(FOR UPDATE),WriteReportedChange,PersistHeartbeat(monotone, Design note 6),ListLivePresence(agents stored asonlineordegraded),GetTargets(desired values of given agents); the repository methods.internal/agents/adapters/httpapi/heartbeat.go: the strict-server methodSendHeartbeat.- The two gauges of Design note 9: as
Specs ininternal/platform/otel/metricsif M1-68 is merged, otherwise ininternal/agents/adapters/otelmetrics/presence.go. infra/observability/prometheus/tests/bothy-alerts.test.yaml(repository root): the cases of the Tests section. M1-04 creates this file and thealert-thresholdscheck; if either is missing at build time, create the file in thepromtool test rulesformat M1-04 prescribes and name the missing check in the report.
Change:
internal/agents/app, use cases of M1-51: after a successful commit ofSetBillingMode,SetBillingModeAll,SetToolClass,DisableandEnable, notify the tracker (Design note 13);getAgentandlistAgentsbuildAgent.presencefromPresence.Statuses(Design note 12).internal/gateway/app/heartbeat.go(M1-64): the agent part of the opheartbeat(Design note 10).internal/sync(snapshot of M1-63, note 9 bulletagents): fillAgentPresencefromagents.Presence.Statuses; remove theunknownplaceholder and its comment.internal/wire: one tracker per process in the rolesapiandallas alifecycle.Component(start load, flush loop) with the stop step of Design note 6; bindPresenceinto the gateway, the sync snapshot and the agents HTTP handlers; bindkernel/eventlog.EphemeralPublisher(M1-66), thechatreadiness port (M2-03), the identityDirectory, the clock and the metrics adapter into the tracker; the configuration keys and the cross-check of Design note 16 (configkeys_agents.goof M2-07,configchapters.go,configchecks.go).architecture/modules.yaml: only if the fallback of Design note 9 addsinternal/agents/adapters/otelmetrics, the vendor entry it needs;make archstays green.api/operations.yaml: movesendHeartbeatfromlatertoenabled(sorted); rerun the route generator (make codegen-checkshows no diff afterwards).docs/api/openapi.yaml(spec first): onsendHeartbeata description of Design notes 2 to 5, 8 and 11 and a response example withpresence.state: onlineandinterval_msequal to the default ofsync.heartbeat_intervalin milliseconds; onHeartbeatapatternonruntime_version(version form of Design note 3) and descriptions ofdetails(closed key set and shapes of Design note 3),project_readiness(Design note 11) andrate_limit(not accepted until M6-14); onBillingEvidenceapatternonproviderandmethod(code form of Design note 3); onAgentRuntimeStatusthe new membersbilling_evidence($ref: BillingEvidence) andwarnings(array of strings; description with the known values of Design note 8, the pattern ofAuditVerifyResult.warnings); onHeartbeatAck.interval_msthe description "sync.heartbeat_interval"; onGatewayHeartbeat.agenta description of Design note 10. Thenmake api-lint,make api-tables,make api-checks.docs/api/asyncapi.yaml: one sentence in the summary ofreceive.heartbeat: errors of the agent part answererrorand keep the connection.docs/spec/09-agents.md(English, like the chapter): 4.8 aligned with the openapi member names (top-level members and keys ofdetails,project_readinessinstead ofprojectswithworkspace_ref), the forms of Design note 3, full-state semantics, presence derivation, start rule, the comparison and warnings of Design note 8; 2.2 namesdetails.capabilitiesas the report.docs/spec/10-backend-policy.md8: the warnings as codes ofAgentRuntimeStatus.warnings, the gauge and the alarm of Design note 9, the unknown mode as a difference.docs/spec/02-architecture.md15.3 roleapistep 3: one sentence "Store the presence state of the agents, without an event" (Design note 6).docs/spec/18-release.md9 point 5: the adapter reports its version indetails.adapter.version(stored inagents.reported_details);reported_runtime_versionis the runtime's version (Design note 3).docs/observability/signals.md4.3: new rowbothy.agent.billing_mode.mismatch_age(Design note 9).docs/observability/alerting.md3.2: rowBothyBillingModeMismatchwith the new condition and the configuration value`billing.mismatch_alert_after`; 4 anchorbothybillingmodemismatch: one sentence on the heartbeat cause.infra/observability/prometheus/rules/bothy-alerts.yaml: the extended rule.infra/observability/grafana/dashboards/agent-runs.json: a panel "Reported billing mode differs" in the row "Adapters and policy";docs/observability/dashboards.mdrowagent-runs.json: "mode mismatches (heartbeat and I3)".docs/operations/configuration.md:make config-reference, thenmake config-doc-check.- READMEs of
agents,gatewayandsync(English, like the existing files);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-20-1and following (Design note 17) and, in row D-G-280, the reference to the row that ends its presence part.
Out of scope¶
- The comparison of
active_runswith the server's runs (interrupted) and the missing heartbeat (stale) inruns.stall_check, the readAgents.HeartbeatStates, the I3 check atrun.startwith409 billing_mode_mismatch, run reasons and the counterbothy.agent.billing_mode_mismatch(M2-09). This ticket storeslast_heartbeat_atandreported_details.active_runsin the shape M2-09 reads. - The heartbeat field
rate_limit, its storage and the backend view (M6-14, D-V08). - Presence across several api nodes over
NOTIFY ephemeralwithcluster.mode = multi(M7-17). This ticket keeps the code node-neutral (OQ-07) with monotone writes. - Status page components from presence (M7-09, M7-21) and alarms into a channel of the instance (M7-20).
- Reading effective capabilities (R-AG-01) for
agent.invocation, follow-ups, components, titles, voice and files: the first consumer adds the accessor (M2-08, M2-11, M2-15, M2-18, M3-09); this ticket validates and storescapabilitiesonly. - The adapter side of BR-05 (M2-33 Hermes Agent adapter, M2-34 and M2-35 Claude Code runner), the heartbeat building blocks of the SDKs (M2-29, M2-30), conformance scenarios (M2-31).
- Switching the desired mode and the guided flow (M2-21); project bindings and their readiness rules (M2-03); readiness detection in the runner (M2-34).
- Presence and warnings in the clients and the console (M4-19, M6), including the "Presence history" of wireframe A5, which has no data model in the specification (spec gap).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-20-heartbeat-presence; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(agents): ...,feat(gateway): ...,feat(sync): ...,docs(api): ...,feat(observability): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- No agent runtime, adapter, SDK, coding agent or model runs in any test and no real third-party service is called: the agent side is the
wstestclient of M1-64 or plain HTTP with the agent's API token and DPoP proofs fromidentitytest. Tests read metrics with an in-process SDK reader and alert rules only through the pinnedpromtoolof M1-04; no collector, Prometheus or Alertmanager is started. - Time-dependent tests set short values for
agent.presence_timeout,agent.heartbeat_persist_intervalandbilling.mismatch_alert_afterthrough the configuration loader or use thekernel/clocktest clock; no test depends on a default value. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - If the base branch lacks
kernel/eventlog.EphemeralPublisherwith the ephemeral path of the hub (M1-66) orApplyProjectReadinesson thechatport (M2-03), stop withVerdict: BLOCKED <missing ticket>: both are needed and lie outside the closure of the header. The metric registry (M1-68) and the rule tests (M1-04) have the fallbacks named in this brief. - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
- Placement (02 4.4, 4.6, 4.7).
sendHeartbeat, the actionagent.heartbeat, the tableagentsand the eventagent.presence_changedbelong toagents; the opheartbeatbelongs togateway, which may useagents. One use case inagents/appbehind the portPresenceserves both transports: the REST handler inagents/adapters/httpapipasses the request body (re-encode the generated request object or read the raw body, whichever the merged strict server allows) and the gateway passes the raw memberagent;domain.DecodeHeartbeatis the only mapping from the wire form. The presence state lives in a process-wide tracker inagents/app, started only in the rolesapiandall, because only they receive heartbeats (02 3.2).agentspublishes throughkernel/eventlog.EphemeralPublisher(02 4.5; 02 11.6: "directly to the hub") and reaches project readiness only through thechatport. Ruling for docs/DECISIONS.md. - Use case
Heartbeat, in this order (the rate limitrl.agent_runtimeruns before, in the chain or the gateway, 05 2.5): a.authorize(actor, agent.heartbeat, {kind: agent, id: actor, owner: actor}). People and apps getforbiddenwith reasonsubject_kind_not_allowedfromAgentSelf(M1-28); the denial is audited byauthz(R-AZ-04). The body carries no agent id, so an agent can only report for itself. b.DecodeHeartbeatand validation (note 3); every violation returns oneerrsvalidation error with all field errors. c. Take the tracker entry's lock; readGetPresenceRow(desiredbilling_modeandtool_class, storedreported_billing_mode,reported_tool_class,reported_runtime_state,last_heartbeat_at) on every heartbeat, so a desired value changed in another process counts at once; create the entry if missing (handle from the identityDirectory). d. Project readiness (note 11). e. Change of the actual mode or class (note 4). f. Update the entry: report,last heartbeat = now, state from the report, dirty unless step e wrote it; reassess (note 8); re-arm the deadline (note 5). g. After any commit of step e: publishagent.presence_changedif the pair changed (note 5). h. Answer200 HeartbeatAck {presence: AgentRuntimeStatus of the entry, interval_ms: sync.heartbeat_interval}. - Decoding and forms of
agent_heartbeat.v1(agents/domain, 09 4.8; the openapi schema stays the contract of the top-level members):- Top-level members as
Heartbeat:state(onlineordegraded),runtime_version,reported_billing_mode,billing_modes_supported,billing_evidence,reported_tool_class,rate_limit,details,project_readiness. An unknown member is a validation error (06 2 row Unknown values). detailsis a closed object:capabilities(object; keys of 09 2.2:followupswithsteerorqueue,components,voice_input,title,files_outas booleans),active_runs(run ids, UUIDs after R-DM-01 checked withkernel/ids),degraded_reasons(codes),adapter(namerequired,versionoptional),runtime(namerequired),protocol_version(integer, at least 1). Any other key, also insidecapabilities,adapterandruntime, is an unknown member.runtime_versionis the runtime's version and fillsreported_runtime_version;details.adapter.versionis the adapter's version (18 9 point 5 is aligned to this). Ruling for docs/DECISIONS.md.- Forms: names (
details.adapter.name,details.runtime.name)^[a-z0-9][a-z0-9._-]*$; versions (runtime_version,details.adapter.version)^[0-9A-Za-z][0-9A-Za-z.+_-]*$; codes (billing_evidence.provider,billing_evidence.method, each entry ofdegraded_reasons)^[a-z0-9][a-z0-9_.:-]*$, the code form of SEC-AUD-5, somethodenters the audit details unchanged. No duplicates inbilling_modes_supported,active_runs,degraded_reasonsand among thechannel_idvalues ofproject_readiness(pointer/project_readiness, as M2-03 expects). Lengths are bounded byhttp.max_json_body_bytes. Reason: 09 4.8 forbids secrets, contents and paths, and these forms make path separators, whitespace, control and bidi characters and most key formats unrepresentable. Ruling for docs/DECISIONS.md. rate_limitpresent: field errornot_enabledat/rate_limit(06 5 point 3) until M6-14 builds the field.- Field error codes and pointers follow what M1-18 uses for the same kind of violation; REST answers
400 validation_failedwitherrors[]. The domain checks every rule of this note itself, also those the chain or the gateway decoder already enforce from the openapi patterns (SEC-IN-1), so both transports reject the same bodies.
- Top-level members as
- Full state and change of the actual mode or class (09 4.7 "last one wins"; 10 8; 03
agents):- Every heartbeat replaces the whole reported state; an absent optional member means "not reported":
reported_billing_modeNULL (D-L8),reported_tool_classNULL, key absent inreported_details. Exception:project_readiness(note 11). Ruling for docs/DECISIONS.md. - A heartbeat whose
reported_billing_modeorreported_tool_classdiffers from the stored value (also from or to "not reported", also the first report ever) is a change: one unit of work runsLockPresenceRow, compares again, and if still different runsWriteReportedChange(both columns,reported_runtime_state,reported_runtime_version,reported_details,last_heartbeat_at = now()) and records throughauditing.Recorderin the same transaction: actionagent.heartbeat, decisionallow, actor the agent, resource{kind: agent, id}, details onlybilling_mode_old,billing_mode_new,tool_class_old,tool_class_new(for the changed column, each only when the value exists),key_typeandmethodfrombilling_evidencewhen present. One entry per heartbeat, also when both columns change. Equal after the lock: nothing written, no audit (another process stored it). Ruling for docs/DECISIONS.md. - Only this path writes
reported_billing_modeandreported_tool_class; the flush of note 6 never does, so neither column changes without its audit entry. - Nothing else happens: no
agents.versionchange (it is the ETag of the record, and owners'If-Matchwrites must not fail on heartbeats), noagent.updated, nopermission_version, noaccess.changed, noevent_logrow (05 4.10; 02 11.7).authorizereads agent policy facts fresh on every call (M1-28), so a newreported_tool_classcounts for SR2 from the commit on and can only raise the effective class.
- Every heartbeat replaces the whole reported state; an absent optional member means "not reported":
- Presence state, deadline and event (04 12 item 2):
- States:
unknown(no heartbeat ever, the column default),onlineordegraded(thestateof the latest heartbeat),offlineoncenow >= last heartbeat + agent.presence_timeoutwithout a newer heartbeat. Ruling for docs/DECISIONS.md: the boundary is inclusive. - Each entry has one timer of
kernel/clockat its deadline; every heartbeat re-arms it. When it fires, under the entry lock and only if no newer heartbeat arrived: stateoffline, entry dirty, mismatch start times cleared (note 8), then publish. agent.presence_changedis published exactly whenstateorreported_billing_modediffers from the last published pair of the entry (initially the stored values). Envelope throughEphemeralPublisher.PublishEphemeral: typeagent.presence_changed,schema_versionfrom the event register, actor{id: agent, kind: agent}for a heartbeat and none for the timer, empty scope, resource{kind: agent, id}withoutversion, no audience, payloadAgentPresencePayload {agent_id, state, reported_billing_mode}; the last reported mode stays in the payload whileoffline(03: "last reported mode") and is omitted when not reported. The hub admits candidates with receive actionagent.readinauthorize.Delivery(ctx)(M1-65 candidates of an event with empty scope: all connections; M1-66 ephemeral rules: never beforecatchup.complete, dropped at a full queue, never4014). A publish error is logged with the agent id and never fails the heartbeat. Ruling for docs/DECISIONS.md: the trigger pair and the mode while offline.
- States:
- Persistence (03
agents: at most once peragent.heartbeat_persist_interval):- A flush loop of the tracker runs every
agent.heartbeat_persist_intervaland writes each dirty entry, ascending agent id, in one unit of work per run, withPersistHeartbeat:UPDATE agents SET reported_runtime_state = $state, reported_runtime_version = $version, reported_details = $details, last_heartbeat_at = $hb WHERE principal_id = $id AND (last_heartbeat_at IS NULL OR last_heartbeat_at <= $hb). No row updated means a fresher heartbeat is stored already; the entry becomes clean either way. The offline write is the same statement with the entry's last heartbeat time, so it never overwrites a newer heartbeat stored by another process. Ruling for docs/DECISIONS.md. reported_detailsholds exactlycapabilities,billing_modes_supported,billing_evidence,active_runs(array of run id strings),degraded_reasons,adapter,runtime,protocol_version, absent members omitted, nevernull;rate_limitstays out until M6-14; project readiness is stored only inproject_bindings.ready(M2-03).- These statements take no counter, no
NOTIFYand no audit (02 11.7). - Shutdown: a stop step
agent_presenceinlifecycle.PhaseConnections, registered after the gateway's step so no gateway heartbeat arrives later, stops the timers without publishing and flushes every dirty entry within the rest ofshutdown.total_timeout; a server restart is not an agent going offline. Ruling for docs/DECISIONS.md; add the sentence to 02 15.3.
- A flush loop of the tracker runs every
- Start (
lifecycle.Componentof the tracker, rolesapiandall):ListLivePresencereturns agents stored asonlineordegraded; keep those whose principal status isactive(identityDirectory, which also gives the handle). Iflast_heartbeat_atis missing or older thanagent.presence_timeoutplusagent.heartbeat_persist_interval, the agent isofflineat once (the stored value lags the real last heartbeat by at most the persist interval, so no heartbeat came withinagent.presence_timeout); otherwise it is tracked with the deadline start plusagent.presence_timeout, which gives adapters time to reconnect after4013. Agents not loaded get an entry with their first heartbeat (note 2c). Ruling for docs/DECISIONS.md. - Desired versus actual (10 7 and 8; 09 2.4 and 4.8), computed by
domain.Assessfrom the desired values of note 2c or note 13, the last report and the state:billing_mode_matchesstays as M1-51 builds it from the reported mode.- Difference while the state is
onlineordegraded: (a)reported_billing_modepresent and unequal tobilling_mode; (b)reported_billing_modeabsent,billing_modes_supportedreported andbilling_modenot in it; (c)billing_evidence.key_type = unknown(10 7: an undeterminable mode counts as unequal to every desired mode). The tracker keeps the time a difference of any kind began and the time (a) began, and clears them when the condition ends or the state isofflineorunknown. AgentRuntimeStatus.warnings, only whileonlineordegraded:billing_mode_mismatchwhen (a) has held longer thanbilling.mismatch_alert_after(10 8 row 1 names the duration);billing_mode_unsupportedwhen (b) holds (row 2);billing_evidence_unknownwhen (c) holds (row 3);tool_class_mismatchwhenreported_tool_classis present and unequal totool_class(09 2.4). A warning disappears with the heartbeat that ends its cause (10 9.2 point 5). Ruling for docs/DECISIONS.md: codes, the delay only for (a), no warnings while not present.- The server takes no action on runs: the binding checks are in the adapter (I3) and in
run.start(BR-06, M2-09).
- Metrics and alarm (signals.md; alerting.md; 17 rule 4):
bothy.agent.heartbeat.age(catalogue row, Gauge,s,bothy.agent.handle): for every active tracked agent with a known last heartbeat, now minus that heartbeat.- New catalogue row
bothy.agent.billing_mode.mismatch_age(Gauge,s, attributebothy.agent.handle, Source "api: seconds since the start of a mismatch between configured and reported mode according to the heartbeat (10, 8); 0 without a mismatch", Prometheusbothy_agent_billing_mode_mismatch_age_seconds): for every active tracked agent, now minus the start of a difference of any kind of note 8, else 0. Ruling for docs/DECISIONS.md (the catalogue has no signal for the heartbeat difference; spec gap). - Both are observable gauges that read only tracker memory, in the roles
apiandall; the values ofbothy.agent.handlecome from the tracked agents, a set the owner limits (signals.md 2 item 1); disabled agents have no series. Declare them asSpecs ininternal/platform/otel/metricswith their attribute set if M1-68 is merged; otherwise ininternal/agents/adapters/otelmetricsover the meter of M1-10 with the vendor entry inarchitecture/modules.yaml, and name in the report that the registry takes them over, as M2-01 does. - Rule
BothyBillingModeMismatchinbothy-alerts.yaml: keep the existing counter expression and addor max by (bothy_agent_handle) (bothy_agent_billing_mode_mismatch_age_seconds{job="bothy/bothy-server"}) >followed by the default ofbilling.mismatch_alert_afterin seconds, as the last comparison of theexpr, because thealert-thresholdscheck of M1-04 reads the final comparison. Severitypagestays. alerting.md row: condition "reported mode differs from the configured mode, according to the heartbeat for longer than the threshold or at a run (I3)", configuration value`billing.mismatch_alert_after`. - Dashboard panel:
max by (bothy_agent_handle) (bothy_agent_billing_mode_mismatch_age_seconds{job="bothy/bothy-server",deployment_environment_name=~"$env",bothy_agent_handle=~"$agent"}). - Recording never blocks and never changes a heartbeat's result.
- Gateway (
internal/gateway/app/heartbeat.go; 04 6.2; 05 7 rowheartbeat):- The op keeps action
gateway.connectand the liveness rules of M1-64 first (heartbeat_ack, reset of the4009deadline). Then, if memberagentis present: rate limitrl.agent_runtimekeyed by the principal through the gateway's rate limiter port (as M2-07 does forack), thenagents.Presence.Heartbeatwith the raw member in the session's actor context. - Errors of the agent part answer
error {op: "heartbeat", code}and keep the connection:forbidden(people and apps),validation_failed,rate_limited,internal_error. Violations of the frame schema found by the decoder of M1-64 still close with4002. - A heartbeat of an agent without member
agentkeeps the connection alive and changes no presence: presence needs the state report, and 04 12 item 2 "implicitly through the gatewayheartbeat" refers to this member. Ruling for docs/DECISIONS.md.
- The op keeps action
- Project readiness (openapi
Heartbeat.project_readiness; M2-03 note 7). Only listed channels are applied; when the member is absent or the list equals the last list this process applied for the agent, nothing is called; otherwiseApplyProjectReadiness(ctx, agentID, reports)of thechatport runs in its own unit of work before the transaction of note 4, so no transaction holds rows of two modules (03 6.3). Duplicates are rejected by note 3 already; any other error of the port fails the heartbeat with500and changes no presence. M2-03 applies only reports of the effective operator and ignores others without error; this ticket adds no rule. Ruling for docs/DECISIONS.md: change-only call, separate transaction. - Snapshot and agent card.
Presence.Statusesanswers tracked agents from memory and other agents from their row (unknownorofflineas stored, with the stored mode, version and details); it does not authorize: callers pass only agents the actor may read (sync in filter modeagent.read, M1-63;getAgentandlistAgentsafter theirauthorize).Snapshot.agentsgetsAgentPresence {agent_id, state, reported_billing_mode};getAgent,listAgentsandHeartbeatAck.presencegetAgentRuntimeStatuswithbilling_evidenceandwarnings. Only principals of kind agent ever have presence (R-PR-02). D-G-280 ends for presence;active_runsstays with M2-05. - Desired values changed. After the commit of
SetBillingMode,SetBillingModeAll,SetToolClass,DisableandEnable(M1-51, same module), the use case calls the tracker with the affected agent ids; the tracker readsGetTargetsfor tracked ones and reassesses note 8.Disablemarks the entry inactive (no metric series; the deadline keeps running, so the agent becomesofflineafteragent.presence_timeout, its tokens being revoked);Enablemakes it active again. No event: M1-51 already writesagent.updated. Another process learns the new values with the next heartbeat (note 2c). - Errors (06 5; codes only from
x-problem-codes):
| Situation | REST sendHeartbeat |
Gateway, member agent |
|---|---|---|
| caller is not an agent | 403 forbidden, reason: subject_kind_not_allowed |
error with forbidden; liveness counted |
body or frame breaks its schema in docs/api/openapi.yaml (unknown top-level member, enum, pattern; SEC-IN-1) |
400 validation_failed (chain of M1-18) |
what the decoder of M1-64 does for a schema violation (close 4002) |
rule of note 3 that the schema does not express (details keys and forms, duplicates, protocol_version) |
400 validation_failed with pointers |
error with validation_failed |
rate_limit present |
400 validation_failed, errors[].code not_enabled, pointer /rate_limit |
error with validation_failed |
class rl.agent_runtime exhausted |
429 rate_limited with Retry-After |
error with rate_limited |
| database or readiness port failure | 500 internal_error, presence unchanged |
error with internal_error |
- Concurrency. One lock per tracker entry serializes heartbeats, the timer and target updates of one agent in a process; the flush loop takes each entry's lock only to copy its state. Across processes the row lock of note 4 lets exactly one process audit a given change, and the monotone write of note 6 keeps the newest heartbeat. A transaction of this ticket locks at most one
agentsrow and no row of another module (03 6.3). - Configuration keys used:
agent.presence_timeout,agent.heartbeat_persist_interval,billing.mismatch_alert_after,sync.heartbeat_interval,shutdown.total_timeout,http.max_json_body_bytes,ratelimit.rl.agent_runtime.per_minute,ratelimit.rl.agent_runtime.burst. Register the first three ininternal/wire/configkeys_agents.goif the base lacks them (kind duration, default from their chapter tables, chapters 04, 03 and 10; add the constantschapter04andchapter10toconfigchapters.goif missing). Cross-checkpresence_timeout_above_heartbeat_intervalinconfigchecks.go:agent.presence_timeoutmust be greater thansync.heartbeat_interval, otherwise the start fails with a message naming both keys, because an adapter that followsinterval_mswould otherwise flap tooffline.HeartbeatAck.interval_msissync.heartbeat_interval, the interval the gateway announces inhello. Ruling for docs/DECISIONS.md. - Rulings for
docs/DECISIONS.mdsection 10 (one row each with date, decision, reason and place):D-M2-20-1one decoder and use case for both transports, tracker per process (note 1);D-M2-20-2closed key set and forms ofagent_heartbeat.v1,runtime_versionversusdetails.adapter.version(note 3);D-M2-20-3full-state semantics, the first report and changes from or to "not reported" are audited changes, one entry with fixed detail keys, only the change path writes the audited columns (note 4);D-M2-20-4inclusive offline boundary, event on a change of state or reported mode, mode kept while offline (note 5);D-M2-20-5monotone flush and shutdown flush without event (note 6);D-M2-20-6start rule (note 7);D-M2-20-7difference kinds, warning codes, delay only for (a), nothing while not present (note 8);D-M2-20-8gaugebothy.agent.billing_mode.mismatch_ageand the extendedBothyBillingModeMismatch(note 9);D-M2-20-9gateway order and no presence without memberagent(note 10);D-M2-20-10readiness change-only in its own transaction (note 11);D-M2-20-11interval_msand the cross-check (note 16);D-M2-20-12the membersbilling_evidenceandwarningsofAgentRuntimeStatus. The offline rule, the persist interval, the immediate audited change, the receive action and the payload are in 03, 04 and 05 and need no ruling.
Tests (write them first)¶
Unit (go test, test clock of kernel/clock, fakes for the ports):
internal/agents/domain:TestDecodeHeartbeat: table with the minimal body{state: online}, a full body,state: offline(rejected), an unknown top-level member, an unknown key indetails,capabilitiesandadapter,followups: always(rejected), a non-UUID and a duplicate inactive_runs,protocol_version: 0, duplicates inbilling_modes_supported,degraded_reasonsandproject_readiness(pointer/project_readiness),rate_limit(codenot_enabled, pointer/rate_limit); each rejection names its pointer.TestHeartbeatForms: names, versions and codes with/,\, whitespace, uppercase letters in codes,=, a control and a bidi character are rejected at their pointers;resolved_credentials,api_key,2.3.1,1.0.0+build.7are accepted.TestReportedChange: from not reported toorganization,organizationtopersonal,personalto not reported, classchattohost, both at once (one detail set), equal values (no change); detail keys exactly as note 4, absent values omitted, nevernull.TestStateAtBoundary: just beforelast + agent.presence_timeoutthe state is the reported one, at and after itoffline; no heartbeat givesunknown.TestStartRule: storedonlinewithlast_heartbeat_atolder thanagent.presence_timeoutplusagent.heartbeat_persist_intervalis offline at once; younger gets the deadline start plusagent.presence_timeout; missinglast_heartbeat_atis offline at once.TestAssess: one case per difference kind (a), (b), (c) and per warning code;billing_mode_mismatchabsent exactly atbilling.mismatch_alert_afterand present after it; no warnings and no difference whileofflineorunknown;tool_class_mismatchin both directions.FuzzDecodeHeartbeat: arbitrary bytes never panic, and an accepted report never contains/,\or whitespace in a name, version or code. Its seed corpus runs in everygo test; run it once locally withgo test -run=^$ -fuzz=FuzzDecodeHeartbeat -fuzztime=<duration of your choice>and report duration and result; commit any crashing input as a seed after the fix.
internal/agents/app(fake repository, authorizer, publisher, readiness port, recorder):TestHeartbeat_PublishesOnlyOnStateOrModeChange: same state and mode twice publishes once;degradedpublishes; a mode change with the same state publishes; a change ofdegraded_reasonsonly does not.TestHeartbeat_OfflineTimer: without a heartbeat the timer setsofflineand publishes without actor; a heartbeat just before the deadline re-arms it.TestHeartbeat_PersistsAtMostOncePerInterval: many heartbeats inside oneagent.heartbeat_persist_intervalcause onePersistHeartbeatper agent; a mode change causes oneWriteReportedChangeat once and one recorder call.TestHeartbeat_ChangeEqualAfterLockWritesNothing: the locked row already carries the new values: no write, no audit.TestHeartbeat_ReadinessOnlyOnChange: the same list twice calls the port once; a changed list calls it again; an absent member never.TestHeartbeat_ShutdownFlushPublishesNothing: the stop step writes every dirty entry and publishes no event.TestTargetsChanged_Reassesses: afterSetBillingModetopersonalthe tracked entry reports difference (a) and its start time.TestPresenceGauges: heartbeat age and mismatch age per active agent; a disabled agent has no series.
internal/gateway/app:TestHeartbeatAgentMember_CallsPresence,TestHeartbeatAgentMember_ErrorKeepsConnection(theheartbeat_ackis still sent),TestHeartbeatWithoutAgentMember_NoPresence.internal/wire:TestConfig_PresenceTimeoutAboveHeartbeatInterval(equal values abort the start with both key names).
Integration (PostgreSQL 18 via pgtest, the in-process server and the wstest client as in M1-65 and M2-07, HTTP through the real chain; Owner Alex with step-up creates the agent support-agent through M1-51 and issues its API token with a DPoP key from identitytest; support-agent operates no channel; Sam has read on channel Allgemein of space Support and therefore no agent.read on support-agent; Alex, Sam and the agent hold gateway connections in phase live):
TestBR05_ServerPart_ActualDiffersFromDesired(BR-05, server part; desiredorganization): a heartbeat withorganizationandkey_type: api_keygivesbilling_mode_matches: true, no warnings and mismatch age 0; a heartbeat withpersonalandkey_type: oauthstoresreported_billing_mode = personalat once (read beforeagent.heartbeat_persist_intervalpasses), writes one audit entryagent.heartbeatallowwithbilling_mode_old: organization,billing_mode_new: personal,key_type,method, sends Alexagent.presence_changedwithreported_billing_mode: personal;getAgentshowsbilling_mode_matches: falsewithout warning beforebilling.mismatch_alert_afterand withbilling_mode_mismatchafter it; a heartbeat back toorganizationends the warning and writes a second audit entry.TestBR05_ServerPart_UnknownEvidence(BR-05,unknown): a heartbeat withoutreported_billing_modeand withkey_type: unknowngives the warningbilling_evidence_unknownat once and a growing mismatch age; nothing grants or denies anything else (a followingauthorizeofagent.readby Alex is unchanged).TestBR05_ServerPart_ChangeDuringRun(BR-05, change during a run): withdetails.active_runsnaming a run id, a mode change is stored and audited at once;reported_details.active_runsis stored with the next flush as an array of id strings; no run, message or event other thanagent.presence_changedis written by the server.TestBR05_ServerPart_DesiredNotSupported: Alex, the only owner, setssupport-agenttopersonalwith Alex as credential holder (setAgentBillingMode; I1 holds because nobody else hastrigger); a heartbeat withoutreported_billing_modeand withbilling_modes_supported: [organization]givesbilling_mode_unsupported; with both modes the warning ends.TestPresenceOfflineAfterTimeout(plan: "offlineafteragent.presence_timeout"): heartbeats every short interval keeponline; after they stop, Alex's connection receivesagent.presence_changed {state: offline}once the timeout has passed and not before;getAgent,getSnapshotand a newreadyshowoffline; the row hasreported_runtime_state = offlineafter the next flush; the heartbeat age gauge keeps growing.TestHeartbeatAuditOnActualChange(plan: "audit on a change of the reported mode"): exactly one audit entry per change of mode or class, none for a repeated value; the same changing heartbeat sent to two server instances on one database at the same time yields exactly one audit entry.TestBillingModeMismatchAlarm(plan: "alert afterbilling.mismatch_alert_aftervisible in the local stack"): with a shortbilling.mismatch_alert_afterthe in-process reader showsbothy.agent.billing_mode.mismatch_ageforsupport-agentabove that value after a lasting difference and 0 after the difference ends; together with the rule tests below this proves the chain metric, rule andpageroute.TestPresenceInSnapshot(plan: "presence in the snapshot"): after a heartbeat,getSnapshotand a newreadyof Alex listsupport-agentwithstate: onlineandreported_billing_mode; Sam's snapshot does not list it (noagent.read); no human principal appears inagents; the agent's ownreadylists itself.TestPresenceChangedDelivery: Alex and the agent's own connection receive the event, Sam's does not; it has noseqand nopermission_version; a connection still catching up does not receive it; the frame decodes intoAgentPresenceChangedEventwith unknown members rejected and contains nonull.TestSendHeartbeatRightsAndRoute(AZ-P1, AZ-P2, AZ-P13): the route is registered withagent.heartbeatandrl.agent_runtime,agent.presence_changedis a registered ephemeral type; Sam's token and an app token get403 forbiddenwithreason: subject_kind_not_allowed; the denials stand aggregated in the audit log; delivering presence events writes no audit entry.TestGatewayHeartbeatEqualsRest: the same bodies throughsendHeartbeatand through the gateway memberagentlead to equal stored state, audit and events; an invaliddetailskey over the gateway answerserrorwithvalidation_failed, keeps the connection and still sendsheartbeat_ack; Sam's gateway heartbeat with memberagentanswerserrorwithforbidden; an agent heartbeat without the member leaves presence unchanged.TestSendHeartbeatValidationLeaksNothing(SEC-IN-1; defence for BR-13): bodies carrying a canary string shaped like base64 key material (uppercase letters,/,=) inbilling_evidence.method,runtime_version,details.adapter.nameand adegraded_reasonsentry are rejected with400; the canary appears in no log record, audit row,event_logrow or stored column.TestReportedToolClassImmediateAndOnlyRaises(07 2.11; red team "faking the reported mode"): a heartbeat withreported_tool_class: hostwhile the desired class ischatmakesAgentPolicySource.AgentPolicyreturn the new reported class at once without a change ofpermission_version; with desiredhost(setAgentToolClass) a heartbeat reportingchatleaves the effective classhost.TestPresenceStartRule: a second server instance started on a database wheresupport-agentis storedonlinewith a freshlast_heartbeat_atkeeps itonlineuntilagent.presence_timeoutafter its start, thenofflinewithout a heartbeat; with an oldlast_heartbeat_atit isofflineat once.TestPresenceFlushMonotoneAcrossInstances: instance A goesofflinefor the agent while instance B received and stored a newer heartbeat; A's flush does not overwrite B's row.TestPresenceShutdownFlush: a heartbeat right before shutdown is stored, connections close with4013, and noagent.presence_changedis sent during shutdown.TestHeartbeatProjectReadiness(world of M2-03: forum Projekt-A bound with the agent Runner as effective operator): a heartbeat of Runner withproject_readiness: [{channel_id: Projekt-A, ready: true}]setsproject_bindings.ready; repeating it does not call the port again;ready: falseresets it; a duplicate channel gives400with pointer/project_readiness.
Alarm (Prometheus rule unit tests in infra/observability/prometheus/tests/bothy-alerts.test.yaml, run by the operations checks of M1-04 inside make check; see Scope if they are missing):
BothyBillingModeMismatchfires with labelbothy_agent_handleand the rendered summary for a gauge series above the default ofbilling.mismatch_alert_after; it does not fire for a series exactly at it; the existing counter case still fires. Thealert-thresholdscheck finds the final comparison equal to the default ofbilling.mismatch_alert_after, and the routing check sendspagetoexternal-oncallandbothy-ops.
Contract and others: every response and frame of the tests above decodes into its generated type with unknown members rejected. If the contract test of M1-71 is on the base, it covers the newly enabled sendHeartbeat. If services/server/test/canary exists (M1-69), its test TestSEC_T10_ScenarioCoversEveryEnabledOperation fails for sendHeartbeat: add a heartbeat with a canary value in runtime_version to the scenario; allowed sinks are the HeartbeatAck, getAgent and listAgents answers and the column reported_runtime_version; logs, traces, metrics, events and audit stay free of it. No platform-specific code: the tests run on Linux in CI and locally on any system with a Docker-compatible runtime.
Test IDs of the plan row and related: BR-05 (server part): an actual mode unequal to the desired one, an unknown mode and a change during a run are stored, audited, shown as warnings and alarmed, and the heartbeat never lets a run start (the start checks are the adapter's and BR-06 of M2-09). AZ-P1: the new route names agent.heartbeat. AZ-P2: the ephemeral type and the op stay registered. AZ-P5: the snapshot lists presence only for agents the principal may read. AZ-P13: denials are audited, delivery filtering writes no audit.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/agents/... ./internal/gateway/... ./internal/sync/... ./internal/chat/... ./internal/authz/... ./internal/kernel/... ./internal/platform/otel/... ./internal/wire/...make codegen-check,make arch,make register-check,make api-lint,make api-tables,make api-checks,make config-doc-check,make docsmake check
Acceptance criteria¶
-
sendHeartbeatis enabled and registered withagent.heartbeatandrl.agent_runtime; the gateway opheartbeatprocesses memberagentwith the same effect; AZ-P1, AZ-P2 and AZ-P13 pass. - BR-05 (server part): an actual mode unequal to the desired one, an
unknownmode, a desired mode outside the provable modes and a change during a run are stored, audited and shown as the warnings of 10 "Expected and reported"; no heartbeat starts, ends or allows anything. - An agent becomes
offlineonceagent.presence_timeouthas passed without a heartbeat, andagent.presence_changedreaches exactly the principals withagent.read, never beforecatchup.complete. - Every change of
reported_billing_modeorreported_tool_classis stored at once and audited once with old value, new value andmethod; other fields are stored at most once peragent.heartbeat_persist_intervaland at shutdown. -
bothy.agent.billing_mode.mismatch_ageexceedsbilling.mismatch_alert_afterfor a lasting difference, andBothyBillingModeMismatchfires on it in the rule tests with routepage. -
getSnapshot,ready,getAgentandlistAgentsshow the current presence; people never have presence. - A reported tool class counts for SR2 at once and can only raise the effective class.
-
docs/api/openapi.yaml,docs/api/asyncapi.yaml, chapters 02, 09, 10 and 18, signals.md, alerting.md, dashboards.md, the READMEs anddocs/DECISIONS.mddescribe what was built. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
agents,gatewayandsyncupdated (portPresence, tracker, state rules, persistence, start and shutdown, events, metrics, the agent part of the opheartbeat, presence in the snapshot). - Rulings of Design note 17 recorded in
docs/DECISIONS.md(one row each with reason); row D-G-280 names the row that ends its presence part. services/server/CHANGELOG.mdentry under 'Unreleased'.- If
docs/security/threat-model.md5.10 lacks the threat "Adapter reports a false reported mode", add the row with its countermeasures (heartbeat evidence only for display, warning, audit and alarm; I3 in the adapter; BR-06 atrun.start; the reported tool class only raises the effective class) and register T-AI-08. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example the timing margins of the timer and flush tests, presence with
cluster.mode = multibeyond the two-instance tests, the alarm in a running local stack beyond the rule tests and the in-process metric reader). - Follow-ups that could not be done in this scope, each with its reason (at least the fallbacks taken for M1-68 or M1-04, if any), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.