# Configuring heartd

`heartd` reads one TOML file. Relative paths are resolved against that file. `examples/pump-station/heartd.toml` is a minimal example, and unknown keys are rejected.

## Sections

| Section | Key | Default | Meaning |
|---|---|---|---|
| `system` | `card` | required | HEART Card, as JSON |
| | `instance` | required | Non-identifying instance label |
| | `custom_dir` | none | Directory of custom definitions |
| | `rules` | none | JSON array of Tending rules |
| `server` | `bind` | `127.0.0.1` | Address. Anything but loopback needs TLS |
| | `port`, `fallback_port` | `43278`, `7685` | Standard and fallback |
| | `tls_cert`, `tls_key` | none | PEM files |
| | `unix_socket` | none | Also listen on this socket |
| | `max_connections` | `32` | Further connections are dropped |
| | `rate_per_sec` | `50` | Per caller, with a burst of twice this |
| | `max_body_bytes` | `262144` | Larger bodies get `413` |
| | `host_socket` | none | Socket for the product's own events, mode 0600 |
| | `trust_gateway_headers` | `false` | Trust a Gateway's caller headers on `unix_socket`, from `gateway_uid` only |
| | `gateway_uid` | none | The Gateway's operating-system user. Required with `trust_gateway_headers` |
| | `gateway_public_key` | none | Base64 of the Gateway's Ed25519 key: the key this system pins. Trusts its **signed** caller headers over TLS, its fleet approvals (presence method `fleet`, API Standard 13.3) and the commands it signs on the command channel. The Gateway prints it at startup as `publicKey` |
| `auth` | `issuer` | none | Required `iss` claim |
| | `public_key` | none | Base64 of the raw Ed25519 public key |
| | `owner_key_file` | none | The Basic credential profile (API Standard 8.2), only for a Card whose `level` is `basic`. At first start heartd makes an owner key (256 random bits), prints it once on standard error as `{"heart-owner-key": ...}`, and keeps only its SHA-256 here, mode 0600. `Authorization: Bearer <owner key>` is then the owner: `human`, `private`, the scopes of the `owner` role. `heartd owner-key <config>` makes a new key and prints it; the old one stops at once. A release, transfer or factory reset makes a new one too. The key is never sent anywhere |
| `storage` | `state_file` | required | Written atomically, mode 0600 |
| `identity` | `key_file` | `identity.key` beside the state file | The system's Ed25519 identity key, created on first start, mode 0600. Signs the Card |
| `presence` | `rp_id` | none | WebAuthn relying party ID, shown as `card.presence.rpId`. With `[presence]`, the Card offers module `presence`. Without it, nothing can be approved |
| | `origins` | none | Origins of the approval page |
| | `[[presence.credentials]]` | none | `principal`, `id`, `alg` (`ES256` or `EdDSA`) and `public_key` (base64url) of each approver's passkey. A seed: they are listed by `GET /heart/v1/presence/methods` with `source: "config"`, beside passkeys enrolled with `POST /heart/v1/presence/methods`, which the state file keeps |
| `core` | `log_capacity`, `store_capacity` | `4096`, `512` | Hard limits |
| | `escalate_after_sec`, `escalate_to` | `900`, none | Escalation |
| | `daily_budget_usd`, `max_call_usd` | `1.0`, `0.05` | AI spending limits |
| | `allowlist` | empty | `write` actions that may run unapproved: entries `{target, initiators, envelope}` as in `heart.control` (Core Standard 7.4). A bare string is an entry for `rule` only |
| | `autonomy_max` | `5` | The starting autonomy ceiling, 0 to 5, as set by `PUT /heart/v1/tending/autonomy`. The mode follows from the current level: 0–2 `observe`, 3 `suggest`, 4–5 `act` |
| | `autonomy_level` | `4` | The promised Autonomy Level. A brain's Proposal uses the allowlist only at 5 |
| | `approval_timeout_sec` | `86400` | How long an Action waits for approval (at least 60) |
| | `fleet_physical` | `false` | Accept a fleet approval from the pinned Gateway for a `physical` Action too, when the fleet command was approved as physical. Shown as `fleetPhysical` in `heart.control`. Set it on the device itself |
| | `max_workers` | `8` | Background effects at once |
| `model` | `gateway_url` | none | Reasoning Gateway. Omit for no model |
| | `token_env` | none | Environment variable holding its token |
| | `tier`, `timeout_ms` | `cloud`, `20000` | |
| | `pins` | empty | `[{purpose, provider, model, pinnedAt}]` |
| `webhooks` | `signing_key_file` | none | Base64 of a 32-byte Ed25519 seed |
| | `extra_ca` | none | Extra PEM certificate to trust |
| | `allow_private_sinks` | `false` | Allow sinks on private addresses |
| `supervisor` | `heartbeat_file` | none | Needed by `heartd supervise` |
| | `hung_after_sec` | `15` | Restart when the heartbeat is older |
| | `max_restarts`, `window_sec` | `5`, `300` | Then give up and leave the Core off |

