The wake plane: ai-memory wake-hub and the wake sink
Issues #3466 (EPIC), #3465 (the bus), #3467 (the hub), #3468 (identity), #3469 (this page: the bus sink), #3470 (this page: the client), #3504 (this page: snapshot reuse + the refresher), #3472 (this page: the certification stance and the removal proof).
Start with Integrate any agent for the end-to-end setup.
What the wake plane is for
Before it existed, memory_notify wrote a durable inbox row and dispatched
nothing. A recipient learned it had mail only by polling memory_inbox. The
wake plane closes that gap: a committed notify pushes a bounded, content-free
HINT to the recipient with a millisecond design target (measurement #3473
remains open), so the poll becomes a safety net
instead of the delivery mechanism.
Three pieces:
| Piece | Owns |
|---|---|
crate::inbox_wake |
the in-process agent_notified broadcast bus, one frame per committed notify |
crate::wake_sink |
the bridge from that bus to the hub — in-process, or over the hub’s socket |
crate::wake_hub |
the same-host Unix-domain-socket switch that pushes the hint to connected agents |
crate::wake_client |
the CLIENT: one long-lived session, one catch-up inbox read per hint, and the bounded poll that makes losing the hub cost latency only |
NORMATIVE: keep polling
A client of the wake plane MUST continue to poll its inbox at least once every
60 seconds (wake_sink::BACKSTOP_POLL_MAX).
This is the rule that makes everything else here safe. The hub holds no durable truth: the ai-memory inbox row is the record and the wake is only a prompt to go read it. Every bound in the plane — a full recipient queue, a full hub-wide egress budget, a full hand-off channel, a lagging bus subscriber, an absent hub process — may drop a hint. Bounding the backstop poll is what turns each of those into a bounded LATENCY cost rather than an unbounded correctness one.
Sixty seconds is a CEILING, not a target. A client that observes a
seq_high_watermark gap should read immediately rather than wait it out.
Self-healing after a lost wake
Every wake carries seq_high_watermark: the producer’s host-wide monotonic wake
counter at the instant the hint was minted. It is deliberately NOT a
per-recipient inbox depth — the bus has no per-recipient counter, and a truthful
one would put a database read on the very latency path this plane exists to
remove.
Read it as “wakes happened that you did not see”. The correct response to a gap is ONE catch-up inbox read. That is fail-safe by construction: a client may read once more than it strictly had to, and can never conclude that nothing was missed when something was.
A sink-side per-recipient counter would be strictly worse. When a broadcast
subscriber lags, it never sees the frames that were dropped, so it would hand
clients contiguous numbers across a real gap. Lag is therefore signalled
explicitly, through InboxWakeSink::on_lagged and its own metric.
What a wake carries — and what it cannot
A wake payload is exactly:
{ inbox_row_id, namespace, sender, digest, seq_high_watermark }
digestis the SHA-256 of the notification body, so a recipient can de-duplicate and verify what it later reads back without the hub ever seeing the body.- There is no body field and no title field, on the bus frame or on the wire. The body reaches only the emitter, which digests it.
- The whole encoding is capped. When a long namespace and a long agent id will
not fit together, fields are shed in a fixed order —
sender, thennamespace, thendigest— andinbox_row_idandseq_high_watermarkare never shed. A hint that will not fit even then is REFUSED rather than truncated: a truncated row id points at the wrong row, and this plane may only ever produce fewer results, never wrong ones.
Who may receive which wake
A substrate wake is addressed DIRECTLY to the recipient agent id and never to a
#topic. The hub’s route table is keyed by the identity a hello authenticated,
so a wake for X can only ever land on a session that authenticated AS X.
A topic-shaped, reserved, empty or over-long recipient is refused and counted;
it is never coerced into something routable.
Which topics a session may subscribe to (#3505)
The verifier admits exactly two topic shapes and nothing else:
#_inbox/<agent-id>for the principal that authenticated — the unconditional own-inbox proof #3468 shipped.#<namespace>for a namespace the CURRENT snapshot PROVES that principal reads.
The proof is derived out of band, by ai-memory identity hub-cache, from the
SAME predicate the store applies for namespace read scope: the #1921 team /
unit / org ancestors of the agent’s own id. There is no second copy of that
predicate — crate::visibility::namespace_read_scope_prefixes is the one
definition, and both the sqlite and the postgres exporter call it.
What the snapshot carries is those PREFIXES (at most three), not an expanded
list of namespaces. The hub then admits #<namespace> when
crate::visibility::namespace_subtree_contains — the store’s own containment
test, the same one crate::storage::matches_subtree and
scope_subtree_visible route through — places the namespace inside one carried
prefix, AND the namespace is not a #3348 substrate namespace (_inbox/,
_messages/, _agents, …).
Four properties follow, and each is load-bearing:
- The hub still opens no database. Verify time is the shared containment test applied to material the hub already holds.
- The hub applies the store’s own subtree containment against
exporter-proven prefixes. It is not a bare
starts_with:acme/engadmitsacme/eng/xand neveracme/engineering, because the shared predicate is segment-wise. Reusing it is what stops the hub from re-widening a scope the store would narrow — the hub is the component with the least information about what the store would allow, so it must not carry its own rule. - The export cannot grow with the corpus. The set is a property of the agent’s ID, so it is fixed-size forever and derived with no query. An expanded namespace list would instead grow as namespaces are created: an org-level agent in a corpus of a few hundred namespaces would exceed any fixed per-agent ceiling, the refresher would then publish NOTHING, and once the snapshot aged out the hub would refuse EVERY hello — a fleet-wide availability failure caused by ordinary corpus growth.
- Narrowing takes effect within one second. Every session’s live
subscription set — the hello topics AND anything a later
subscribeframe added — is re-verified on the one-second revalidation. Drop a prefix from the next snapshot and the already-open subscription is dropped, with no reconnect and no cooperation from the client. The session itself survives: losing a scope costs subscriptions, not the agent’s own inbox, because a fleet that reconnects on every scope change is an outage where fewer subscriptions is a degrade.
The prefix set is bounded per agent by MAX_READABLE_PREFIXES, which IS
crate::visibility::NAMESPACE_READ_SCOPE_DEPTH — one number, so the hub’s
ceiling can never disagree with the derivation. The exporter cannot produce an
oversize set, so a file that carries one is evidence it is not what it claims
to be and the hub REFUSES to load it — never a truncation, because which of an
agent’s prefixes survived would then depend on ordering.
Sending to a topic (#3505)
A topic has two doors, and both take the same proof. Frame.to =
#<namespace> on a wake is gated by the SAME verify_topics check a
subscribe takes, so a session may address exactly the topics it could itself
subscribe to. An out-of-scope topic send is refused with 403 forbidden and
fans out to nobody.
Without that gate the widening would have been one-sided: before #3505 nobody
could subscribe to #<namespace>, so an ungated topic send reached nobody and
cost nothing — but the moment a proven namespace became subscribable, any
authenticated peer, in any tenant, could have published fabricated hints (row
id, sender, digest) to every subscriber of a team namespace and forced
catch-up reads fleet-wide while paying only its own token bucket.
403 forbidden, not 401 unauthorized: this refuses ONE frame on an otherwise
valid session (the hub closes the connection on unauthorized and keeps it on
forbidden), so a mis-addressed wake never becomes a fleet-wide reconnect
storm.
This is the boundary the producer follow-up must cross deliberately. The
reserved wake-hub-producer (#3469) is a flat id with no team / unit / org
ancestor, so it carries an EMPTY prefix set by design and therefore cannot
publish to a namespace topic at all. Its DIRECT, own-inbox wakes are unchanged
— that is the whole authority the sink was ever granted. Making the substrate
emit a TOPIC wake therefore cannot be done by wiring alone: it requires an
explicit, reviewed decision to grant that principal a namespace scope, which is
exactly the deliberation a broadcast authority deserves.
Snapshot format. readable_prefixes is an ADDITIVE field and
ALLOWLIST_FILE_VERSION deliberately stayed at 2. Both mismatch directions
already fail in the right direction: a NEWER hub reading an OLDER snapshot sees
no field, defaults the set to EMPTY and admits own-inbox only (never
“everything”) — which a version bump would have turned into an outright refusal
of every older file, so upgrading the hub before the exporter would take the
fleet down; an OLDER hub reading a NEWER snapshot refuses the file whole through
deny_unknown_fields, so it can never part-honour a grant it does not
understand. The version stays reserved for a change to the meaning of an
EXISTING field.
Not in this lane — the producer half. Nothing in the substrate publishes a
TOPIC-addressed wake yet. The #3469 sink emits DIRECT wakes only
(Frame.to = <agent-id>), so today a namespace topic is subscribable and is
fed only by another authenticated session that PROVES the namespace sending
wake to #<namespace>. Making the substrate emit a topic wake when a
shared-namespace row lands is a separate, deliberate change to
src/wake_sink/ and its agent_notified producer — it needs its own decision
about which namespaces are worth broadcasting, how the fan-out is charged, and
(per the send gate above) whether the reserved producer principal should be
granted a namespace scope at all. It is tracked as a follow-up rather than
smuggled in behind a verifier widening.
Wakes are never sourced from the webhook lane. That lane is operator egress,
with a global dispatch semaphore and a subscription-scan ceiling; sourcing an
agent’s latency-critical wake from it would make one slow operator webhook the
recipient’s wake latency. The agent_notified event still fires there for
operator subscribers — the two lanes are fed from the same emitter and are
independent of one another.
Knowing when a subscription is live (#3532)
An applied subscribe is acknowledged, and the acknowledgement is a
promise about the router, not about the frame. The hub adds the topics to
its routing table FIRST and mints the ack only after that returned. Because
one connection’s frames are read, handled and written in order, a client that
has OBSERVED the ack knows the route already existed when the ack was
minted — so any topic wake a peer addresses after that point is routed against
a table containing this session. unsubscribe is acknowledged the same way,
with the dual guarantee: once the ack is observed, no further wake for those
topics will be routed there.
Before this, an accepted subscribe was answered with nothing at all. A client
could not know when its subscription went live, and a peer’s hint sent inside
that window fanned out to nobody. The inbox row is the durable truth and the
<=60 s backstop still finds it, so the cost was LATENCY and never loss — but
it was UNOBSERVABLE latency, and a fleet cannot manage what it cannot observe.
It was also why every wake-hub test had to round-trip a ping after
subscribing before any peer addressed the topic; the ack replaces that
workaround with the property itself.
The hello already had this. Topics asserted in the hello are registered
before the welcome is sent, so the welcome has always been their
acknowledgement. #3532 extends the same guarantee to a subscription taken
later in the session.
The ack is an echo, and that is deliberate. It is the SAME kind as the
request (subscribe -> subscribe, unsubscribe -> unsubscribe), stamped
from the hub, carrying the request’s own topic-list bytes. It does not
consume a new wire number, for a reason that is about other people’s clients
rather than about elegance: the frame parser REFUSES an unknown kind byte (so
does the Python SDK reader), so a hub that unilaterally started emitting a new
subscribed kind would have ENDED the session of every client written before
it, instead of being ignored by one. Kinds 5 and 6 are already in every
reader’s table, and every client in this tree ignores a frame kind it has no
opinion about — so the ack is inert for an old client and meaningful to a new
one, which is what “additive” has to mean on a wire other people have already
implemented. hello is already bidirectional with a different payload per
direction, so a direction-disambiguated kind is the protocol’s existing idiom.
Three properties follow, and each is pinned by
tests/wake_hub_subscribe_ack_3532.rs:
- Acknowledged means applied. A REFUSED subscribe — out of scope, or past
MAX_TOPICS_PER_SESSION— is answered with itserrorand never with an ack. There is no frame that says “listening” for a topic the hub did not take. - The ack carries nothing new. Its payload is the request’s own bytes, so it cannot smuggle a body, an identity claim or an authority the client did not itself send. The wake-only posture is unchanged.
- A wake may still precede the ack. If the session already held the topic, a wake for it can legitimately arrive between the request and its acknowledgement. A client waits for the ack while dispatching whatever arrives first; it must not assume the ack is the next frame.
The two deployment shapes
Co-hosted with the daemon
wake_sink::in_process::install_in_process(router) attaches a fire-and-forget
sink to the bus. The encoded frame goes straight to the hub’s own
Router::deliver — the same injection point a hub connection’s own route_wake
uses — so a substrate wake and a peer-relayed one are indistinguishable
downstream and obey the same per-recipient queue depth, per-recipient byte cap,
hub-wide egress budget and coalesced offline set. There is one set of bounds to
reason about, not two.
Hub as a separate process
wake_sink::uds::install_uds(cfg, credential) starts a forwarder that is an
ORDINARY hub client over the hub’s Unix domain socket: the hub opens with a
challenge, the forwarder answers with a signed hello, and from then on it writes
wake frames through the same length-delimited codec and the same frame ceiling
every peer uses. There is no privileged side channel and no second admission
path — the hub applies its peer-credential gate, its identity verifier, its
token buckets and its queue bounds to the daemon exactly as it does to an agent.
Identity, and why it fails closed
A substrate wake is stamped with the reserved wake-hub-producer identity, NOT
with the notifying agent’s id. No hub session ever authenticated that agent for
that frame, so putting its id on the frame’s from would be a claim the hub
never checked. The real sender rides in the wake metadata, where it is plainly
metadata.
wake-hub-producer is a reserved agent id, so no wire caller can register it or
claim it through X-Agent-Id, an MCP tool argument, or an HTTP body. “May wake
any agent on this host” is therefore an operator grant to one unclaimable name
rather than an authority an agent can talk its way into. The forwarder REFUSES
to start for a credential that authenticates as anything else.
The shipped join credential (NoJoinCredential) refuses to sign, so a daemon
with no enrolled producer identity refuses to start a forwarder rather than
opening a socket it could not authenticate on. This mirrors the hub’s own
shipped DenyAllVerifier. There is deliberately no flag that relaxes either
one: a switch that disables identity verification is a switch that eventually
gets set in production.
Degrade, never corrupt
The durable inbox row is already committed before a wake fires. A slow, full or absent hub therefore costs a HINT and a counter — never a committed notify, and never backpressure applied to the notify path. Concretely:
- Everything on the bus pump is an encode plus a non-blocking enqueue. No
.await, no lock held across one, no I/O. - The hand-off channel to a separate-process hub is bounded. When it is full the new HINT is dropped and counted; the durable write it referred to has already committed, so the recipient still finds the row on its next poll.
- Reconnects use jittered exponential backoff capped at the backstop interval, so a hub restart cannot produce a synchronised reconnect blast across a fleet. The ladder resets only after a connection that actually lasted, so a hub that accepts and instantly drops cannot turn the forwarder into a hot loop, and a daemon that survived one long outage does not stay stuck at the cap forever.
Every drop cause has its own counter on wake_sink::SinkMetricsSnapshot —
unaddressable recipient, unencodable frame, hub queue or egress overflow,
offline-coalesced, offline-unknown, hand-off channel full, hub down, and bus
lag. A hub that silently stopped waking anyone must not look like a quiet fleet.
Certification: transport-only, and provably removable
The enterprise-federation certification
(docs/compliance/ENTERPRISE-FEDERATION-CERTIFICATION.md) covers the
federation trust boundary. The wake plane is explicitly NOT covered by it
(§6), and that entry is a limit, not an omission. Three properties are what
let the certification’s claims stay true while this plane ships beside them:
- Transport-only. The hub federates nothing. It is reachable only over a
mode-
0600Unix-domain socket on ONE host, never between peers, and no/sync/pushverdict, noreceive_authcontrol and no envelope or signature gate the certification cites consults it. Nothing in the certified federation path gets weaker, or stronger, because a hub is running. - Content-free. A wake frame is exactly
{inbox_row_id, namespace, sender, digest, seq_high_watermark}— no body and no title, on the bus frame or on the wire. A hub process therefore observes no memory content, and the plaintext-to-peer exposure the certification already discloses is unchanged by it. - Loss degrades LATENCY only. The inbox row is committed before any hint
is minted and stays the durable truth; the
<= 60 sbackstop poll is armed hub or no hub. A hub that is slow, full, absent — or deleted from the deployment entirely — costs wake latency and a counter, never a committed notify and never a row.
What is therefore not covered: any wake-latency SLO, any delivery, ordering or at-least-once guarantee for a hint, any MEASURED hub scale envelope (the bounds here are architected, like the agent dimension of the certification’s scale envelope), and any authority derived from a hint — what a woken recipient may read is decided by the ordinary inbox read it then performs, not by the wake.
The removal proof
“Removable” is mechanised rather than asserted. scripts/check-cert-removal-proof.sh
carries the row wake_backstop_always_armed_3472, the only row in that
harness that runs in the opposite direction to the others: every other row
proves a control the certification COVERS is load-bearing, this one proves the
subsystem the certification does NOT cover is the removable half.
The control it mutates is the unconditional spawn of the backstop loop in
wake_client::WakeStream::start — the one that happens before, and
independently of, the if let Some(hub) arm. Disarming it, and only it, is
exactly the world where the hub is the sole delivery mechanism. The guard test
is tests/wake_client_3470.rs::inbox_wait_returns_on_the_bounded_backstop_with_no_hub_3470,
which drives ai-memory inbox --wait on a host with no hub and asserts the
backstop fires inside its own bound. Broken it goes RED; restored it goes
GREEN. That is the executable form of the sentence at the top of this page:
the POLL is the guarantee, the wake is a prompt.
Read the harness before running it: it rewrites tracked source in place for
the length of a run (#3118 / #3119), so git add -A after one is banned
and --force-restore is the recovery path.
Turning it on: the operator ceremony
Nothing pushes wakes until an operator asks for it. The default posture is no forwarder, no socket and no identity load.
1. Run the hub. ai-memory wake-hub --allowlist <allow.json> in its own
process (see docs/CLI_REFERENCE.md; --posture prints the resolved socket,
directory mode and fd budget without binding anything).
2. Grant the producer name. The daemon issues its wake sessions under the
reserved principal wake-hub-producer, signed by the daemon’s OWN enrolled
daemon key — the same key it already signs links with. Publish an allowlist
row binding that name to that public key:
ai-memory identity hub-cache --daemon-producer \
--include-agent <each agent that may listen> --out <allow.json>
--daemon-producer is the switch that publishes it. It reads only
daemon.pub from this host’s owner-only key directory and writes the row with
bind_authority: "daemon_key_dir".
That row is the single, revocable grant that says “this host’s daemon may wake
agents on this hub”. Drop the switch on the next refresh and the row disappears,
which revokes the daemon’s wake authority within a second — the hub revalidates
every established session once per second against the current snapshot. Both the
grant and the revocation are recorded on the signed_events audit spine as
identity.hub_allow / identity.hub_revoke, exactly like an agent’s.
There is deliberately no way to publish this row by naming the principal:
--include-agent wake-hub-producer is REFUSED, because a reserved principal has no
key history and the store loop would silently omit it, publishing a snapshot
that looked successful and granted nothing.
The row’s bound_at is the daemon key’s own bind instant — read from
daemon.pub on this host — not the instant the snapshot was published, so it is
IDENTICAL on every refresh (#3540). That matters because the hub refuses a
delegation issued BEFORE the binding it rides on: a bound_at that moved
forward on every 30 s republish would refuse the daemon’s own established
session on the very next re-validation, and the forwarder would reconnect once
per refresh, dropping every wake minted in the gap. Revocation is still the
same single lever — drop the switch and the row disappears.
Operationally that means the stamp is the daemon key FILE’s timestamp: anything
that rewrites daemon.pub — re-staging the key, a restore that does not
preserve modification times, a touch — moves the binding forward, and the
daemon’s hub session is re-established ONCE on the next refresh (one reconnect,
then stable again). Copy key directories with cp -p / rsync -a if you would
rather not pay even that one reconnect. Nothing else about the grant changes,
and the daemon re-joins on its own.
The same ordering check compares the two stamps at the precision the delegation carries, WHOLE SECONDS: a bundle minted in the same second as the binding is admitted (the ceremony above mints exactly that), and one minted in an earlier second is still refused.
What daemon_key_dir does and does not attest
It says: the operator of this host, with read access to its 0700 key directory,
asserted that this host’s daemon key may wake agents on this hub. It does NOT
claim a possession challenge was answered or that a v97 ledger row backs it —
neither is true, and stamping possession_proof would be a lie about the
durable identity root. The hub therefore treats it as delegating authority for
the reserved wake-hub-producer name and for NO other principal: that name is
unclaimable on the wire, owns no memories and no namespace, and the only thing
it can do is deliver a content-free wake hint addressed to an agent’s own inbox.
A hub build that has never heard of the value maps it to “unrecognised”, which
cannot delegate at all — so an older hub reading a newer snapshot fails closed.
3. Point the daemon at the hub. In config.toml:
[wake_hub]
sink_socket = "/run/user/1000/ai-memory/wake-hub.sock"
Restart serve. The startup log names the enrolled public key it will issue
under, so you can check it against the row you published.
If the sink is configured but the daemon has no enrolled key — or a public-only one — it REFUSES to start the forwarder, logs the exact remediation at ERROR, and keeps serving. It does not open a socket it could not authenticate on, and it does not take the durable substrate down over a hint it cannot push.
Why the daemon’s own key, and not a key of its own
wake-hub-producer is a reserved agent id: no wire caller can register or
claim it. It deliberately has NO enrolled root of its own. Minting one would
mean a second private key on the host, with its own enrolment ceremony, its own
rotation and its own revocation story — a second identity root, which is
exactly what “one identity root” forbids. Instead the daemon’s already-enrolled,
already-proven root is the sole authority, wake-hub-producer is a scoped NAME
that root may speak under on the wake plane, and the allowlist row is the
operator’s explicit, revocable grant that says so. The per-connection session
key is generated in memory and never written anywhere, so there is no
credential file to steal and nothing to rotate.
The co-hosted shape
wake_sink::in_process::install_in_process(router) is available as a library
call and is exercised by the test suite, but nothing in serve hosts a hub in
this build — ai-memory wake-hub runs the hub as its own process. When a
serve-hosted hub lands, wiring it is one line at the same boot site.
The client: ai-memory wake-listen
The plane is only useful if something listens on it. ai-memory wake-listen
(#3470) is that something, and it is what replaces the fleet’s three-minute
ai-memory inbox cron: one process, one session, one inbox read per event
instead of one process boot per poll.
# once, per agent, on the host that will listen
ai-memory identity delegate --scope a2a-hub --agent-id ai:alice --hub-id ai-memory-wake-hub
# then, long-lived
ai-memory wake-listen --agent-id ai:alice --json
ai-memory wake-listen --agent-id ai:alice --exec 'my-notifier'
# or, for a one-shot "block until there is mail, then print it"
ai-memory inbox --wait --timeout 300 --agent-id ai:alice
What it reads, and how often
Exactly ONE catch-up inbox read per event, through the same memory_inbox
funnel every other surface uses — never a read per queued hint:
| Event | Why it reads |
|---|---|
| welcome | mail may have arrived while this agent was offline |
welcome with lagged |
the hub’s offline id set overflowed, so its id list cannot be trusted |
wake |
a hint arrived naming a row |
wake with a seq_high_watermark gap |
wakes happened that this listener did not see |
| backstop tick | the bounded poll, always armed |
The backstop’s clock is reset by every catch-up read, hub-driven or not, so the
guarantee is “at most BACKSTOP_POLL_MAX since the LAST read” rather than a
fixed schedule that fires right after a wake. A healthy hub therefore costs at
most one idle read per minute per agent, not one per wake plus one per minute.
--poll-secs is REFUSED above 60 rather than clamped: a listener that silently
polled less often than the plane’s own contract would be reporting a guarantee
it does not provide.
Subscribing, and waiting for the acknowledgement (#3532)
wake-listen itself asserts NO topics: a substrate wake is addressed directly
to the recipient, so its own-inbox scope needs no subscription and its
welcome is already the acknowledgement of everything it asked for. A client
that DOES want a namespace topic (#3505) asks for it after the welcome, and the
rule is the same in all three client libraries:
Send the subscribe, then treat the subscription as live only when the
acknowledgement arrives — never when the write returns. A client that reports
liveness on the write reintroduces exactly the race #3532 closed.
- Rust —
Session::send_subscribe/send_unsubscribeonly WRITE; liveness isSession::next_eventyieldingSessionEvent::Subscribed. Keeping the ack in the one ordered event stream is what lets a wake that arrives before it be delivered rather than swallowed. - Python — pass
topics=toWakeListener; the pump sends thesubscribeafter the welcome and emits aWakeReason.SUBSCRIBEDsignal when the hub acknowledges.listener.subscribedholds the acknowledged topics and is empty until then. - TypeScript — pass
topicstoWakeStateMachine/WakeListener; the machine emits asubscribedsignal on the ack and exposessubscribedTopics.
All three IGNORE an acknowledgement they cannot parse rather than dropping the session over it: the subscription simply stays unacknowledged, so a caller waiting on liveness keeps waiting instead of being told a lie, and the durable inbox row is untouched either way.
One identity root, and every check fails closed
The listener loads the bundle ai-memory identity delegate --scope a2a-hub
wrote — <key-dir>/<agent-id>.a2a-hub.json, mode 0600 — which holds a
DELEGATED private key and never the enrolled one. There is no second identity
root and no second enrolment ceremony; the agent’s enrolled key is still the
sole authority, and the delegation is a short-lived certificate it minted.
Before a byte reaches the wire the listener refuses a bundle that is:
- not mode 0600, not owned by the caller, or reached through a symlink;
- a version this build does not understand;
- minted for a different hub, or for a different agent than the one being watched;
- holding a private key that is not the one its certificate authorises;
- carrying a certificate that does not verify under the agent’s ENROLLED public key in the same key directory;
- outside its validity window (refused locally, with the re-mint command in the
message, rather than as an opaque
401after a reconnect ladder).
It also checks the socket the way the hub hardened it: an owner-only (0700)
directory holding an owner-only (0600) socket, both owned by the caller.
Dialling a socket another local user could have created would be handing the
handshake to whoever won that race.
There is deliberately no flag that skips any of this — the same reasoning that
keeps --insecure off ai-memory wake-hub.
Degrade, never corrupt
Every failure mode here costs LATENCY:
- No hub configured, hub down, hub refusing. The bounded poll IS the delivery mechanism. That is the documented degraded mode, not an error.
- Reconnects. Jittered exponential backoff capped at the backstop, with the ladder resetting only after a session that actually lasted — so a hub that accepts and instantly drops cannot become a hot loop, and a fleet restart cannot produce a synchronised reconnect blast.
- A slow consumer. Signals cross to the consumer through a bounded channel
using
try_send. A full channel means a catch-up read is already queued and will see every row a dropped signal referred to, so the drop coalesces reads rather than losing them — and it is counted, because a listener that silently stopped reading must not look like a quiet inbox. - A failed catch-up read. Logged; the row is already committed and the next signal — at worst the backstop — reads it again.
- A hung
--exechook. Bounded at 30 s and killed, so one slow notifier cannot become a listener that stops reading.
The exec hook carries metadata, never a body
--exec runs sh -c <cmd> with the wake hint in the ENVIRONMENT (never on the
command line, so wire-sourced values reach neither ps output nor shell
word-splitting):
AI_MEMORY_WAKE_REASON, _AGENT_ID, _HUB_ID, _INBOX_ROW_ID, _NAMESPACE,
_SENDER, _DIGEST (lowercase hex SHA-256 of the body), _SEQ,
_MISSED, _PENDING, _INBOX_COUNT.
_DIGEST is what lets a hook verify what it later reads without the hub ever
having seen it. There is no body variable because there is no body on the wire.
These variables are EMITTED by the listener, not read by the substrate, so they
carry no precedence ladder and appear in no AI_MEMORY_* resolution table.
Replacing a polling fleet
A fleet that coordinates through memory_notify + ai-memory inbox on a timer
converts to the wake plane one agent at a time, and never all at once:
# 1. once per agent, on the host that will listen
ai-memory identity delegate --scope a2a-hub --agent-id <agent> --hub-id ai-memory-wake-hub
# 2. replace `sleep 180; ai-memory inbox --agent-id <agent> --json`
ai-memory inbox --wait --timeout 180 --agent-id <agent> --json
# 3. or, for a long-lived worker, replace the loop entirely
ai-memory wake-listen --agent-id <agent> --exec 'my-handler'
Nothing about the durable side changes: the same rows, the same read, the same
output. What changes is that the read happens when there is mail rather than
every three minutes — and, because the <=60 s backstop is always armed, an
agent whose hub is unreachable is strictly no worse off than the poller it
replaced.
The sdk/python/swarm acceptance harness converts the same way: set
SWARM_WAKE_HUB_SOCKET + SWARM_WAKE_HUB_BUNDLE_DIR and its consumer lanes
wait for the wake instead of racing the write. Leaving them unset keeps the
harness byte-identical to its pre-#3470 behaviour, which is what makes the
switch safe to roll out per fleet rather than per release.
ai-memory inbox --wait
The one-shot form: block on the wake plane, then perform and print the read
exactly as ai-memory inbox does. --timeout bounds the wait, and on expiry
the read STILL happens — a timeout means “nothing arrived in that window”,
never “skip the durable truth”. Omitting --timeout does not mean waiting
forever: the backstop tick is itself a return, so a wait without it lasts at
most one poll interval (<= 60 s, wake_sink::BACKSTOP_POLL_MAX).
A hub that is merely DOWN is already covered by the always-armed backstop: the
credential loads, the session loop backs off, and the poll returns on schedule.
When the CREDENTIAL itself will not load — no ai-memory identity delegate was
ever run on this host, the bundle expired, or it was minted for another hub —
--wait logs that refusal once at WARN with its cause chain and then waits
on the bounded poll anyway. That is deliberate: --wait is the drop-in for
sleep 180; ai-memory inbox, and a version that returned immediately on a
credential error would replace a paced poll with a hot loop. ai-memory
wake-listen keeps the hard refusal, because an operator who started the
listener explicitly asked for a hub session and needs to see why it will not
open.
Keeping the snapshot fresh: the refresher is not optional
The hub REFUSES every hello once the snapshot it reads is older than 60 seconds
(identity::hub_cache::MAX_CACHE_AGE_SECS). That is deliberate — a snapshot is
the only thing standing between a revoked agent and a live session, so an
un-refreshed one must expire into refusal rather than linger into authority —
but it means a hub with no refresher admits nobody one minute after the last
manual export.
Ship the refresh as a job, not as a habit:
| Platform | Unit |
|---|---|
| Linux | packaging/systemd/ai-memory-wake-hub-refresh.service + .timer |
| macOS | scripts/templates/dev.alphaone.ai-memory.wake-hub-refresh.plist |
Both run the same command every 30 seconds — half the refusal threshold, so a missed tick costs nothing, writing to the SAME path the hub reads:
# Linux (the $ALLOWLIST that ai-memory-wake-hub.service passes to --allowlist)
ai-memory identity hub-cache \
--include-agent <each agent that may listen> \
--out /var/lib/ai-memory/hub-allow.json
# The shipped refresher then install(1)s that file 0600 owned by
# ai-memory-hub into /run/ai-memory-hub/hub-allow.json — the hub uid
# must own the snapshot at exact mode 0600.
# macOS (the path dev.alphaone.ai-memory.wake-hub.plist reads)
ai-memory identity hub-cache \
--include-agent <each agent that may listen> \
--out ~/.ai-memory/hub-allow.json
Write it where the hub reads it. /run/ai-memory-hub/hub-allow.json on
Linux and ~/.ai-memory/hub-allow.json on macOS are the paths the hub units
name; a refresher pointed anywhere else leaves a hub reading a file nobody
writes, which looks like a working install right up to the moment every hello
is refused. On Linux the hub runtime directory is created 0700 by the hub
unit (RuntimeDirectory=ai-memory-hub, User=ai-memory-hub,
RuntimeDirectoryPreserve=yes). The refresher is a DISTINCT user
(User=ai-memory) because it opens the store; it cannot write the 0700 hub
directory, so ExecStartPost=+/usr/bin/install -o ai-memory-hub … is the
hand-off that satisfies the hub’s owner-only 0600 allowlist gate (#3578).
Grant the database’s DIRECTORY, not the database file. The systemd
refresher runs under ProtectSystem=strict, so every path it writes has to be
named in a ReadWritePaths=. SQLite runs the store in WAL mode, and WAL mode
creates and locks ai-memory.db-wal and ai-memory.db-shm BESIDE the
database — a reader opens them too, and identity hub-cache is not even a
pure reader, since it audits its allow/revoke decisions onto the signed_events
spine. Naming only /var/lib/ai-memory/ai-memory.db would leave those side
files on a read-only parent: the run fails, nothing is published, and the
previous snapshot ages past the 60 s ceiling into refusal — fail-closed, but a
hub that admits nobody. The shipped unit therefore grants
ReadWritePaths=/var/lib/ai-memory, the same directory grant
ai-memory.service uses (“Only touch AI_MEMORY_DB’s parent”); if you keep
the database somewhere else, name THAT directory here instead. The macOS
launchd job carries no path allowlist, so it needs no equivalent.
After a reboot the snapshot is gone. /run is a tmpfs, so the hub starts
admitting nobody and stays that way until the refresher has run once — which is
exactly what OnBootSec=15s (systemd) and RunAtLoad (launchd) are for. The
first post-boot refresh is what lets the hub admit anyone at all.
Three properties make that safe to run on a timer. It publishes ATOMICALLY: the
snapshot goes to a temp file created in the SAME directory, set to mode 0600
before a byte is written, then written, fsynced and renamed into place over
the old inode, so a hub reading concurrently
sees the whole old snapshot or the whole new one and never a truncated file. It
publishes NOTHING on failure: a store error, a record-stop or a failed audit
leaves the previous snapshot alone, which then ages out and refuses — the
fail-closed direction. And it can only ever NARROW: the snapshot is complete, so
an agent omitted from the next refresh loses admission within the hub’s
one-second session revalidation, which is exactly how revocation works.
The units are deliberately SEPARATE from ai-memory-wake-hub.service. The
refresher opens the store; the hub must never. Keeping them in one unit would
put a database handle inside the process whose whole security story is that it
holds only public material.
Watch it with --posture. ai-memory wake-hub --posture prints the
snapshot’s age and whether the hub still accepts it:
allowlist snapshot: 12 s old (ceiling 60 s)
--posture --json carries the same facts under allowlist_snapshot
(age_secs, max_age_secs, within_max_age). Alert on within_max_age
turning false, or on age_secs climbing: that is the refresher having stopped,
and it is visible before the agents notice they can no longer join. An
unreadable snapshot reports no age and within_max_age: false — a snapshot the
hub cannot read is not a fresh one.
Why there is no in-process refresh loop
wake-hub deliberately grew no --refresh-from <store-url> flag. The
derivation reads the durable v97 identity root and writes to the
signed_events audit spine, so an in-process loop would put a live store
handle — and its credentials — inside the hub process. The hub’s entire
security posture is that it holds ONLY public material and opens NO database on
either backend, which is what bounds a hub compromise to “wake hints stop
flowing” instead of “the identity root is reachable”. A timer that runs the
existing, already-audited exporter buys the same automation without spending
that property.
Reusing the parsed snapshot
The hub re-checks identity on every hello AND once per second for every
established session, so at the 256-connection ceiling it was parsing the
snapshot JSON hundreds of times a second. It now reuses the PARSED snapshot
while the file’s identity (device, inode, mtime, size) is unchanged and the
parse is younger than wake_hub::limits::ALLOWLIST_CACHE_TTL (2 seconds, 30x
under the 60-second refusal threshold).
Reuse changed the COST and nothing about the DECISION. Three properties hold on every single identity check, warm parse or not:
- The permission gate runs every time. Each check opens the snapshot with
O_NOFOLLOWand re-proves through that descriptor that it is a regular file owned by this uid at exactly mode 0600. A snapshot whose mode is widened, whose owner changes, which is replaced by a symlink, or which is removed is refused immediately. - The age gate runs every time. The snapshot’s own
refreshed_atis re-tested against the clock on every check, so reuse can never extend a snapshot’s life past the 60-second ceiling. A stale file stays refused exactly as it was before reuse landed. - A replaced snapshot takes effect immediately. A new inode, mtime or size misses the reuse key and forces a re-read on the very next check, so a refresh or a revocation still lands within the same one-second revalidation window it always did.
The permissions that are checked, the identity that reuse is keyed on, and the
bytes that are parsed all come from ONE descriptor — there is no stat-then-open
window for a swap to slip through.
Operating the hub
Issue #3471 — the ops surface: metrics, the health probe, the SIGTERM drain, the supervisor units, and the
doctorposture check.
ai-memory wake-hub --posture resolves and prints the socket, directory mode,
fd budget, drain deadline, identity verifier and allowlist snapshot age
without binding anything, so it is safe to run against a host already
serving a hub. --allowlist names
the derived public snapshot of enrolled agent keys the delegation verifier
reads; the hub itself never opens the store. Full flag reference:
docs/CLI_REFERENCE.md.
Is it up? — wake-hub --health
ai-memory wake-hub --health # human report; exit 0 reachable, 2 not
ai-memory wake-hub --health --json # machine-readable, same exit codes
The probe is an ordinary hub client. It connects to the configured socket, waits for the hub’s opening challenge frame, and closes. That is the whole probe, and the three things it deliberately is NOT are what make it safe:
- Not a privileged side channel. There is no admin socket and no status endpoint. A health surface that bypassed the hub’s own admission path would be an unauthenticated way to learn its state — and, worse, would stop testing the path that actually matters.
- Not a bypass of the peer-credential gate. The probe is subject to
SO_PEERCRED/getpeereidlike any peer. Run as the wrong user it is denied and reportsunreachable, which is the correct answer: from that user, the hub is unreachable. - Not an authenticated session. It presents no
hello, holds no key material, and is refused everything past the challenge — so it cannot enumerate agents, join topics or inject a wake, and it needs no credential, which is what makes it runnable from a supervisor where no agent identity exists.
Cost to the hub is one short-lived connection bounded by a 2 s budget, against
a pre-auth budget of 4 frames/s that the probe never spends (it sends zero
frames). Every outcome that is not “a well-formed challenge arrived” is
unreachable with a named cause and remedy — socket_missing,
not_a_socket, connection_refused (a stale socket with no listener),
permission_denied, timeout, unexpected_frame — because a supervisor that
reads “healthy” from an inconclusive probe is worse than no probe.
What it reports — the metrics
The hub’s counters, gauges and histograms are read through one stable JSON
shape (wake_hub::metrics::MetricsSnapshot::to_json), published at rest by
wake-hub --posture --json under metrics_schema so an exporter can be
written against a documented contract:
| Family | Answers |
|---|---|
connections_current, recipients_current |
how many agents are attached, and how many hold a route |
queue.queued_bytes_current / queued_frames_current |
how much is waiting, under BOTH per-recipient bounds — bytes and frames are different faults with different remedies |
queue.slow_consumers_current / slow_consumer_events_total |
who is falling behind, counted BEFORE anything is dropped |
drops.* |
WHY a delivery was refused: recipient_queue_full (one slow reader), global_egress_full (the hub is saturated), channel_full (a burst deeper than the queue), write_failed (accepted then unwritable), offline_unknown |
denied.* |
peer credential, connection ceiling, hello, malformed, forged from, rate limit |
fanout_latency_us, wake_latency_us |
count / mean / max / p50 / p99 — fan-out is the hub-internal hand-off span, wake latency is mint-to-delivery |
Two properties are load-bearing. The histograms are fixed-bucket and
allocation-free — an observability surface the hub can be made to allocate
would be a denial-of-service surface, not observability — so a bucketed
quantile is reported as the containing bucket’s UPPER bound, a conservative
over-estimate that can make the hub look slower than it is and never faster.
And a quantile with no observations behind it is null, never 0: “no
traffic yet” and “instantaneous” are different facts and an alert rule must be
able to tell them apart. Mint-to-delivery crosses a wall-clock boundary, so it
is advisory: a wake with no stamp records nothing, and a peer whose clock runs
ahead records 0 rather than an underflowed enormous value.
Shutting it down — the bounded drain
On SIGTERM or SIGINT, in this order and no other:
- Stop accepting. The listener closes first, so a peer arriving mid-shutdown is refused by the kernel rather than accepted into a hub that is about to stop reading it.
- Ask every session to go. Each reader is woken and each writer gets its
close sentinel. Nothing content-bearing is emitted — no goodbye frame,
no last wake. The hub holds no durable truth, so there is nothing it could
owe a peer at shutdown; the committed inbox row and the
<=60 sbackstop poll are the guarantee, exactly as at every other moment. - Wait, bounded. At most 5 s (
wake_hub::limits::DRAIN_DEADLINE_MS) for the connection gauge to reach zero, then exit anyway with a WARN naming the residual. An unbounded drain is a hungsystemctl stopthat ends inSIGKILL— strictly worse, because the socket then survives the process. - Unlink our own socket, and only ours. The path must still be a socket
AND carry the
(device, inode)this process created. Without the inode check, a hub slow to drain while its replacement had already bound a fresh socket at the same path would delete the REPLACEMENT’s socket, and every agent on the host would be talking to a live process through a path that no longer exists. When ownership cannot be established the file is left for the next start-up probe, which connects to it before unlinking anything.
The process exits 0 after a completed drain, so systemctl stop and
launchctl bootout do not record a failure for the thing they asked for.
Supervisor units
| Platform | Template |
|---|---|
| systemd | packaging/systemd/ai-memory-wake-hub.service |
| launchd | scripts/templates/dev.alphaone.ai-memory.wake-hub.plist |
Both pin the file-descriptor budget to wake_hub::limits::DESIRED_NOFILE
(4096) — LimitNOFILE= on systemd, SoftResourceLimits/HardResourceLimits
NumberOfFiles on launchd. This is the point of the templates: macOS
ships a default soft RLIMIT_NOFILE of 256, which lands EMFILE at exactly
the 256-agent scale the hub is designed for. At start-up the hub raises its own
soft limit toward that value where the hard limit allows, sizes its connection
ceiling from what it actually got (WARNing when that is below the target), and
REFUSES to bind at all when the budget cannot cover MIN_CONNECTION_CEILING
connections plus FD_HEADROOM descriptors — a smaller hub is honest, a hub
that lies about its capacity is not.
Wire identity domain (#3578). a2a-hub and every a2a-hub/... scoped
form are reserved by validate::RESERVED_AGENT_IDS and its scoped-form
check. HTTP header/body claims and MCP explicit caller claims are refused
through the shared identity validator, even when the claimed value agrees
with the resolved caller. Ordinary principal names such as ai:alice and
a2a-hub-agent retain their existing grammar. The allowed/refused resolver
matrix is pinned by tests/wake_hub_identity_domain_3578.rs. This wire
reservation does not change the internal-bootstrap shape-only validator.
Credential and write-authority pins (#3578, #3579).
tests/wake_hub_write_authority_3578.rs first admits the exact delegation
through the production hub hello verifier using a backend-derived possession
binding. That delegation’s wire envelope, root signature, and delegated public
key cannot replace an enrolled HTTP API key. A hub-domain signature or a memory
write signed by its delegated key cannot replace the enrolled root’s memory-write
signature. Denied requests leave the complete memory-row snapshot unchanged;
allowed controls persist the independently resolved caller. Forged wake
principal/sender/from and namespace fields do not select notification authority
or its destination namespace. PostgreSQL tests require a live database and
also check that the SQLite shadow receives no write.
HTTP tests use the real router in per-agent API-key Enforce mode. MCP tests
use the existing production-handler test entry and its host caller context;
MCP has no API-key login. The PostgreSQL MCP path forwards over a real loopback
HTTP listener requiring an enrolled-root write signature. That listener has
no API-key gate because the existing forwarder sends only X-Agent-Id; the
separate HTTP credential cases cover that gate. These pins do not claim stdio
framing, a sandbox, or authentication of arbitrary operator code.
tests/http_notify_caller_binding_3579.rs additionally pins the exact HTTP
sender in both ambient-identity postures on SQLite and live PostgreSQL, and
preserves MCP’s host identity ladder and validation order.
Binary hint codec pins (#3578). tests/qual_wake_meta_codec_3578.rs,
sdk/python/tests/test_wake_meta_3578.py, and
sdk/typescript/__tests__/wake_meta_3578.test.ts consume the same
sdk/fixtures/wake_meta_3578.json vectors. They pin the five metadata fields,
empty and UTF-8 hints, a valid 256-byte encoding, 257-byte refusal, appended
content/title refusal, every truncation of each valid vector, and reserved
body kinds 11/12/13 with an allowed wake control. Rust also checks exact
re-encoding and refuses an oversized hint at encode time. These tests cover
the binary hint; handshake/control frames have their own bounds. They do not
assert a 256-byte limit on the CLI reporting JSON or a JSON-input decoder.
JSON output pins (#3578). tests/qual_wake_json_3578.rs pins the exact
closed output key set: inbox_row_id, namespace, sender, digest,
seq_high_watermark, reason, hub_driven, agent_id, hub_id, missed,
pending_count, and inbox_count. No content, title, body, or payload
key is admitted. Hint values and all reporting values are checked, including
bare signals and metadata text that resembles JSON keys. The serialized
reporting envelope may exceed 256 bytes; the ceiling applies to encoded binary
WakeMeta. The #3578 tests in wake_client::session deliver the shared denied
binary vectors and reserved kinds through a socket pair into the production
Session::next_event: refusal produces no renderable event, while valid hints
produce exactly the expected metadata. This tests the boundary after admission,
not credentials, database authority, JSON input validation, or process isolation.
Dependency gate (#3578). tests/qual_wake_hub_zero_authority_3578.rs
walks every Rust source under src/wake_hub, scanning all feature branches
and resuming after complete inline test modules. Its reviewed crate-edge
allowlist admits the public delegation verification vocabulary, snapshot-age
constant, binding-authority type, producer sentinel, and shared visibility
predicates. identity::keypair::decode_public_base64 is the sole keypair
exception; its public decoder body and imported dependencies are pinned.
Module/glob imports cannot widen that exception. Mutation fixtures cover
aliased/grouped imports, fully qualified paths, the production tail after
tests, database/configuration literals, signing material, and source inclusion.
This lexical qualification gate does not prove filesystem isolation or replace
the OS permissions below; runtime credential refusal and content-plane checks
are separate controls.
Forward-binding content-plane allowance (#3578).
tests/qual_wake_content_boundary_3578.rs runs in the qualification family.
Its reviewed source manifest covers the hub, both producer sinks, Rust client,
CLI consumer, wake bus, spawn-audit wrapper, and Python/TypeScript listeners
(including the Python swarm adapter). It admits the existing flow:
- A committed notify emits metadata to the bus; the in-process and UDS sinks
encode a
WakeMetahint. The producer’s inbound hub frames handle liveness and refusals; they do not dispatch memory writes. - The Rust client decodes a hint into a signal.
wake-listencallscatch_up_readwith the operator-resolved listener identity, through the existinghandle_inboxfunnel. The hint never selects that identity. Rendering receives metadata and the resulting count, not inbox content. - An operator-supplied
--execcommand goes throughspawn_audit::audited_tokio_command. This explicitly admitted edge can open the seeded audit database and append a content-free signed spawn-audit row. The elevenAI_MEMORY_WAKE_*fields attached by the listener are pinned by exact key and value expression: reason, listener identity, hub identity, row ID, namespace, sender, digest, sequence, missed/pending counts, and inbox count. No hint content/title becomes an environment value or shell program.
The hook and SDK on_signal / onSignal callbacks are external operator
trust boundaries, not SDK-screened content paths. They may independently
read or write using their own credentials. The hook also inherits its operator
environment; the pin covers the fields the listener attaches, not a sanitized
process environment. GA has no automatic A2A decoder-to-notify/store content
write in these reviewed paths. A future payload decoder requires a reviewed
SDK-edge screen and an explicit allowlist change; an operator callback is not
that screen.
The manifest is a conservative source-change gate: Rust production tokens
(including imports, all feature branches and code after inline test modules)
and SDK source bytes must match, and the three Rust directory inventories must
remain exact. Rust formatting/comments and complete inline cfg(test) modules
may change without updating the pin. Mutation tests refuse added writes and
aliases, caller substitution, content/title environment fields, command
substitution, SDK callback-to-write changes, and production appended after
tests. Updating a digest requires boundary review, even for a harmless code
change; do not regenerate the manifest merely to make a gate green. This is
not a compiler call-graph proof, macro-expansion analysis, proof of unchanged
external dependency implementations, or a sandbox for arbitrary application
callbacks. Runtime codec/credential tests and the OS isolation contract remain
separate evidence.
Process isolation (#3578). The systemd unit runs as User=ai-memory-hub
(not the daemon’s ai-memory), jails /var/lib/ai-memory with
InaccessiblePaths=, and restricts the address family to AF_UNIX: the wake
plane is same-host by construction, so the kernel enforces that as well as the
code. A TCP listener is not shipped; an operator drop-in that added one
would have to add both a listen flag and AF_INET, and
tests/wake_hub_process_isolation_3578.rs refuses a unit that names either
today. The launchd template names UserName/GroupName ai-memory-hub,
which launchd honours only for a LaunchDaemon under /Library/LaunchDaemons;
the ~/Library/LaunchAgents copy is a dev convenience that does not satisfy
the contract. Create the system user before enabling the unit:
sudo useradd --system --home /run/ai-memory-hub --shell /usr/sbin/nologin ai-memory-hub
Its ExecStartPost runs --health, so a unit that reports “started” has
actually been reached — a claim that holds only because that probe retries.
Type=simple lets systemd run ExecStartPost as soon as it has forked the
main process, before the hub has bound, and the hub sends no sd_notify; the
unit therefore retries the probe for about five seconds across the bind race
and fails the unit only if the hub is still unreachable after that. A
single-shot probe would fail every start and, with Restart=on-failure,
convert a healthy host into a restart loop. The unit also sets
RuntimeDirectoryPreserve=yes, so the allowlist snapshot the refresher
install(1)s at /run/ai-memory-hub/hub-allow.json survives a stop or restart
instead of being deleted with the runtime directory — which would leave the
restarted hub admitting nobody. /run is a tmpfs, so that path still does not
survive a reboot; republish it after boot.
ai-memory doctor
doctor carries a Wake hub (#3471) section — filesystem and getrlimit
only, no bind, no connect, no database. A live socket whose mode or ownership
is wrong is Critical (that is an exposure on this host right now); a
file-descriptor budget below the desired value on a host that runs a hub is a
Warning, escalating to Critical only below the floor at which the hub would
refuse to start; an installed supervisor unit is informational and its
absence is never a finding, because running the hub in the foreground or under
another supervisor is a legitimate deployment. On a host with no [wake_hub]
configuration and no socket on disk the section reports configured = no and
nothing else.