HEART Embedded Systems logoHEART

Configuring heartd#

Reading tracks3 Advanced
Advanced: the reference implementation of HEART Full. Which track am I?

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#

SectionKeyDefaultMeaning
systemcardrequiredHEART Card, as JSON
instancerequiredNon-identifying instance label
custom_dirnoneDirectory of custom definitions
rulesnoneJSON array of Tending rules
serverbind127.0.0.1Address. Anything but loopback needs TLS
port, fallback_port43278, 7685Standard and fallback
tls_cert, tls_keynonePEM files
unix_socketnoneAlso listen on this socket
max_connections32Further connections are dropped
rate_per_sec50Per caller, with a burst of twice this
max_body_bytes262144Larger bodies get 413
host_socketnoneSocket for the product's own events, mode 0600
trust_gateway_headersfalseTrust a Gateway's caller headers on unix_socket, from gateway_uid only
gateway_uidnoneThe Gateway's operating-system user. Required with trust_gateway_headers
gateway_public_keynoneBase64 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
authissuernoneRequired iss claim
public_keynoneBase64 of the raw Ed25519 public key
owner_key_filenoneThe 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
storagestate_filerequiredWritten atomically, mode 0600
identitykey_fileidentity.key beside the state fileThe system's Ed25519 identity key, created on first start, mode 0600. Signs the Card
presencerp_idnoneWebAuthn relying party ID, shown as card.presence.rpId. With [presence], the Card offers module presence. Without it, nothing can be approved
originsnoneOrigins of the approval page
[[presence.credentials]]noneprincipal, 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
corelog_capacity, store_capacity4096, 512Hard limits
escalate_after_sec, escalate_to900, noneEscalation
daily_budget_usd, max_call_usd1.0, 0.05AI spending limits
allowlistemptywrite 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_max5The 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_level4The promised Autonomy Level. A brain's Proposal uses the allowlist only at 5
approval_timeout_sec86400How long an Action waits for approval (at least 60)
fleet_physicalfalseAccept 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_workers8Background effects at once
modelgateway_urlnoneReasoning Gateway. Omit for no model
token_envnoneEnvironment variable holding its token
tier, timeout_mscloud, 20000
pinsempty[{purpose, provider, model, pinnedAt}]
webhookssigning_key_filenoneBase64 of a 32-byte Ed25519 seed
extra_canoneExtra PEM certificate to trust
allow_private_sinksfalseAllow sinks on private addresses
supervisorheartbeat_filenoneNeeded by heartd supervise
hung_after_sec15Restart when the heartbeat is older
max_restarts, window_sec5, 300Then 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.