| `uplink` | `gateway_url` | none | Join this Gateway |
| | `token_file` | required | Token used to join, with scope `heart.send` |
| | `extra_ca` | none | Extra certificate to trust for the Gateway |
| | `push_clearance` | `internal` | Highest level sent to the Gateway |
| | `every_sec` | `30` | How often to announce again |
| | `commands` | `false` | Keep a command channel (API Standard 9.3): the Card is pushed without endpoints, as for a device behind NAT, and heartd long-polls the Gateway's inbox with `Prefer: wait`. Each command must be signed with `[server] gateway_public_key`, be for this system, be at most 60 seconds old and new; anything else is dropped unanswered and recorded as `security.command.refused`. A command runs through the API and the gate as a request from the Gateway would, and its answer goes back as a `heart.reply`. Needs `gateway_public_key` |
| | `poll_wait_sec` | `25` (1 to 30) | How long one long poll waits for a command |

### Optional modules served by the Core

Each of these sections offers one module of API Standard 13.1, and adds it to the Card's `modules`. Without the section the module's operations answer `501`. Every store is bounded; the ceiling is given after the default.

| Section | Key | Default | Meaning |
|---|---|---|---|
| `inventory` | `[[inventory.components]]` | empty | The parts: `id`, `kind` (as in `component.schema.json`), and optionally `name`, `parent`, `version`, `vendor`, `depends_on`. `GET /components` lists parents first. A part's `status` comes from its open alerts |
| | `[[inventory.dependencies]]` | empty | Outside services and systems, the same fields, and `probe = "host:port"`: heartd connects to it, and a change of answer raises or resolves `connectivity.dependency-unreachable` for it |
| | `probe_every_sec`, `probe_timeout_ms` | `30`, `2000` | How often, and how long one try may take |
| `config` | `[config.settings.<name>]` | empty | Each setting: `type` (`string`, `number`, `integer` or `boolean`), and optionally `minimum`, `maximum`, `enum`, `min_length`, `max_length`, `pattern`, `default`, `title`, `description` and `privacy` (`public`, `internal` (default), `private` or `never-share`). The value in `values` takes the setting's level. A `never-share` setting is never returned and is not changed through the API; a caller changes only settings at or below their clearance. A value outside `minimum`/`maximum` answers `422 out-of-range`; anything else wrong, `400` |
| | `history` | `20` (1000) | Revisions kept. `GET /config/history` sets `gap: true` once older ones are gone, and `POST /config/rollback` to one of them answers `404` |
| | `file` | none | The current values, as JSON, written atomically (mode 0600) at start and on each change, every setting included, for the product to read |
| | `apply_command` | none | Program (no shell) run to apply a change, with `HEART_CONFIG_VALUES` (JSON) and `HEART_CONFIG_REVISION`. A non-zero exit refuses the change (`502 native-failed`) and no revision is made |
| | `timeout_sec` | `30` (600) | Longest `apply_command` may take |
| `telemetry` | `[[telemetry.metrics]]` | empty | Metrics: `name` (a vital or property), `kind` (`gauge`, `counter` or `histogram`), `unit` (UCUM), `privacy` of the points (default `internal`, or the property's own). A `never-share` metric is never kept |
| | `include_all` | true without metrics | Also keep every vital and numeric property as a metric |
| | `points_per_metric` | `1440` (100000) | Points kept per metric, oldest dropped first |
| | `sample_every_sec` | `10` | At most one point per metric in this time; a newer value replaces it |
| | `max_metrics` | `64` (1024) | Metrics kept |
| | `log_capacity` | `1000` (100000) | Log entries kept in memory. Every Rhythm event is also a log entry (`attributes.source: "heart"`); lines from the host are `source: "host"` |
| | `max_line_bytes` | `2048` (16384) | Longer messages are cut |
| | `log_file` | none | A log file to follow, like `tail -F`. ERROR, WARN, CRIT and FATAL in a line set its severity |
| | `journald_unit` | none | A systemd unit whose journal to follow, through `journalctl -o json` |
| `diagnostics` | `[diagnostics.tests.<name>]` | empty | Each self-test: `command` (program, no shell: exit 0 passes, each printed line is a finding, at most 20) or `native` (a native request: it passes if the device answers), with `description`, `disruptive` (then `write`: it passes the gate, needs an `Idempotency-Key`, and answers with its Action), `cancellable` (heartd stops the program when the Action is cancelled, or by Stop) and `timeout_sec` (`30`, at most 600). Names are lower-case letters, digits, `_`, `.` and `-`, not `bundle` |
| | `runs` | `50` (1000) | Past runs kept |
| | `bundle_max_bytes` | `262144` (16 MiB) | Largest support bundle. `POST /diagnostics/bundle` takes `{since, until, include, maxBytes}`; sections are `logs`, `rhythm`, `config`, `components`, `diagnostics`, `metrics` and `pulse`, each filtered for the caller's clearance (never above `private`). Oldest log lines and Rhythm events go first when it is too large |
| `software` | `[[software.packages]]` | heartd only | What is installed: `name`, `version`, `kind` (`firmware`, `os`, `application`, `model` or `library`). heartd lists itself too. `GET /software` shows them to `internal` callers |
| | `channel`, `boot_slot` | none | Shown in `GET /software`. A manifest for another channel is not offered |
| | `sbom_file` | none | SPDX or CycloneDX JSON (at most 4 MiB), read at start and served by `GET /software/sbom` to `internal` callers; without it, `404` |
| | `update_source` | none | A directory of `*.manifest.json` files, or an `https://` URL whose `index.json` lists them (below). Needs `update_keys` and `install_command` |
| | `update_keys` | empty | Base64url Ed25519 public keys. A manifest not signed by one of them is listed with `signed: false` and never installed (`403 signature-invalid`) |
| | `install_command` | none | Program (no shell) run with `HEART_UPDATE_FILE` (the verified payload), `HEART_UPDATE_PACKAGE`, `HEART_UPDATE_VERSION`, `HEART_UPDATE_FROM` and `HEART_UPDATE_MANIFEST`. Exit 0: installed; exit 10: installed, in effect at the next start (`awaiting-reboot`); anything else: failed, and `rollback_command` runs |
| | `rollback_command` | none | Program run with `HEART_UPDATE_PACKAGE` and `HEART_ROLLBACK_VERSION`: after a failed install, and for `POST /software/rollback`, which is offered only with it |
| | `install_timeout_sec` | `600` (86400) | Longest an install or rollback may take |
| | `updates_kept`, `max_update_bytes` | `20` (1000), 256 MiB | Update records kept; largest payload accepted |
| | `staging_dir` | `updates` beside the state file | Where payloads are fetched to, mode 0600 |
| `artifacts` | `dir` | `artifacts` beside the state file | Where files are kept, one per artifact ID, mode 0600. Files nothing refers to are removed at start |
| | `max_count` | `100` (10000) | Files kept. A full store answers `409 conflict` |
| | `max_total_bytes` | 64 MiB (4 GiB) | Bytes of all files together |
| | `max_file_bytes` | 8 MiB (256 MiB) | Largest file. Uploads are JSON with the file in base64, so `[server] max_body_bytes` must be about a third larger |
| `schedules` | `max` | `50` (1000) | Schedules kept. A single-time schedule that has run makes room for a new one |
| | `min_interval_sec` | `60` (604800) | Shortest time between two runs of one `cron` schedule. Faster answers `422 out-of-range` |
| | `runs_kept` | `10` (100) | Runs listed per schedule |
| | `grace_sec` | `300` (86400) | A run this much later than planned, because heartd was not running, is recorded as `missed` and not carried out |
| `privacy` | `consent_required` | `["model-provider"]` | Kinds of recipient (`model-provider`, `subscriber`, `peer`, `gateway`) that receive `private` data only while a consent naming that kind, or that recipient, is granted. Withdrawing the consent filters the next transfer at once |
| | `retention_days` | none (36500) | Longest Memory keeps an item. `PUT /valve/policy` can change it |
| | `consents_kept` | `200` (10000) | Consents kept. Withdrawn and expired ones make room first; with only granted ones, a new one answers `409 conflict` |
| | `erasures_kept` | `100` (10000) | Records that an erasure happened, oldest dropped first |
| | `export_max_bytes` | 1 MiB (16 MiB) | Largest export. The oldest entries of the largest part go first, and `truncated` says so |
| | `export_expires_sec` | `604800` (7776000) | With `[artifacts]`, an export is stored as an artifact of kind `export` that expires after this. A full store answers the package itself |
| | `search_logs` | `true` | An export also lists log lines (module `telemetry`) that name the person |
| `ai` | `modes` | `["org-proxy"]` | `org-proxy` and/or `byok`. With `[brain]`, only `org-proxy` |
| | `providers`, `regions` | empty (any) | Providers and regions allowed. A pin for another provider is answered from rules, and cannot be set |
| | `uses` | all | What a model may be used for: `explain` (`/ask`), `serve`, `propose`, `plan`, `chat`. Another use is answered from rules |
| | `monthly_budget_usd` | none | Model spending per calendar month (UTC). The daily budget and the most per call come from `[core]` `daily_budget_usd` and `max_call_usd` |
| | `allow_fallback` | `true` | Sent as `modelPolicy.allowFallback` |
| | `usage_days` | `90` (3660) | Days of model use listed by `GET /ai/usage` |
| `memory` | `max_items` | `500` (100000) | Items kept. When full, the oldest short-term item goes first. Each message received at `/inbox` is a short-term item at its own level (at least `internal`); how each Trial ended is a long-term item |
| | `max_summary_bytes` | `2048` (16384) | Longer summaries are cut. Secrets are removed first, as in logs |
| | `short_term_sec` | `604800` (31536000) | How long a short-term item is kept |
| `evolving` | `suite`, `regression_command` | none | The regression suite's name, and the program (no shell) that runs it on a candidate model with `HEART_REGRESSION_SUITE`, `_PURPOSE`, `_PROVIDER`, `_MODEL` and `_VERSION`. Exit 0: passed; a line `score: 0.93` gives the score. Without them no pin can change (`409 conflict`) |
| | `timeout_sec` | `300` (3600) | Longest the suite may take. heartd stops it when the Action is cancelled, or by Stop |
| | `purposes` | `explain`, `serve`, `propose`, `plan`, `chat` | Purposes a model can be pinned for |
| | `candidates_kept`, `feedback_kept` | `20` (200), `500` (10000) | Candidate models listed by `GET /evolution`; ratings kept from `POST /feedback` |
| `security` | `audit_capacity` | `1000` (100000) | Audit entries kept, oldest first to go. Each is chained to the one before by SHA-256, and `GET /audit` says `gap: true` once older ones are gone. Every request that may change something is logged, with any refused read and every denied, refused or expired Action; bodies never are |
| | `secure_boot`, `certificate_expires_at` | none | Shown in `GET /security` when given. `tls` there is true when `[server] tls_cert` is set |
| `platform` | `model`, `cpu`, `memory_bytes`, `serial`, `os` | none; `memory_bytes` from `/proc/meminfo`, `os` from `/etc/os-release` | `GET /platform`. `serial` is private |
| | `power_source`, `network_kind`, `clock_source` | none | `mains`, `battery`, `solar`, `poe` or `other`; the link kind; the time source. The product reports power, network and clock as they change on the host feed (below) |
| | `clock_synced_file` | none | A file whose existence means the clock is synchronised, such as `/run/systemd/timesync/synchronized`. Checked every minute |
| `provisioning` | `claim_code_file` | required | The claim code that came with the device (at least 16 characters), readable only by heartd. After a claim, a transfer, a release (`POST /provisioning/release`) or a factory reset heartd writes the new code there, mode 0600: it is shown nowhere else |
| | `max_attempts`, `lock_sec` | `5` (20), `3600` (60 to 86400) | Wrong codes before claims are refused (`429`), and for how long. Kept across restarts |
| | `local_only` | `false` | Take a claim only through loopback or the Unix socket (others get `403`). The code travels in the body, and before the claim the client cannot check the server, so turn this on unless the product claims by a key exchange such as SPAKE2+ first (API Standard 10.1 rule 3) |
| `peers` | `max` | `100` (10000) | Peers kept. A new one when full answers `409 conflict` |
| `access` | `max_grants` | `100` (10000) | Grants kept (module `access`, API Standard 13.2 rule 21). Each gives one principal a role: `owner`, `installer`, `maintainer`, `operator` or `viewer`. While any grant exists, every caller (token, forwarded or on the command channel) is held to its own: the scopes its token and its role both allow, the lower clearance, and `peer`, `public`, no scopes without one. Granting or widening is `destructive`, narrowing or removing `write`; the first grant gives `owner`, and the last owner grant stays. A grant's `certificate.sanUri` is kept and listed as the mutual-TLS table; heartd itself does not terminate mutual TLS |
| `batch` | `max_items` | `100` (100) | Requests in one batch. The Card's `limits.maxBatch` also bounds it |
| `native` | `protocols` | required | Protocols open to passthrough: `modbus-tcp`. `process` never is |
| | `targets` | required | Targets open to passthrough, such as `tcp:127.0.0.1:502:1`. Any other answers `404` |
| | `allow_writes` | `false` | Without it passthrough only reads, and a write answers `409 guard-failed` |
| | `[[native.protected]]` | empty | `target` and `register` never written, beside every address a custom definition marks `physical` or guards |
| | `timeout_ms`, `max_request_bytes` | `2000` (30000), `4096` (65536) | Longest time and largest request |
| `reasoning_gateway` | `ceiling` | `internal` | Serve `POST /heart/v1/reason` (API Standard 7.2). Context above this level answers `403 valve-blocked` |
| | `daily_budget_usd`, `max_call_usd` | none | Each caller's spending per UTC day, and the most one call may cost. A call that could pass either is not made (`429 budget-exceeded`) |
| | `max_output_tokens` | `1024` (32768) | Output tokens one call may ask for |
| | `[[reasoning_gateway.upstreams]]` | required | Tried in order (by price with `prefer: cost`): `name`, `provider`, `model`, `api` (`openai` for an OpenAI-compatible `/chat/completions`, or `anthropic` for `/v1/messages`), `base_url` (https; `http://127.0.0.1:` only with `allow_loopback_http`), `api_key_env` or `api_key_file`, `region`, `input_usd_per_mtok`, `output_usd_per_mtok`, `timeout_ms` (`30000`). heartd refuses to start if a named key is missing |

**Updates** follow the SUIT architecture (RFC 9019): a signed manifest describes the payload, and the Core verifies the signature, then the payload's size and SHA-256, before anything is installed. Installing is `destructive`: a person proves presence with the request, or another person approves it. A manifest file is:

```json
{"manifest": {"name": "station-fw", "version": "3.3.0", "kind": "firmware", "sizeBytes": 524288,
              "sha256": "<hex of the payload>", "payload": "station-3.3.0.bin", "channel": "stable",
              "notes": "Fixes the level sensor", "security": false},
 "signature": "<base64url Ed25519 signature over the RFC 8785 JSON of manifest>"}
```

`payload` is a file name in the update source, without `..`. While the payload downloads the update can be cancelled, and Stop cancels it; once it installs, it cannot.

**Schedules** run as their author. Each run is a new Action, with `scheduleId`, that passes the gate as the author at that moment: a destructive run waits for a person, a peer's or AI's run follows the mode and the allowlist as they are then, and while autonomy is stopped no run that changes anything starts. A schedule made by `ai`, `peer` or `rule` is `proposed` until a person approves it. `cron` is read in UTC. A lifecycle request with `delaySec` becomes a schedule.

**Privacy.** `PUT /valve/policy` and `PUT /ai/policy` replace the whole policy. Tightening the Valve policy is `write` and runs at once for a person; loosening it (a higher `llmCeiling`, a lower or removed classification, a longer retention) and any change to the AI policy are `destructive`, so a person proves presence with the request, or another person approves it. The Valve policy starts from the Card's `valve` and `retention_days`; once changed through the API it is kept in the state file, until the Card's `valve` changes. `llmCeiling: private` needs a granted consent for `model-provider`. An export or erasure is asked by the person (`sub` of the token), or by a person with `heart.admin` who says in `authority` what they act under. Erasing removes the person's Memory, consents, ratings, receipt links and stored exports, writes `[erased]` for their identifier in Actions, Rhythm and logs, and keeps only a record with a keyed digest of the identifier.

**Keys, owners and trust.** `POST /security/rotate` is `destructive`. heartd makes a new identity key and a rotation statement signed with the old one, keeps the old key in `identity.key.previous` and in the key set for 7 days, and sends the statement as `Heart-Key-Rotation` with every Card, and as `heartkeyrotation` when it pushes its Card to a Gateway, which then moves its pin. Another rotation within those 7 days answers `409`. A claim takes the code from the box, records `ownerKey` and `issuer`, and from then on heartd accepts only tokens that key signs: the `[auth]` key no longer works. A transfer moves that to the new owner at once, and removes presence methods, grants, subscriptions, consents and schedules, and cancels the Actions waiting for approval. A release does the same without naming anyone, and the system is claimable again. A factory reset also erases personal data (Memory, consents, ratings, receipt links, erasure records and every other Action) and removes peers; a decommission does the same, and the system can never be claimed again. Each makes a new claim code (none after a decommission), and on a Basic system a new owner key. Trusting a peer and changing the allowlist pass the gate like any change to authority; a peer's messages stay data. A batch's items each pass every check as if sent alone; an item that needs an `Idempotency-Key` uses the batch's key with `.` and its position.

**The Reasoning Gateway** keeps the owner's instructions in the system prompt and puts every context item and the conversation in marked blocks, escaped so that they cannot close their block. Provider keys are added by heartd only; a provider's error text is never passed on. `GET /security/attestation?nonce=` answers evidence signed by the identity key.

**Model pins.** `PUT /evolution/pins/{purpose}` is `destructive`; the model is pinned only if `regression_command` passes, and the Rhythm records `evolving.model.adopted` or `evolving.model.rejected`. `POST /evolution/rollback` (`write`) returns to the pin before the last change. Pins set under `[model]` are the starting point.

Secrets never reach a log or a bundle: the values of `private` and `never-share` settings, `key=value` and `key: value` pairs whose key names a password, secret, token, API key or authorisation, and the word after `Bearer` are replaced by `[redacted]` before a line is kept.

```toml
[inventory]
probe_every_sec = 30
[[inventory.components]]
id = "controller"
kind = "board"
name = "Pump controller"
[[inventory.components]]
id = "pump"
kind = "actuator"
parent = "controller"
[[inventory.dependencies]]
id = "plc"
kind = "network"
probe = "192.168.1.20:502"

[config]
file = "settings.json"
apply_command = ["/usr/lib/pump/apply-settings"]
[config.settings.flow_target]
type = "integer"
minimum = 0
maximum = 120
default = 40
privacy = "public"
[config.settings.plc_password]
type = "string"
privacy = "never-share"

[telemetry]
log_file = "/var/log/pump/pump.log"
[[telemetry.metrics]]
name = "com.acme.tank.level"
unit = "cm"

[diagnostics]
[diagnostics.tests.self-check]
command = ["/usr/lib/pump/self-check"]
[diagnostics.tests.flush-lines]
command = ["/usr/lib/pump/flush-lines"]
disruptive = true
cancellable = true
timeout_sec = 120

[software]
channel = "stable"
sbom_file = "sbom.spdx.json"
update_source = "/var/lib/pump/updates"
update_keys = ["A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"]
install_command = ["/usr/lib/pump/install-update"]
rollback_command = ["/usr/lib/pump/rollback"]
[[software.packages]]
name = "station-fw"
version = "3.2.1"
kind = "firmware"

[artifacts]
max_count = 100
max_total_bytes = 67108864

[schedules]
max = 50
min_interval_sec = 300

[privacy]
consent_required = ["model-provider", "subscriber"]
retention_days = 365

[ai]
providers = ["acme"]
uses = ["explain", "serve"]
monthly_budget_usd = 20.0

[memory]
max_items = 500

[evolving]
suite = "pump-answers"
regression_command = ["/usr/lib/pump/model-regression"]
timeout_sec = 600

[security]
audit_capacity = 5000
secure_boot = true

[platform]
model = "PX-200"
serial = "SN-0042"
power_source = "mains"
clock_source = "ntp"
clock_synced_file = "/run/systemd/timesync/synchronized"

[provisioning]
claim_code_file = "/etc/heartd/claim-code"
local_only = true

[peers]
max = 50

[batch]
max_items = 50

[native]
protocols = ["modbus-tcp"]
targets = ["tcp:127.0.0.1:502:1"]
[[native.protected]]
target = "tcp:127.0.0.1:502:1"
register = 40100

[reasoning_gateway]
ceiling = "internal"
daily_budget_usd = 2.0
[[reasoning_gateway.upstreams]]
name = "main"
provider = "openai"
model = "gpt-4o-mini"
api = "openai"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
input_usd_per_mtok = 0.15
output_usd_per_mtok = 0.6
[[reasoning_gateway.upstreams]]
name = "backup"
provider = "anthropic"
model = "claude-haiku-4-5"
api = "anthropic"
base_url = "https://api.anthropic.com"
api_key_file = "/etc/heartd/anthropic.key"
input_usd_per_mtok = 1.0
output_usd_per_mtok = 5.0
```

## Host feed

Write one JSON object per line to `host_socket`. Each line gets `ok`, or `error` with a reason.

```text
{"event": "compressor.started", "severity": "info", "component": "compressor", "summary": "optional"}
{"vital": "errorRate", "value": 0.02}
{"state": "cooling"}
{"phase": "maintenance"}
{"recovered": "compressor"}
{"log": "Compressor started after 3 tries", "severity": "warning", "component": "compressor"}
{"platform": {"power": {"source": "battery", "batteryPct": 64, "lowPower": false}, "network": {"connected": true, "signalDbm": -61}, "clock": {"synced": true, "driftMs": 3}}}
```

A `log` line is kept in the `telemetry` module's log, with secrets removed; without that module it gets `error`. A `platform` line updates what `GET /platform` shows.

Unlike messages from peers, these events can trigger Tending rules, so only the product's own user may open the socket. Names starting `circulation.`, `security.` or `valve.` are refused.

**Fleet approvals.** A fleet command that is not `read` is approved once at the Gateway, over the fleet digest (the command, the operation, its body, `physical`, the selector, the sorted target IDs and the rollout). The Gateway then sends each system the operation with a new `actionId` and a `presence` of method `fleet` that it signs for that system. A system with `gateway_public_key` accepts it as the approval of its own Action only if the signature checks, it is among the targets, the request is exactly the one approved, the approval is at most 300 seconds old, and the approver is not the caller; a `physical` Action also needs `fleet_physical`. Its gate, guards and Stop still apply. Every system's request body carries the whole target list, so `[server] max_body_bytes` bounds the size of a fleet (about 8,000 targets with the default).

## Gateway

`heartd gateway <file>` reads a different file, shown in `examples/gateway/heartd.toml`:

| Section | Key | Default | Meaning |
|---|---|---|---|
| `gateway` | `id`, `instance` | required | The Gateway's own identity |
| | `state_file` | required | Registrations and the Gateway's own state |
| | `socket_dir` | required | Routed sockets must be inside it |
| | `max_systems` | `10000` | |
| | `rhythm_per_system` | `256` | Events kept per pushed system |
| | `allowed_origins` | empty | Browser origins allowed to push |
| | `allow_private_routes` | `false` | Allow TLS routes to private addresses |
| | `list_requires_auth` | `false` | Hide the system list from anonymous callers |
| | `extra_ca` | none | Extra certificate to trust for routed systems |
| | `identity_key_file` | `identity.key` beside the state file | Signs the Gateway's Card, the caller headers it sends, the fleet approvals it gives each system, and the commands it puts on command channels |
| `server`, `auth` | | | As for `heartd run` |

To route a system over its Unix socket, place the socket inside `socket_dir`, set `trust_gateway_headers = true` and `gateway_uid` on that system, and add an `[uplink]` section. To route over TLS, set `gateway_public_key` on the system instead. A system behind NAT sets `[uplink] commands = true` and `gateway_public_key`: the Gateway serves it as pushed, and passes any request it cannot answer from what was pushed to the system's command channel, waiting up to 30 seconds for the answer (`504 system-unreachable` after that, `501` while the system has not polled in the last 65 seconds).

## Security rules (Advisory HEART-2026-001)

- **Tokens** must carry `aud` equal to the system ID (the Gateway's ID for the Gateway). A token with `cnf.jkt` must come with a DPoP proof. Off the loopback address, unbound tokens are refused with `token-not-bound`. A token whose `act` claim names any actor other than a HEART Gateway the issuer marked with `"heart_gateway": true` is treated as `ai` and loses `heart.approve`; a person working through such a Gateway stays `human`. The Card's `level: full` needs `control` in `modules`, or heartd does not start.
- **Approvals** carry a passkey assertion over the Action's `digest`, in `presence` in the decision body. A person's own destructive or physical request carries it in the request body itself, with the `actionId` the client chose, on `POST`, `PUT`, `PATCH` or `DELETE`; without it the request waits for another person, because nobody approves their own Action. Passkeys come from `[presence]` and from enrolment through the API. The first one may be enrolled through the Unix socket or loopback while none is enrolled.
- **Playbooks** added through the API are destructive, and only an administrator can adopt one. A Playbook runs only its own capability, and a physical one only if the rule has `"physical": true`. Rules in `rules` are treated as adopted by whoever installed the file.
- **Receipts** are salted. The salt goes to the recipient: `receiptSalt` to a model, `Heart-Receipt-Salt` to a Gateway, and the `heartreceiptsalt` attribute to a webhook. The commitment covers the message without these.
- **Joining a Gateway** at Connected or Full needs a signed Card, sent automatically by `[uplink]`. The Gateway pins the key.

## Access tokens

`heartd` is an OAuth 2.0 resource server. It accepts JWTs signed with `EdDSA`, from your authorisation server, with these claims:

| Claim | Meaning |
|---|---|
| `sub` | Principal |
| `iss`, `exp` | Issuer and expiry |
| `scope` | Space-separated scopes, such as `heart.read heart.ask` |
| `heart_clearance` | `public`, `internal` or `private` |
| `heart_caller` | `human`, `peer`, `ai` or `rule`. Defaults to `peer` |

## Tending rules

```json
{
  "id": "pump-link-lost",
  "enabled": true,
  "status": "adopted",
  "description": "Raise an alert when the pump controller stops answering",
  "when": { "event": "component.failed", "component": "optional", "count": 3, "windowSec": 30 },
  "then": { "raise": { "code": "connectivity.dependency-unreachable", "severity": "error", "summary": "Pump controller is not answering" },
            "capability": "optional_capability", "arguments": {} }
}
```

A rule runs only if `enabled` is true and `status` is `adopted` (Core Standard 4.3). Writing `adopted` in this file is the owner's adoption; a rule with no `status` is `proposed` and does nothing. Set `adoptedPhysical: true` to let it run a physical capability. Use a check such as `{ "signal": "errorRate", "op": ">", "value": 0.1 }` in `when` to watch a metric; it fires each time a new sample makes the check hold. The older forms (`{"vital", "above"}`, `withinSec`, a `raise` string, `physical`) are still read. A rule's capability must list `rule` in `callers`, and it passes the same gate as any other request.

## Running as a service

`packaging/heartd.service` is a systemd unit that sets CPU and memory ceilings and runs with few privileges.

Reserve the HEART port so Linux never lends it to an outgoing connection:

```sh
echo "net.ipv4.ip_local_reserved_ports = 43278" | sudo tee /etc/sysctl.d/99-heart.conf
sudo sysctl --system
```
