Catalog of business events carried on the daemon's event bus. Naming and
authoring rules live in agent-rules/events.md —
in short: subject.past-tense-fact, emitted post-commit, facts not commands.
Status: planned catalog — update the Status column as events are implemented, and add new events here in the same change that introduces them.
The event
idis a one-time exception to additive-only for the event history, taken by ADR 0014 the way ADR 0004's was: every envelope gains a requiredid, and a stored event without one is not replayed. History from before the upgrade is not shown.
The payload removals in the lease rows are a deliberate exception to events rule 6 ("treat payload shape as a public contract: additive changes only"), granted by ADR 0004 in its Consequences:
lease.grantedlosesmodeandlease.releasedloses theclosedandorphanedreasons, since neither concept exists any more — there is no connection close to release on, and no startup sweep to orphan anything. The ADR takes that exception once, while the package is 0.x; this note records it here so the catalogue does not read as a silent rule violation.ADR 0007 §13 takes the same exception once more:
lease.requestedandlease.rejectedcarry the request, anddevice.provisionedthe spec, sofullleaves those payloads andmodeenters (requestSpec.mode,spec.mode).ADR 0015 §9 takes it a third time:
lease.requestedandlease.rejectedcarry the request as it arrived, sorequestSpec.modelbecomes optional (a request naming a class or nothing has none) andrequestSpec.classenters. Making a required key optional is not additive. The exception coversrequest.dispatchedtoo, which names the same request:modelbecomes optional andclassenters, for the same reason.ADR 0021 narrows ADR 0014 §6's "a worker's own events ... show what they always showed": a worker's events for a gateway's dispatch (a probe) carry
fleetRequestId, and a refused probe islease.declined, notlease.rejected. Its events for local requests are unchanged.
| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
lease.requested |
request id, request spec (platform, model only when the request named one, class when it named one -- a request naming neither carries neither, ADR 0015 §9, os version -- an exact version or an OS range as typed, ADR 0015 §2, device mode as the request named it -- absent when it named none, since the worker's default is applied after this event, ADR 0007 §2; imageTag when the request named one, #214, additive; physical: true when the request asked for an enrolled physical device, ADR 0022, additive), requester, wait policy, fleetRequestId on a worker's event for a gateway's dispatch (a probe; ADR 0021 §3, additive, events rule 6) |
a lease request is accepted by the daemon and stored; the request id is the stored request's, so an observer can match this event to it (#72 added the id; additive, events rule 6) | LeaseAcquisitionCoordinator (worker) / FleetLeaseCoordinator (gateway, ADR 0005 §11 — its own fleet queue's admission, before any worker is chosen) | implemented |
lease.queued |
request id, queue position (counted in the request's own line, ADR 0022 §3: a physical request waits apart from simulator and emulator requests) | no capacity; request entered the wait queue | LeaseAcquisitionCoordinator (worker) / FleetLeaseCoordinator (gateway) | implemented |
lease.granted |
lease id, device id, requester, request id, source (warm/booted/provisioned, ADR 0016 §2: the kind of the plan that granted it -- an eviction never grants by itself, so the plan made after it decides; a physical grant is warm, ADR 0022 §3), fleetRequestId when the grant served a probe (ADR 0021 §3, additive), the device's platform (ios or android) |
a device was assigned and handed out | LeaseLifecycle | implemented (payload per ADR 0004 pending) |
lease.renewed |
lease id, new deadline | a lease.renew succeeded — whether it came from simlock lease renew, POST /v1/leases/{id}/renew, or the renew timer a running simlock lease / MCP session keeps over its own lease. There is one renew path and this is it |
LeaseLifecycle | implemented (payload per ADR 0004 pending) |
lease.released |
lease id, device id, reason (explicit/killed/device-lost), owner id | an explicit lease.release (which is what a simlock lease holder does on its way out), (killed) an operator release --all or nuke, or (device-lost) a leased device could not be recovered after it stopped running outside simlock, or a daemon start found its device not running, or (device-lost, ADR 0022 §7) a physical device was not present for the grace period (health.stableObservations reads in a row, health.probeIntervalMs apart; emitted by the PhysicalDeviceWatcher through LeaseReleaseCoordinator.endForAbsentDevice; the device becomes absent in the same write and nothing is wiped), and the device of that lease was wiped and returned to the pool, left waiting in reclaiming on a platform the daemon could not list, or marked missing. Closing a connection is not a release and never emits this |
LeaseLifecycle | implemented (payload per ADR 0004 pending) |
lease.expired |
lease id, device id, owner id | the lease's deadline passed with no lease.renew behind it — the grant-time TTL, or the TTL of the last renew, simply ran out. This is the one way a lease ends without somebody asking, and the only bound on a holder that was killed outright |
LeaseLifecycle | implemented (payload per ADR 0004 pending) |
lease.rejected |
request id, requester (both required on every reason, additive, ADR 0016 §2), request spec (as on lease.requested; full replaced by mode, ADR 0007 §13; model optional and class added, ADR 0015 §9; osVersion may be a range as typed, ADR 0015 §2), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/lease-id-taken/boot-timeout/killed/updating/cancelled/daemon-restarted/worker-failed), code and worker on worker-failed (ADR 0021 §4, additive; worker, never workerId, which marks a relayed event, ADR 0014 §6) |
a request ended without a grant. A worker emits it only for its local requests; a probe ends in lease.declined there (ADR 0021 §2). A gateway emits it for every ending that is not a grant (ADR 0021 §4): its own reasons, unresolvable-spec for the last cannot-serve refusal whether or not the request had queued, and worker-failed for any other failure on the worker it went to -- a terminal refusal, a failure after progress, WORKER_UNREACHABLE, INTERNAL, a dispatch timeout, a worker's own REQUESTER_ALREADY_LEASED or LEASE_ID_TAKEN -- with code the error code the caller got and worker the worker. A worker's PROVISION_FAILED before it pushed progress is not among them (after progress it is a failure after progress, relayed as it is): the gateway walks on to the next worker, and only when every worker that could take the request has failed so does it end worker-failed with code PROVISION_FAILED and the last worker (#637). dispose() emits nothing, so a request still open when a gateway stops has no ending event (ADR 0021 §5 plans for usage to close those at the next daemon.started; not built yet). already-leased means the requester already has a waiting request on the same platform, or holds lease.maxPerRequester leases in scope (ADR 0023): the requester's leases of the request's platform when the limit is per platform, all of its leases when it is a number, where the leases it waits for count too; a worker skips the held-lease count for a probe, so it declines a probe already-leased only for a waiting one on its platform. A request refused at admission (killed, updating, already-leased, lease-id-taken) was never stored and has no lease.requested (except a gateway's lease-id-taken after a grant, below); it carries the id minted for it before the check, the one the stored request would have had; on a gateway (ADR 0009 §4, §8) no-worker is rows 1 and 5 of the fast-fail table (NO_CAPACITY: no worker takes requests, or none that does can serve it) and unresolvable-spec rows 2 to 4 (NO_DRIVER, UNKNOWN_MODEL, RUNTIME_MISSING) and CAPABILITY_MISSING (a request whose requires no connected worker able to serve it meets with every name available, run on the table a second time on the capable views; ADR 0026, narrows ADR 0009), emitted in the dispatch walk before the request is queued, so a request rejected on arrival emits no lease.queued, and a waiting one is rejected when the views change (additive, events rule 6). A request a worker refused with one of those codes or with CAPABILITY_MISSING (ADR 0009 §5, ADR 0026) and the table later ends with that stored refusal gets a gateway unresolvable-spec, whether or not it had entered the gateway queue (the worker only declined it, ADR 0021 §4); daemon-restarted is a request still waiting when the daemon stopped, settled as failed when it starts again (#72; widens a published vocabulary, which events rule 6 allows as additive). The reason list can grow: a consumer must tolerate a reason it does not know; cancelled is an explicit single-request cancel (cancelPending on the leasing module, backing DELETE /v1/lease-requests/{id}) of a still-queued waiter -- one with device work already in flight is reported not-cancellable instead, the same envelope the queue timeout already uses; a physical waiter has no device work, only a presence read, so it is cancelled in any state |
LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented |
lease.declined |
{ requestId, fleetRequestId, requester, requestSpec, reason } -- reason takes lease.rejected's values; requestSpec is there for the same reason as on lease.rejected: a decline at admission has no lease.requested (ADR 0021 §2) |
a worker refused or failed a probe, a lease.request carrying fleetRequestId (ADR 0021 §1), whatever the reason (no-wait, unresolvable-spec, already-leased, lease-id-taken, boot-timeout, killed, daemon-restarted at the next start) and however far the work got. The gateway owns the outcome of a probe, so the worker never rejects one and never queues one: where it would queue (a second failed provision) it declines and answers PROVISION_FAILED. The event depends only on the request being a probe, never on whether the gateway will retry. A local request is rejected exactly as before |
LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) | implemented |
On a gateway, lease.requested, lease.queued and lease.rejected are its own fleet queue's facts (ADR 0005 §11/§14),
emitted by FleetLeaseCoordinator and never by the worker whose device is
eventually granted — lease.granted/renewed/released/expired for a fleet
lease arrive already relayed from the owning worker (see "Fleet (gateway
mode)" below), so a gateway never emits those four itself. Every request a gateway ends while it runs has one ending of its own (ADR 0021 §4): request.granted, or lease.rejected. already-leased
on a gateway is the fleet-wide one-lease-per-requester check (§14, keyed on
requesterId), answered from the gateway's own lease index before any
worker is ever contacted. lease-id-taken (ADR 0020; widens a published vocabulary, which events rule 6 allows as additive) is a leaseId an active lease or a waiting request already holds; on a gateway it is the gateway's own leases and requests, checked the same way, and a worker's own refusal of it is that worker's lease.declined and, on the gateway, a lease.rejected with reason worker-failed and code LEASE_ID_TAKEN (ADR 0021 §4). A gateway also emits it when a grant arrives for a bare id its index already routes to another worker: the new worker's lease is released and the request ends with it.
| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
capacity.changed |
per platform (ios, android) and global: running, max running, reserved, warm; ramBudget (used bytes, limit bytes) only under the resource capacity strategy |
a worker's capacity figures differ from the last ones emitted: after a registry commit, or after a provisioning or boot reservation is taken or released. Emitted once when the daemon has started, so every run begins with a step; two consecutive events never carry equal figures. The figures are the ones simlock status reports |
CapacityObserver (worker) | implemented |
queue.changed |
depth, physical: true when the physical line changed (ADR 0022 §3, additive, events rule 6) |
the number of requests waiting in a queue changed: a request joined it or left it. A queue has two lines; depth is the changed line's, and the event without physical is the virtual line's. Emitted once when the daemon has started. A worker emits it for its own queue and a gateway for its fleet queue; a worker's events a gateway relays carry workerId, and the gateway's own do not |
WaitQueue (worker) / FleetLeaseCoordinator (gateway) | implemented |
warm-pool.target-missed |
{ platform, model, osVersion?, mode, origin, count, ready, reason } — origin is always configured: the event is for the warmPool.targets entries, never for a kind the pool learned, and count is the configured count (ADR 0038 §8); osVersion is the resolved one, or the configured one for a target that did not resolve, and absent when it names none and did not resolve; reason is the one reason the pool records for the target (warm-pool/policy.ts for one that resolved, warm-pool/converger.ts for one the resolver refused), the one short carries in status.get (the one reason simlock status shows as the target's short, but never disabled, since a pool that is off reports no target: no-driver, runtime-missing, unknown-model, unresolvable, boot-failed, device-limit, running-limit, reserve or ram-budget) |
a warm pool target went short: after a pass the pool could do nothing for a target that has fewer ready devices than its count, and it was not already missed (ADR 0017 §7). Emitted once the pass's actions have run (events rule 3), once per report on that edge (targets that resolve to one spec are one report and emit one event between them, with their counts added; each target the resolver refuses emits its own), not on each short pass after, nor when a retry or a boot fills it and it is short again; the edge is kept per configured target, and only ready devices reaching its count end the miss, so a target met in between and missed again emits again. A target that is filling, held by warmPool.maxConcurrentBoots or waiting behind a queued request the pool could not serve is not short |
WarmPool (worker) | implemented |
| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
device.provisioned |
device id, spec (physical: false, always, ADR 0022 §1: a physical device never emits this event, additive; mode: "slim" for a device planned slim, absent for a full one -- the one place the planned mode is visible, ADR 0007 §9; imageTag for a device whose request named one, #214, additive), driver, duration |
driver provision committed to registry |
Registry | implemented |
device.ready |
device id, boot duration, the device's platform (ios or android) |
readiness probe passed (on iOS simulators, boot completion and a successful installer probe) | Registry | implemented |
device.reclaimed |
device id, strategy (erase/snapshot/wipe/uninstall, ADR 0022 §4), duration, missingApps for a physical device (enrollment-list apps no longer installed: reported, not reinstalled; additive) |
fresh-state reclaim finished. Never emitted for a device created under lease.identity fresh (#75): nothing is reclaimed, the device is deleted instead |
Registry | implemented |
device.purge-failed |
device id, lease id, attempted strategy (erase/snapshot/wipe/delete/uninstall), duration, stable error summary | release-time purge failed (including the deferred wipe a daemon start runs in the background, ADR 0019 §2; a physical device read not present emits nothing and becomes absent), or (strategy delete, #75) the shutdown or delete that ends a fresh device's lease failed; the device enters quarantined (see below) rather than rejoining the pool. delete widens a published vocabulary (events rule 6 allows additive changes): a consumer must tolerate a strategy it does not know |
ReclaimCoordinator (strategy delete: ReclaimCoordinator's spent-device path) |
implemented |
device.quarantined |
device id, max retries, next retry deadline, platform, cause kind (purge-failed, shutdown-failed, delete-failed or stalled), stalled state (stalled only), message (the other three, at most 300 characters) |
a device committed to quarantined — present in the registry, still counted as running when it is a virtual device, not eligible for a grant. Fires immediately after device.purge-failed for a release-time purge or delete failure (reclaiming/shutdown → quarantined), or on its own for a stalled-transition timeout (see device.stalled-transition-detected), for a returned physical device whose reclaim failed while it was present (ADR 0022 §8: there is no lease for device.purge-failed to name), or for a shut-down device whose boot for a waiter failed and whose destroy failed too (#403: shutdown → quarantined, the lease engine handing it over lease-less; cause delete-failed) |
QuarantineCoordinator | implemented |
device.quarantine-recovered |
device id, attempts, reclaim strategy (erase/snapshot/wipe/uninstall), missingApps for a physical device (additive), the device's platform (ios or android) |
a quarantined device's retried purge succeeded; it returned to ready/shutdown and rejoined the warm pool. Never emitted for a spent fresh device (mayBeGranted false): its retry is a delete, and a successful one emits device.deleted with initiator lease-end |
QuarantineCoordinator | implemented |
device.quarantine-abandoned |
device id, attempts, the device's platform (ios or android) |
a quarantined device exhausted its configured retry budget (warmPool.quarantine.maxRetries) — purge retries, or delete retries for a spent fresh device — and was destroyed, or, for a physical device, unenrolled: Simlock never destroys one (safety rule 2, ADR 0022 §4) |
QuarantineCoordinator | implemented |
device.quarantine-stranded |
device id, attempts, stable error summary, the device's platform (ios or android) |
a quarantined device exhausted its retry budget (purge or delete retries) and the destroy that should have retired it also failed; it stays quarantined with no further retry until an operator intervenes |
QuarantineCoordinator | implemented |
device.shutdown |
device id, initiator (rule/command; warm-pool when the warm pool shut an idle device down, over the running limit, with warmPool.enabled: false, to keep warmPool.reserveRunning running slots free, or because a ready device that never served a lease and that no warmPool.targets entry and no kind the pool learned from a lease keeps has been idle past idle.shutdownAfterMs, or to displace it: an older leased kind's idle device shut down so a newer kind can boot into its running slot) |
device stopped, still on disk | Registry; ReclaimCoordinator for interrupted reclaim recovery | implemented |
device.deleted |
device id, initiator (lease-end when a fresh device's lease ended and its delete completed — from ReclaimCoordinator, at startup convergence, or retried from quarantine; #75. doctor when doctor --fix marked a missing device, or startup's lease reconciler found a leased device absent from a platform it could read and ended the lease in the same write, ADR 0019 §2. operator when device remove unenrolled a physical device, ADR 0022 §2. quarantine when a physical device's quarantine retries ran out and it was unenrolled, ADR 0022 §4; lease-engine when a device that failed to boot for a lease was destroyed in cleanup; cleanup-reaper when the cleanup reaper destroyed a device; nuke when nuke destroyed it), physical (the record's own flag: true for an enrolled physical device, false for a simulator or emulator; additive, events rule 6) |
device removed from the registry, and from disk for a virtual device; a physical device is only forgotten and nothing is sent to it | Registry | implemented |
device.enrolled |
device id, platform, driver device id (the canonical ID), model, OS version, class (phone/tablet), app count, reenrolled | an admin's device add wrote a physical device ready (ADR 0022 §2). reenrolled is true when the device already had a ready, quarantined or absent record, which kept its registry ID and took a fresh read of model, OS version, class and apps |
Registry | implemented |
device.absence-detected |
device id, previous state (ready/leased/reclaiming), absent reason (locked/untrusted/not-present) | an enrolled physical device became absent (ADR 0022 §2, §7, §8): the PhysicalDeviceWatcher read it not present health.stableObservations times in a row (a leased device's lease ends first, lease.released device-lost, in the same write), or ReclaimCoordinator found it not present after a failed reclaim (reclaiming → absent, no device.purge-failed, no device.quarantined). Emitted after the commit |
Registry (the watcher's writes, the reclaim's failure path) | implemented |
device.returned |
device id, OS version | the watcher read an absent physical device present, and the return reclaim it started committed absent → reclaiming with the OS version the read reports (ADR 0022 §7). Emitted after that commit; the reclaim then emits device.reclaimed |
Registry (written by ReclaimCoordinator's return reclaim) |
implemented |
device.foreign-state-detected |
device id, platform, expected (running/stopped), observed (running/stopped) | doctor reconcile found a managed device's observed boot state disagreeing with the committed registry state | Doctor | implemented |
device.foreign-provenance-detected |
device id, platform, detail (erased/mark-mismatch/durable-mark-missing) | doctor reconcile found a managed device's provenance marks no longer proving Simlock owns it | Doctor | implemented |
device.stalled-transition-detected |
device id, platform, state (provisioning/reclaiming), age, threshold | doctor reconcile found a provisioning/reclaiming device whose time in that state exceeds a driver-derived threshold (stalledTransition.thresholdMultiplier over Driver.estimate, floored at stalledTransition.minimumThresholdMs) — the driver call meant to resolve the transition never did |
Doctor | implemented |
device.crash-detected |
device id, lease id, platform, observed | a leased device was observed stopped for health.stableObservations consecutive ticks |
LeaseHealthMonitor | implemented |
device.recovered |
device id, lease id, attempts, duration, the device's platform (ios or android) |
a crashed leased device was rebooted under its existing lease and passed readiness | LeaseHealthMonitor | implemented |
device.recovery-failed |
device id, lease id, attempts, reason, error, the device's platform (ios or android) |
recovery could not restore a leased device (absent from driver reality, provenance drift, or attempts exhausted) and its lease was released | LeaseHealthMonitor | implemented |
device.orphan-purged |
driver device id, platform, device root | simlock doctor --purge-orphans destroyed a device that sat inside a validly-marked Simlock device root with no registry record — see ADR 0001 |
Doctor | implemented |
device.slimmed |
device id, address, platform (ios), categories, label count, duration, signature, unknown labels | after the post-slim reboot's bootstatus and installer probe succeeded -- i.e. once the launchctl disable overrides applied via simctl spawn are actually in force on the simulator |
driver-diagnostics | implemented |
device.slimmed reports a fact committed to the simulator's own launchd database, not to the
Simlock registry (ADR 0002, docs/internal/adr/0002-opt-in-slim-ios-simulators.md) -- so events rule 3 ("emit post-commit
only") is satisfied by waiting for that commit to become observable, not by waiting on a registry
write: the driver applies the launchctl disable overrides, reboots the device, and only fires
onSlimmed once the second bootstatus and installer probe have passed, proving the overrides survived the reboot and
are actually in force. The registry's own device.ready for that same boot is a separate,
later event, emitted through the normal readiness path once the driver call returns. A slim
request on a runtime older than iOS 18.5 is resolved to a full spec before planning (ADR 0007
§4), so it never reaches a slim pass. A skipped slim on a slim-spec device -- its runtime id
didn't parse, or the disable pass itself failed -- is deliberately not an event: it isn't a fact worth putting in front of every event-bus
consumer, just operator diagnostics, so it's a warn log line (daemon.driver-discovery) instead.
| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
component.install-started |
platform, component id (the string the install was asked for: an iOS runtime version, latest, or a bare major; an Android API level), requester id (of the request that started the install, when known: a lease request's requester, or the principal that ran component install) |
once per install, after the free-disk check passed and disk was set aside, just before xcodebuild -downloadPlatform / sdkmanager --install runs |
component-installer | implemented |
component.installed |
platform, component id, version (the exact version now installed), already present (true when the installer ran and found the component already there), duration, requester id |
once per install, when the installer finished and a fresh read confirmed the component — never on a bare exit 0 | component-installer | implemented |
component.install-failed |
platform, component id, duration, stable error summary, requester id | once per install, when it failed: the installer failed (a license retry included), it ran out of downloads.timeoutMs, the daemon stopped, the installer exited 0 but a fresh read could not confirm the component, or the component installed but its record could not be stored |
component-installer | implemented |
component.removed |
platform, component id (the version the removal was asked for), version (the exact version removed), size in bytes (when it could be read), residue (when something stayed on disk: what, and how to reclaim it), requester id (the admin principal that ran component remove) |
once per removal, after the platform's own removal (simctl runtime delete / sdkmanager --uninstall) finished, a fresh read confirmed the component is gone, and Simlock's record of installing it was deleted — never for a dry run or a refused removal |
component-installer | implemented |
ComponentInstaller (src/core/component-installer.ts) is the only emitter (ADR 0010 §3):
drivers install but never emit (architecture rule 5). The events fire once per install, not
once per request that joined it, and only for an install that reached the driver -- a call
that ends as already-installed from findComponent, as not needed, or refused by the disk
reservation (InsufficientDiskSpaceError) emits nothing. component.installed is emitted after
the component record is committed to the registry (events rule 3); with alreadyPresent: true
nothing was recorded (ADR 0010 §5). requesterId is the requester whose call started the
install. flows/device-selection.md shows how a missing
component gets to this point. The requester hears the install through its own downloading
progress stage, a direct call chain from the installer, not these events (architecture rule 5).
component.removed has the same single emitter (ADR 0010 §8). ComponentInstaller.remove
emits it after the driver's removeComponent returned and the component record was deleted
from the registry (events rule 3); a dry run, a refusal (COMPONENT_NOT_OWNED,
COMPONENT_IN_USE, COMPONENT_BUSY) and a driver failure emit nothing, and a failure keeps the
record. requesterId is the admin session's principal, never a caller-supplied id (safety rule
6). residue is the driver's text, carried unread: on iOS it names the never-used default-set
simulators the removal left unavailable and the asset-store download simctl runtime delete
left behind (#79), each when there is one.
| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
daemon.started |
version, config snapshot (gateway.token, when set, reads [redacted]: the payload reaches the ring and history.db, which are not a token's place the way config.json and config.get are; masked rather than dropped so the keys stay the same, #170) |
daemon finished startup + reconcile | DaemonServer | implemented |
daemon.stopping |
reason | graceful shutdown began (reason signal when the process got SIGTERM, ADR 0041 §4) |
DaemonServer | implemented |
daemon.update-started |
from, to, forced | an update began: daemon.update resolved the version and entered waiting or installing (ADR 0042 §11); forced is whether it was asked with force at the start |
UpdateCoordinator | implemented |
daemon.update-failed |
stage (installing or restarting), from, to, message (at most 2000 characters) |
an update ended without a new daemon: the updater reported a failed line or exited non-zero while this daemon runs (it serves again), or a rolled back update's result file was read at start (ADR 0042 §7, §8) | UpdateCoordinator | implemented |
disk.pressure-detected |
free bytes, threshold | free disk crossed under the configured threshold (edge-triggered: once per crossing, not once per tick while it persists) | CleanupReaper | implemented |
cleanup.executed |
rule name, action, target, reason | cleanup executor committed a proposed action | CleanupExecutor | implemented |
doctor.reconciled |
drift findings | daemon reconciliation completed | Doctor | implemented |
driver.root-rejected |
platform, root path, reason (not-absolute/missing-marker/invalid-marker/wrong-instance/symlink/wrong-owner/wrong-permissions/non-empty-unowned-root/unreadable/invalid-device-set/device-set-changed) | a driver's device root failed ownership validation at startup, or (iOS) drivers.ios.deviceSet was not simlock or default, conflicted with drivers.ios.deviceRoot, or was switched with simulators left in the set being given up (ADR 0048; root is then the configured value or the set holding them), so that platform's driver did not start |
DaemonServer | implemented |
driver.adb-server-rejected |
port, reason (occupied/start-failed/invalid-port) | Simlock's own adb server could not be established — the port was occupied by a server it does not own, the server it started never began listening, or the configured port is not usable — so the Android driver did not start | DaemonServer | implemented |
These are the facts a daemon running as a gateway emits about the fleet connected to it (ADR 0005). A worker never emits them — it has no workers or fleet queue of its own — and a gateway emits none of the device-lifecycle facts above on its own behalf, because it owns no devices.
| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
worker.connected |
worker id, label, worker's daemon version | a worker's uplink opened and its hello completed, so the gateway can drive it. A worker whose hello found no overlapping protocol range emits nothing: it is in the registry as incompatible, which is where an operator finds it |
WorkerRegistry | implemented |
worker.rejected |
reason (unauthenticated/forbidden/worker-mismatch), worker id, label, occasionally a count |
an uplink was turned away at the door, before any session existed: unauthenticated (401) for a missing or unrecognized join token — a revoked or mistyped one lands here — forbidden (403) for a real token whose role is not worker, and worker-mismatch (403) for a join token pinned to another worker id, a worker id another token is pinned to, or an empty claimed id (a token is pinned to the first worker id that joins with it; token revoke frees both). Version skew is deliberately not one of these: that uplink authenticated, so the worker enters the registry as incompatible and emits nothing (see the note below the table). A refused peer proves no identity, so workerId and label are only what the connection claimed and may be absent. count, present only above one, is how many identical (reason, claimed id) refusals the previous coalescing window absorbed — GatewayService computes it before calling into the registry, so a flood of refused dials collapses to one event per window rather than one per attempt, since the ring buffer they land in is bounded by count, not bytes |
WorkerRegistry | implemented |
worker.disconnected |
worker id, label, lease count | a worker's uplink closed. leaseCount is what its view still shows it holding — how much is stranded, and why the view is kept rather than dropped, not ended: the gateway never guesses a lease is gone before the worker says so |
WorkerRegistry | implemented |
worker.removed |
worker id, label, reason (operator/retention) | a disconnected worker's view was forgotten: by simlock worker remove, or because every gateway-issued lease on it had expired and gateway.disconnectedRetentionMs elapsed. Never emitted for a connected worker (WORKER_CONNECTED refuses that) |
WorkerRegistry | implemented |
worker.drain-started |
worker id, label | simlock worker drain flagged a worker: it keeps its leases and receives no new dispatches |
WorkerRegistry | implemented |
worker.drain-ended |
worker id, label | simlock worker undrain cleared that flag. Nothing else ends a drain: the flag lives in the gateway's persisted worker registry, so it survives both a worker reconnect and a gateway restart |
WorkerRegistry | implemented |
request.dispatched |
{ requestId, workerId, requesterId, platform, model?, class?, mode?, physical?, reason, stage, queuedMs } — model is the exact model the request named and class the class it named; a request names at most one, and one naming neither carries neither (ADR 0015 §9); mode is the device mode the request named, slim or full, and absent when it named none (ADR 0009 §8); physical is true when the request asked for a physical device and absent otherwise; reason is the routing policy's own warm-hit/free-capacity distinction (ADR 0005 §13): warm-hit when the deciding stage is warm-hit, free-capacity otherwise; a physical pick is warm-hit with stage warm-hit, matching lease.granted.source warm (ADR 0022 §9); stage names the routing stage that decided (ADR 0009 §1, §8); queuedMs how long the request sat queued before this dispatch |
the gateway's fleet queue sent a queued request to a worker with noWait: true, and the worker took it — the grant itself, or its first progress push, whichever arrives first (ADR 0005 §11: device work having started means the request is that worker's now). An immediate NO_CAPACITY is a stale view, not a dispatch, and emits nothing — the request stays queued, and that worker is not sent it again until its reported state changes (ADR 0009 §5); a noWait request fails with NO_CAPACITY instead, unless another worker takes it at once |
FleetLeaseCoordinator | implemented |
request.granted |
{ requestId, worker, leaseId, workerLeaseId } -- leaseId is the gateway lease id and workerLeaseId the worker's; worker, never workerId, because payload.workerId marks a relayed event (ADR 0014 §6) |
the gateway handed a fleet request's grant to its caller (ADR 0021 §4): after the lease index accepted it, and only if the waiter was still open. A grant given back for ADR 0020's mismatched id, one the lease index refused (the request ends in lease-id-taken), and one that lands after the waiter was settled (timeout, cancel, or the gateway stopping) emit nothing. A worker's push of this name is refused by the worker link (it is in GATEWAY_OWN_EVENTS with the six worker.* events and request.dispatched) |
FleetLeaseCoordinator | implemented |
Drain and undrain are idempotent, and an event is a fact about a change: draining an already-drained worker succeeds and emits nothing.
worker.rejected exists because the alternative is silence (ADR 0005 §22): a
worker whose credential is refused produces no worker.connected, and
without this fact an operator staring at simlock worker list sees a machine
that simply never appears, with nothing anywhere to say why. It is
deliberately not a worker.disconnected with another reason — nothing
connected, so nothing disconnected, and a fact whose subject never existed
should not borrow the vocabulary of one that did.
A protocol mismatch is neither of those. That uplink presented a valid
join token and authenticated; what failed was hello's range negotiation. So
the worker does enter the registry, with state: "incompatible" and both
ranges on its view, visible in simlock worker list — which is the whole
point, since that is the machine an operator has to go and upgrade. It is
simply never dispatched to, and no worker.connected follows, because
nothing usable connected. That is also why incompatible is not one of
worker.disconnected's reasons: an incompatible worker was never a connected
worker to lose.
A relayed event keeps the worker's id and timestamp (ADR 0014 §2, §5,
§6). A gateway republishes a worker's business events through the bus's
republish, whose only caller is the worker link: it mints seq and nothing
else, so one fact has one id in the worker's history, the gateway's, both
rings and both streams, and the two can be joined by it.
payload.workerId is the only mark of a relay; nothing is added to the
envelope. A relayed line's time is when the fact happened on the worker, so a
worker's clock skew shows in the gateway's order, and --since on the gateway
filters by that time. simlock events, events.replay, GET /v1/events and
the console's buffer present a replay by timestamp, then seq;
simlock events --follow prints live pushes, and the pushes buffered during its
replay, in arrival order, so a relayed event from a worker whose clock is behind
prints after a later gateway event. A worker's id and
timestamp are claims, bounded by the push schema before the relay sees them: the
id by its pattern, the timestamp to the range a JavaScript date holds
(|t| <= 8.64e15 ms), so no value the console cannot render as a date reaches the
gateway's bus or history.
device.exec emits no event, and that is deliberate. It is the one
operation here with no fact of its own. Running a command against a device is
not a state change simlock owns — the lease that authorizes it already
emitted lease.granted, the device's own state is untouched, and a fleet
where every adb shell input tap produced a bus event would push everything
else out of a 1000-entry ring buffer within minutes. An audit trail of what
agents ran on their devices is a different feature with different retention
needs, not a line in this catalogue.
A screen stream emits no event either (ADR 0031). Watching a device's
screen changes no lease and no device, the same as device.exec, and a viewer
toggling the console's Screen pane would fill the ring buffer with nothing
behind it. The daemon log records each stream opened and each one refused.
Every worker's own business events are republished on the gateway's bus
with workerId added to the payload, under their original names and with
their original emitting module — the fact happened in that worker's lease
engine or reaper, and rewriting either would make the audit trail lie about
where. That is what makes simlock events and simlock events --follow
against a gateway a fleet-wide view (lease.granted, device.ready,
cleanup.executed, and the rest, each naming the machine it happened on),
and it is the one place a payload documented above arrives with an extra
field: additive, and only ever on a gateway. The eight events in this
section's own table are the gateway's own, and carry no workerId beyond
the worker they are about.
Relayed events are written to the gateway's own event history
(history.db) along with its own, so simlock events --since against a
gateway reaches back across a gateway restart. Two consequences of relaying
rather than owning: the gateway's history only holds what arrived while its
uplinks were up (a worker's events from before it connected are not
backfilled), and a worker's own
simlock events keeps showing exactly what it always did, un-prefixed and
unaware that anything is watching.
A relayed lease.expired/lease.released/device.crash-detected/
device.recovered names the worker's own lease id in its payload, not the
gateway's — GatewayOwnerRoutedFacts (the gateway's OwnerRoutedFacts) is
what resolves the gateway's own lease id, and the ownerId to route the
corresponding lease-lost/device-unhealthy/device-recovered push by,
from the fleet lease index rather than the relayed payload directly. The
payload's own ownerId is correct for a worker's own local lease (the
worker never had a reason to touch it), but for a gateway-issued one it is
whatever that worker echoed back of what the gateway forwarded when it
granted the lease (ADR §27a) — round-tripped through a machine this gateway
does not control, not a value it minted itself, so it is resolved from the
index (which recorded the real answer at grant time) rather than trusted
verbatim off the wire.
- Every event carries:
id(evt_and a unique suffix; it names the event for good and survives a daemon restart),seq,timestamp,event,payload, emitting module. - The
idarrived with protocol 10. Events recorded before that upgrade have none and are not shown bysimlock events --since. - Events are appended to an in-memory ring buffer, which
simlock eventswithout--sincereplays, and to the event history~/.simlock/history.db, a SQLite file with one row per event holding the same fields. It survives daemon restarts and crashes, is whatsimlock events --sincereads, and is the durable record: no event is copied intodaemon.log. It is kept foreventLog.retention(seven days), and the stored size of its events is bounded byeventLog.maxBytes. Anevents.jsonlleft by an earlier version is left untouched and never read. See ADR 0006 and ADR 0016.