SectionKeyDefaultMeaning
inventory[[inventory.components]]emptyThe 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]]emptyOutside 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_ms30, 2000How often, and how long one try may take
config[config.settings.<name>]emptyEach 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
history20 (1000)Revisions kept. GET /config/history sets gap: true once older ones are gone, and POST /config/rollback to one of them answers 404
filenoneThe current values, as JSON, written atomically (mode 0600) at start and on each change, every setting included, for the product to read
apply_commandnoneProgram (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_sec30 (600)Longest apply_command may take
telemetry[[telemetry.metrics]]emptyMetrics: 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_alltrue without metricsAlso keep every vital and numeric property as a metric
points_per_metric1440 (100000)Points kept per metric, oldest dropped first
sample_every_sec10At most one point per metric in this time; a newer value replaces it
max_metrics64 (1024)Metrics kept
log_capacity1000 (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_bytes2048 (16384)Longer messages are cut
log_filenoneA log file to follow, like tail -F. ERROR, WARN, CRIT and FATAL in a line set its severity
journald_unitnoneA systemd unit whose journal to follow, through journalctl -o json
diagnostics[diagnostics.tests.<name>]emptyEach 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
runs50 (1000)Past runs kept
bundle_max_bytes262144 (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 onlyWhat is installed: name, version, kind (firmware, os, application, model or library). heartd lists itself too. GET /software shows them to internal callers
channel, boot_slotnoneShown in GET /software. A manifest for another channel is not offered
sbom_filenoneSPDX or CycloneDX JSON (at most 4 MiB), read at start and served by GET /software/sbom to internal callers; without it, 404
update_sourcenoneA directory of *.manifest.json files, or an https:// URL whose index.json lists them (below). Needs update_keys and install_command
update_keysemptyBase64url Ed25519 public keys. A manifest not signed by one of them is listed with signed: false and never installed (403 signature-invalid)
install_commandnoneProgram (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_commandnoneProgram 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_sec600 (86400)Longest an install or rollback may take
updates_kept, max_update_bytes20 (1000), 256 MiBUpdate records kept; largest payload accepted
staging_dirupdates beside the state fileWhere payloads are fetched to, mode 0600
artifactsdirartifacts beside the state fileWhere files are kept, one per artifact ID, mode 0600. Files nothing refers to are removed at start
max_count100 (10000)Files kept. A full store answers 409 conflict
max_total_bytes64 MiB (4 GiB)Bytes of all files together
max_file_bytes8 MiB (256 MiB)Largest file. Uploads are JSON with the file in base64, so [server] max_body_bytes must be about a third larger
schedulesmax50 (1000)Schedules kept. A single-time schedule that has run makes room for a new one
min_interval_sec60 (604800)Shortest time between two runs of one cron schedule. Faster answers 422 out-of-range
runs_kept10 (100)Runs listed per schedule
grace_sec300 (86400)A run this much later than planned, because heartd was not running, is recorded as missed and not carried out
privacyconsent_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_daysnone (36500)Longest Memory keeps an item. PUT /valve/policy can change it
consents_kept200 (10000)Consents kept. Withdrawn and expired ones make room first; with only granted ones, a new one answers 409 conflict
erasures_kept100 (10000)Records that an erasure happened, oldest dropped first
export_max_bytes1 MiB (16 MiB)Largest export. The oldest entries of the largest part go first, and truncated says so
export_expires_sec604800 (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_logstrueAn export also lists log lines (module telemetry) that name the person
aimodes["org-proxy"]org-proxy and/or byok. With [brain], only org-proxy
providers, regionsempty (any)Providers and regions allowed. A pin for another provider is answered from rules, and cannot be set
usesallWhat a model may be used for: explain (/ask), serve, propose, plan, chat. Another use is answered from rules
monthly_budget_usdnoneModel spending per calendar month (UTC). The daily budget and the most per call come from [core] daily_budget_usd and max_call_usd
allow_fallbacktrueSent as modelPolicy.allowFallback
usage_days90 (3660)Days of model use listed by GET /ai/usage
memorymax_items500 (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_bytes2048 (16384)Longer summaries are cut. Secrets are removed first, as in logs
short_term_sec604800 (31536000)How long a short-term item is kept
evolvingsuite, regression_commandnoneThe 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_sec300 (3600)Longest the suite may take. heartd stops it when the Action is cancelled, or by Stop
purposesexplain, serve, propose, plan, chatPurposes a model can be pinned for
candidates_kept, feedback_kept20 (200), 500 (10000)Candidate models listed by GET /evolution; ratings kept from POST /feedback
securityaudit_capacity1000 (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_atnoneShown in GET /security when given. tls there is true when [server] tls_cert is set
platformmodel, cpu, memory_bytes, serial, osnone; memory_bytes from /proc/meminfo, os from /etc/os-releaseGET /platform. serial is private
power_source, network_kind, clock_sourcenonemains, 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_filenoneA file whose existence means the clock is synchronised, such as /run/systemd/timesync/synchronized. Checked every minute
provisioningclaim_code_filerequiredThe 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_sec5 (20), 3600 (60 to 86400)Wrong codes before claims are refused (429), and for how long. Kept across restarts
local_onlyfalseTake 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)
peersmax100 (10000)Peers kept. A new one when full answers 409 conflict
accessmax_grants100 (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
batchmax_items100 (100)Requests in one batch. The Card's limits.maxBatch also bounds it
nativeprotocolsrequiredProtocols open to passthrough: modbus-tcp. process never is
targetsrequiredTargets open to passthrough, such as tcp:127.0.0.1:502:1. Any other answers 404
allow_writesfalseWithout it passthrough only reads, and a write answers 409 guard-failed
[[native.protected]]emptytarget and register never written, beside every address a custom definition marks physical or guards
timeout_ms, max_request_bytes2000 (30000), 4096 (65536)Longest time and largest request
reasoning_gatewayceilinginternalServe POST /heart/v1/reason (API Standard 7.2). Context above this level answers 403 valve-blocked
daily_budget_usd, max_call_usdnoneEach 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_tokens1024 (32768)Output tokens one call may ask for
[[reasoning_gateway.upstreams]]requiredTried 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:

{"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.

[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.

{"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:

SectionKeyDefaultMeaning
gatewayid, instancerequiredThe Gateway's own identity
state_filerequiredRegistrations and the Gateway's own state
socket_dirrequiredRouted sockets must be inside it
max_systems10000
rhythm_per_system256Events kept per pushed system
allowed_originsemptyBrowser origins allowed to push
allow_private_routesfalseAllow TLS routes to private addresses
list_requires_authfalseHide the system list from anonymous callers
extra_canoneExtra certificate to trust for routed systems
identity_key_fileidentity.key beside the state fileSigns 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, authAs 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:

ClaimMeaning
subPrincipal
iss, expIssuer and expiry
scopeSpace-separated scopes, such as heart.read heart.ask
heart_clearancepublic, internal or private
heart_callerhuman, peer, ai or rule. Defaults to peer

Tending rules#

{
  "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:

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