Repository navigation
Projections: request, minute and device fact rows #630
Description
Part of #623.
Scope
After this PR, history.db holds three projections beside its events: one row per lease request, minute rows (per worker and platform, plus totals) written when a value changes, and one row per device fact. Each is written in the same transaction as the event that changed it (#629's projection step). They survive a restart, are kept for metrics.retention (90 days by default), and outlive the 7 days of events. A projection whose check fails at start is rebuilt from the kept events, in batches, and simlock status says so once. A failed migration, or a stored version newer than the code's, stops the daemon from starting. history.usage, the read side #631 calls on its read thread, answers from the rows. ADR 0032 §2, §3, §4 and §6, ADR 0016 §2, §3 and ADR 0021 §5 fix the rules.
In short
Each event that changes a request, a level or a device fact updates its row before the event's transaction commits. The rows are those of feature #623 Shared values: Request row, Minute row, Device fact row. This task adds their tables, the one function that fills them, and the reads #631 makes. Six device events and lease.granted gain the device's platform.
Projection: a table built only from event envelopes, by one function. Example: after lease.requested at 10:00:01 and lease.granted at 10:00:39 for req_8f2, its request row has requestedAt 10:00:01 and grantedAt 10:00:39, so its wait is 38 s.
Integral: a value times the milliseconds it held in the minute, summed. Example: 1 lease for the first 15 s of a minute and 3 for the other 45 s gives 1 × 15 000 + 3 × 45 000 = 150 000, a mean of 2.5.
Gap marker: a row that says "no data from here". Written at a daemon stop, a daemon start and, on a gateway, worker.disconnected. Example: 2 leases held from 10:04:30 and no lease event until 10:09 reads as 2 for 10:05 to 10:08; a daemon stopped at 10:06 reads as 2 until 10:06 and no data after.
flowchart LR
B[Bus] --> H["#629 writer: BEGIN; INSERT event"]
H --> P["Projector (new)<br/>request, minute, device fact rows"]
P --> C[COMMIT]
S["#629 start()"] --> M{"migrate + check (new)"}
M -->|migration fails or newer version| X["Daemon stops (new)"]
M -->|check ok| R[Ready]
M -->|check fails| RB["Rebuild in batches (new)"]
RB -->|ok| R
RB -->|throws| E["Rows the events cover stay empty (new)"]
Q["#631 read thread"] --> U["history.usage (new)"] --> D[(history.db, read-only)]
simlock status after a start that rebuilt, and after one whose rebuild failed:
History: figures were rebuilt from the event history at 2026-10-16 09:12.
History: figures could not be rebuilt; see daemon.log.
A start after a downgrade, in daemon.log, and the daemon exits 1:
{"level":"error","module":"daemon.history","message":"History projection usage is at version 3; this simlock knows up to 2. Upgrade simlock, or move history.db away.",…}
| Input | Today | After |
|---|---|---|
metrics.retention: 7776000000 (90 days) |
Unknown config key: "metrics" warning |
accepted |
no metrics.retention, eventLog.retention 100 days |
— | metrics.retention is 100 days |
metrics.retention: 3600000 with eventLog.retention 7 days |
warning, ignored | refused at load, naming metrics.retention |
metrics.retention: 0 |
warning, ignored | refused at load, naming metrics.retention |
Quantities: every time in a row is epoch ms by the clock named in the rules below. metrics.retention is ms, counted back from the prune's Clock.now(); a request row goes by requestedAt, a minute row by its minute's start, a device fact row by at. An integral is value × ms, within its minute.
If this goes wrong, an operator sees #631's figures disagree with a count by hand from simlock events, or figures lost after an upgrade.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | lease.requested carries requestId, requestSpec (unknown type), requester, waitPolicy, and fleetRequestId on a worker's probe. |
src/bus/index.ts:12-23 |
| F2 | requestSpec holds platform; model only when the request named one, class when it named one. |
docs/EVENTS.md:12; built from the request at src/leasing/lease-acquisition-coordinator.ts:307 |
| F3 | lease.granted carries leaseId, deviceId, requester, requestId, source, fleetRequestId?, no platform; its emitter holds the device record. |
src/bus/index.ts:28-39; src/leasing/lease-lifecycle.ts:94-110 |
| F4 | lease.released carries leaseId and reason (explicit, killed, device-lost); lease.expired carries leaseId. |
src/bus/index.ts:41-55 |
| F5 | lease.rejected carries requestId, requester, requestSpec, reason, and code, worker on worker-failed. A refusal at admission has no lease.requested. |
src/bus/index.ts:56-90 |
| F6 | lease.declined carries requestId, fleetRequestId, requester, requestSpec, reason. |
src/bus/index.ts:92-98; emitted at src/leasing/lease-refusal.ts:30 |
| F7 | request.granted carries requestId, worker, leaseId, workerLeaseId; the gateway emits it only when the waiter was still open. |
src/bus/index.ts:99-104; src/gateway/fleet-coordinator.ts:1114-1121 |
| F8 | capacity.changed carries ios, android, global (running, maxRunning, reserved, warm) and ramBudget? (usedBytes, limitBytes) for the whole host. Emitted on change and once at start. |
src/bus/index.ts:4-9, :105-110; src/core/capacity/observer.ts:49-60 |
| F9 | queue.changed carries depth and physical?: true, one event per queue line, no platform. A worker and a gateway each emit it, and once at start. |
src/bus/index.ts:111-114; src/leasing/create-leasing.ts:116-121, :262-264; src/gateway/fleet-coordinator.ts:179-180, :204-206 |
| F10 | Device events carry deviceId and no platform: device.provisioned { spec, driver, duration }, device.ready { bootDuration }, device.recovered { …, duration }, device.quarantined, device.recovery-failed, device.quarantine-recovered, device.quarantine-abandoned, device.quarantine-stranded. |
src/bus/index.ts:115-121, :141-176 |
| F11 | Emit sites, each holding the device record (platform is ios or android): device.ready in Registry#emitDeviceEvent; device.recovered and device.recovery-failed in the lease health monitor; device.quarantine-recovered, -stranded, -abandoned in the quarantine coordinator. |
src/core/registry.ts:1056-1059; src/leasing/lease-health-monitor.ts:327-328, :347; src/core/quarantine-coordinator.ts:220, :334, :361; src/core/domain.ts:31 |
| F12 | Doctor marks a device ready with bootDuration: 0: it booted nothing. |
src/core/doctor.ts:827-830 |
| F13 | The catalogs' rows for these events. | docs/EVENTS.md:14, :42, :46-48, :58-59; docs/internal/EVENTS.md:80, :84-86, :96-97 |
| F14 | A relayed event's payload is not validated, and its name may be unknown to this EventMap. payload.workerId marks it, but the gateway's own worker.* and request.dispatched facts carry workerId too; WorkerLink drops any worker-sent worker.* or request.granted. |
src/gateway/worker-link.ts:538-554, :570-578; src/contract/schemas.ts:784; src/bus/index.ts:334-338, :366-368 |
| F15 | A worker settles a request still open at its last stop with lease.rejected daemon-restarted at the next start; a gateway's open requests vanish with it. |
src/leasing/lease-startup.ts:66-78; src/gateway/AGENTS.md:133-135 |
| F16 | daemon.started and daemon.stopping are emitted by DaemonServer on a worker and a gateway. |
src/daemon/server.ts:572-576, :640 |
| F17 | Config: an unknown key warns and is dropped; cross-field checks run after the merge; the keys a gateway reads are a fixed list. | src/core/config.ts:832, :413-420, :476-485 |
| F18 | config.get's output schema lists each section; eventLog is optional for a gateway parsing an older worker. |
src/contract/schemas.ts:889-903 |
| F19 | PR #356 counted a slot as used when it is running or reserved. |
PR #356 src/core/usage/read-events.ts (used: entry.running + entry.reserved) |
Modules touched
Searches: the event names in F1-F10 (git grep -n '"lease.requested"\|"lease.granted"\|"lease.rejected"\|"lease.declined"\|"request.granted"\|"capacity.changed"\|"queue.changed"', 34 production hits, and the eight device names, emitted in src/core and src/leasing); metrics in src/core/config.ts (0 hits); #629's HISTORY_PROJECTIONS and HistoryProjectionDefinition.
src/history/projections/usage/(new, the first projection): the pure projector, its SQL schema and migrations, check, rebuild, prune, and the read sidehistory.usage. Registered in The event history is a SQLite file, history.db #629'sHISTORY_PROJECTIONS.src/history/(The event history is a SQLite file, history.db #629's host): the definition gainsversion,migrations,check,rebuild;start()runs migrate → check → (rebuild);status()gainsrebuiltAtandrebuildFailed; a refusal makesstart()reject.src/bus/index.ts:platformonlease.grantedand the six device payloads (feature Operational metrics kept over hours and shown in the console #623 Shared values, Device eventplatform). Emit sites (F3, F11):src/leasing/lease-lifecycle.ts,src/core/registry.ts,src/core/quarantine-coordinator.ts,src/leasing/lease-health-monitor.ts. All but the lifecycle are strict test areas; so issrc/leasing/.src/daemon/main.ts: passesconfig.modeandmetrics.retention; a rejectedhistory.start()stops the daemon with reasonhistory-refusedand exit 1.src/core/config.ts:metrics.retention, its default and check, andmetricsin the gateway's keys.src/contract/operations.ts,schemas.ts:status.gethistory.rebuiltAt,rebuildFailed;config.getmetrics.src/daemon/dispatcher.ts,src/gateway/dispatcher.ts: pass the two fields through.src/cli/index.ts: the third and fourth status lines.- Docs:
docs/CONFIGURATION.md(metrics.retention, the gateway key list at line 237),docs/CLI.md(simlock status),docs/HTTP-API.md(GET /v1/status), bothEVENTS.mdrows (F13), the history manual,docs/internal/ARCHITECTURE.md's history row.
flowchart LR
hist["history (changed)"] --> proj["projections/usage (new)"]
proj --> bus["bus (changed: platform fields)"]
main["daemon/main.ts (changed)"] --> hist
t631["#631 read thread"] --> reads["history.usage (new, in projections/usage)"]
lease["leasing emitters (changed)"] --> bus
core["core emitters (changed)"] --> bus
config["core/config (changed)"]
contract["contract (changed)"]
cli["cli (changed)"]
No new process or network hop: the projector runs inside the daemon's event transaction; the reads run on #631's thread, in this process.
Surface and extension points
- The projection is the first implementation of The event history is a SQLite file, history.db #629's extension point.
src/history/projections/usage/index.tsexports its definition andopenUsageReads. The history host names no projection (architecture rule 21). - The event history is a SQLite file, history.db #629's
HistoryProjectionDefinitiongrows:version: number;migrations: readonly string[](SQL, one per step from the previous version);check(db): boolean; andcreate(db, { fleet, metricsRetentionMs })→{ apply(envelope), prune(now), rebuild(cursor) }. The conformance suite gains: migrations from version 0 reachversion;rebuildover the eventsapplysaw gives the same rows. - Version. Stored per projection, in a
projection_versions(name, version)table. Instart(), for each projection: stored version above the code's → refuse; else each migration from the stored version up, one transaction each; a migration that throws → refuse. Refuse:start()rejects naming the projection and both versions; nothing is dropped. check: every table and every column the code's version declares exists with its declared type, the stored version equals the code's, and no rebuild is marked as running. False → rebuild.- Read side
history.usage=openUsageReads(db), over the read-only connectionusage.getandsimlock statsfrom the projections #631 opens on its read thread (feature Operational metrics kept over hours and shown in the console #623 Shared values,history.usage). The four calls are synchronous and run inside the one read transactionusage.getandsimlock statsfrom the projections #631 opens, so they see one commit:oldestTs(): number | undefined: the earliest of request rows'requestedAt, minute rows' minute start and device fact rows'at;undefinedwhen all three are empty.requestRows({ from, to }): RequestRow[]: rows withfrom ≤ requestedAt < to, byrequestedAtthenrequestId.minuteBuckets({ from, to, bucketMs, workerId, platform }): MinuteBucket[]: for one key, one entry per bucket (start=from + k × bucketMs) that has a known value:{ start, knownMs, <value>: { peak, integral } }, aggregated in SQL.peakis the largest minute peak,integralthe sum of minute integrals,knownMsthe sum of known ms. Values carry across minutes with no row, from the last row beforefromtoo, up toto, and stop at a gap marker (rules below). A value absent from every minute of the bucket is absent.deviceFacts({ from, to }): DeviceFactRow[]: rows withfrom ≤ at < to, byat.
Contract and event changes
status.gethistorygainsrebuiltAt?: number(epoch ms) andrebuildFailed?: true(feature Operational metrics kept over hours and shown in the console #623 Shared values,status.gethistory).config.getgainsmetrics: { retention: number }, optional in the schema, aseventLogis (F18).- This PR bumps the protocol by one.
lease.granted,device.ready,device.recovered,device.quarantine-recovered,device.recovery-failed,device.quarantine-abandonedanddevice.quarantine-strandedgainplatform: "ios" | "android", the device's own (feature Operational metrics kept over hours and shown in the console #623 Shared values, Device eventplatform). Additive (events rule 6). Their rows in bothEVENTS.mdfiles (F13) name the field. A quarantined device says what failed #635 adds it todevice.quarantined. No event added.- Wire input: every payload field the projector reads is parsed with bounds, because a relayed payload is unvalidated (F14). Strings are cut to 200 characters; numbers must be finite and ≥ 0; enums must be one of the known values. A field that fails is absent from the row; a payload missing a key field (
requestId,leaseId,deviceId) changes no row. The event row is still written (safety rule 10). An unknown event name changes no row.
Projection rules
The fields, keys and kinds are feature #623 Shared values: Request row, Minute row, Device fact row. Below is only how events fill them.
Own and relayed, on a gateway: a request fact is the gateway's own when it has no payload.workerId. worker.disconnected is always the gateway's own (F14) and its workerId names the worker. request.dispatched is never read.
Request row:
- Created by
lease.requested, or bylease.rejectedwith no row for itsrequestId(refusedAtAdmission).platformandmodelcome fromrequestSpec(F2); a class request has nomodel. grantedAt,source,leaseIdfromlease.grantedbyrequestId.rejectedAt,reasonfromlease.rejectedbyrequestId.endedAt,endReason(explicit,killed,device-lost, orexpired) andheldMs(endedAt − grantedAt) fromlease.releasedorlease.expiredbyleaseId.- A field already set is not set again by a later event.
- On a worker, a
lease.requestedwithfleetRequestIdmakes a row withprobeset. Itslease.declinedsetsrejectedAtandreasonbyrequestId, withdeclined: true, and also writes a device fact row.
Fleet rules, on a gateway (ADR 0021 §5, from #347):
- Request rows come from the gateway's own
lease.requested,lease.rejectedandrequest.granted, by request id, by the gateway's clock. request.grantedsetsgrantedAt,workerId(itsworker) andworkerLeaseId. Aworker-failedrejection setsworkerIdfrom itsworker.- Held leases: each relayed
lease.grantedis kept in aheld_leasestable, keyed(workerId, leaseId), with itsplatform,sourceand time (worker's clock), and deleted at that lease's relayedlease.releasedorlease.expired. Rows older thanmetrics.retentionare pruned. This bounds what waits for a join. sourcecomes from the held lease matching the row'sworkerIdandworkerLeaseId, whichever of the two arrives first. With no match,sourcestays absent; a reader treats an absentsourceon a granted gateway row asunknown.unknownis never written.endedAt,endReasonandheldMson a gateway row come from the relayed end, by the worker's clock:heldMs= relayed end − relayed grant. They are used only forheldMs; turnaround is wait plusheldMs, never a gateway time minus a worker time.- A request with no outcome when the gateway's own
daemon.startedarrives is rejecteddaemon-restartedat that event's time (F15). A relayeddaemon.startedcloses nothing. - Relayed
lease.requested,lease.queued,queue.changed,lease.rejectedandlease.declinednever create or settle a request row.
Minute row:
- A step is an event that changes a value. On each step, every row the value belongs to is updated: the platform row, the worker's
allrow and, on a gateway, the*row, so thealland*peaks are true totals at one moment. A row is written only for a minute in which a value changed. slotsUsed=running + reserved,slotsMax=maxRunning, fromcapacity.changed(F8, F19):iosandandroidper platform,globalforall.ramUsedBytesandramLimitBytesfromramBudget, onallonly.queueDepth: the sum of both queue lines' lastdepth(F9), onallonly.leasesHeld: leases granted and not yet ended, bylease.granted'splatform.waiting: requests whose row has no grant and no rejection. A probe is never counted on a worker.- On a gateway, the
*rows:slotsUsed,slotsMax, RAM andleasesHeldare the sums over connected workers' latest values;queueDepthandwaitingare the gateway's own lines and requests only. A worker's rows hold its relayedcapacity.changed,queue.changedand held leases. peak,lastandintegralper value per minute; a step splits the integral at its timestamp.knownMsis the ms of the minute from the first known value (the minute's start when carried in) to the minute's end or a gap marker.- Clock skew: a step stamped before its key's last step (a worker clock behind the gateway's, or two workers' clocks on a
*row) counts at the last step's time. - Gap markers, in a
gaps(at, workerId)table: the daemon's owndaemon.stoppinganddaemon.startedwrite one for every key (workerIdnull); a gateway'sworker.disconnectedwrites one for that worker, and its values leave the*sums. A reader carries a value up to the next marker for its key. At a start marker with no stop marker before it (a crash), carrying ends at the end of the last minute with a row before it, so nothing is carried over the downtime.
Device fact row:
- One row per event listed in feature Operational metrics kept over hours and shown in the console #623 Shared values, Device fact row.
kindis the event name.durationMsfromdevice.provisioned.duration,device.ready.bootDurationanddevice.recovered.duration. Adevice.readywithbootDuration0 (doctor, F12) is no boot fact and writes no row. platformfrom the event:device.provisioned'sspec,lease.declined'srequestSpec, and the newplatformfield on the others. An event without it gives no platform.workerIdfrompayload.workerIdon a relayed event; absent on a worker's own.
Restart (create() after a stop or crash):
- Restores from the tables: open requests (rows with no grant and no rejection) and held leases (
held_leases, and on a worker the rows granted and not ended). - Minute values are not carried over: the start's gap marker cuts them. At the own
daemon.started,leasesHeldandwaitingget a step from the restored state; slots, RAM and queue depth get theirs from the start'scapacity.changedandqueue.changed(F8, F9). - Rows written before the stop are not changed.
Rebuild (feature #623 Shared values, Projection version):
- Runs in
start()whencheckis false, after The event history is a SQLite file, history.db #629's start prune and before its timer is armed. It marks itself running in the database; a stop or crash mid-rebuild leaves the mark, so the next start rebuilds again. - It recreates each table that fails the check (its rows are lost), deletes rows the kept events cover from the tables that pass (request rows with
requestedAt, device facts withat, minute rows and gaps from the oldest event's minute on), and replays every kept event in write order (rowid) throughapply, in batches of 1 000 events, one transaction each, withsetImmediatebetween batches. Rows older than the oldest event are kept. - Events emitted meanwhile are written by The event history is a SQLite file, history.db #629's writer with no projection step; the rebuild's cursor reaches them, and live projection resumes in the same tick as the last batch. Leases,
status.getand other work run between batches. Bound: ⌈kept events / 1000⌉ batches. - Done: clear the mark, set the version, set
rebuiltAt. Throws: the deleted rows stay deleted;rebuildFailed: true,writingstays true, norebuiltAt, oneerror; the projector writes no rows for the rest of this run.
Prune: in #629's prune, before the vacuum, delete rows older than now − metrics.retention: request rows by requestedAt, minute rows and gaps by minute start, device facts by at, held leases by their grant time.
- Replay order (decided by the maintainer, 2026-10-19): a replay applies events in the order they were written, never by timestamp. The start catch-up reads the bus ring in
seqorder (its own read;bus.replay()keeps time order for its other callers); the rebuild readshistory.dbbyrowid. Aseqthat does not rise marks a new run.
Other code on the same state
Searches for the state: writers of the projection tables (only this module; git grep -n "INSERT INTO\|DELETE FROM" src after #629); readers of the events in F1-F10 (git grep -n 'subscribe("lease\.\|subscribe("capacity\|subscribe("queue', 5 production hits, none writes history); HISTORY_PROJECTIONS (#629 only).
| Component | What happens when both act | Who wins |
|---|---|---|
| #629's event writer | The projector runs inside its transaction, after the event insert, only when the insert added a row; during a rebuild it skips the step and the rebuild catches up. | One commit holds both. |
| #629's prune | Deletes event rows; this projection's prune deletes its own rows; then #629 vacuums. A rebuild only covers the events still kept. |
Both, in that order. |
| #629's set-aside | A fresh file has no projection rows; versions start at 0 and migrations run. No rebuild (no events). | — |
| #631's read thread | Reads in one read transaction on its own connection (WAL); it sees the last commit, never half an event. | Both. |
Gateway WorkerLink (F14) |
Relayed events reach the projector through the bus in arrival order. | — |
| #635 (quarantine cause) | Adds platform and the cause to device.quarantined in src/core/quarantine-coordinator.ts, where this task adds platform to three other events. #635 merges first. |
Both fields kept. |
#434 (a gateway republishing a worker-pushed request.dispatched) |
Never read here. | — |
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
apply(envelope) |
ok | Rows changed; #629 commits. | none |
apply |
a payload field fails its bound | That field is absent; the rest is applied; no throw. | — |
apply |
throws (a defect) | #629 rolls back and stops writing (#629's write failure). | — |
migration in start() |
throws, or stored version newer | start() rejects; the daemon stops (history-refused), exit 1, one error naming the projection and versions; nothing dropped. |
— |
check in start() |
false | Rebuild. | — |
| rebuild batch | ok | Next batch after setImmediate. |
⌈events / 1000⌉ batches |
| rebuild | throws | As in the rebuild rule. | — |
stop() mid-rebuild |
— | Stops before the next batch; the mark stays; the next start rebuilds. | one batch |
a read in history.usage |
throws | The error reaches #631's caller; nothing is written. | — |
| prune | throws | #629's prune row: one warn, next try in an hour. |
5 s per lock wait (#629) |
State lines:
- A crash mid-transaction: the event and its rows are both there or both absent.
- A crash mid-rebuild: the mark stays, so the next start rebuilds again.
- Two callers at once: one write connection, one thread;
usage.getandsimlock statsfrom the projections #631's reads use their own connection and a read transaction.
Rules in play
- ADR 0032 §2, "each event and its projection changes in one transaction": the projector runs inside The event history is a SQLite file, history.db #629's transaction; minute rows only on change; gap markers.
- ADR 0032 §4: a failed migration or a newer version refuses; only a failed check rebuilds, in batches; rows older than the oldest event are kept.
- ADR 0032 §5: reads on their own read-only connection.
- ADR 0016 §2, "nothing is inferred from timing alone": every join is by id.
- ADR 0021 §5, "a fleet request's outcome is its gateway
request.grantedorlease.rejected": the fleet rules above. - Architecture rule 1: the projector has no platform code;
iosandandroidare payload values. - Architecture rule 12: the rebuild is running, done or failed, and marked in the database.
- Architecture rules 19-21: the first registered projection; the host names none.
- Safety rule 10, "anything that arrives over the wire is a claim": bounded parse of every payload field.
- Testing rule 6: driven through the bus and read back through
history.usage.
Tests
Seam: createEventHistory with HISTORY_PROJECTIONS over a real node:sqlite file in a temp directory; events go in through EventBus.emit and republish; rows are read back through openUsageReads on a second, read-only connection. Model #629's src/history/history.test.ts. Config and CLI lines through their existing suites.
- a request granted, held and released gives one row with
requestedAt,grantedAt,source,leaseId,endedAt,heldMsandendReasonexplicit; an expired lease givesendReasonexpired - a rejected request's row has
rejectedAtandreason; an admission refusal gets its own row withrefusedAtAdmissionandrequestedAtequal torejectedAt; a class request has nomodel - a field already set is not changed by a later event, an event already stored changes no row a second time, and an event for an unknown request or lease changes no row
- a relayed payload with an over-long string, a negative number, an unknown
sourceor norequestIdwrites the event row, and the row field is absent or no row changes; an unknown event name changes no projection row - on a worker, a probe's row has
probeset, is not counted inwaiting, and itslease.declinedsetsrejectedAt,reasonanddeclinedand writes a device fact row - on a gateway, a row takes its grant, worker and
workerLeaseIdfrom the ownrequest.granted, ignoring relayedlease.requested,lease.queued,lease.rejected,lease.declinedand an earlier relayedlease.granted; aworker-failedrejection sets the worker and atimeoutone none - on a gateway,
sourceandheldMscome from the held relayed lease matchingworkerIdandworkerLeaseId, by the worker's clock, whether the relayed grant arrives before or afterrequest.granted; with no relayed grantsourceis absent; a relayed end deletes the held lease - on a gateway, an open request is rejected
daemon-restartedat the own nextdaemon.started; a relayeddaemon.startedcloses nothing - minute rows:
slotsUsedis running plus reserved andslotsMaxper platform and onall; RAM used and limit and queue depth (both lines summed) only onall; no row for a minute with no change peak,lastandintegralof a value that steps twice in a minute are the highest, the final and the time-weighted sum; anallrow's peak is the highest total at one moment, not the sum of platform peaksleasesHeldbylease.granted's platform andwaitingper platform rise and fall with grants, ends and rejections, across two minutes- on a gateway, the
*row sums the workers' slots, RAM and leases and holds only the gateway's own queue depth and waiting; afterworker.disconnectedthat worker leaves the sums - a step stamped before its key's last step counts at the last step's time, on a relayed row and on a
*row daemon.stopping,daemon.startedand, on a gateway,worker.disconnectedeach write a gap marker- after a restart, open requests and held leases are restored,
leasesHeldandwaitingget a step atdaemon.started, and every row written before the stop is unchanged - one device fact row per listed device event and per
lease.declined, withdurationMs,platformfrom the event (absent when the event has none) andworkerIdon a relayed one; adevice.readywithbootDuration0 writes none lease.grantedand the six device events carry the device's platform, for an iOS and an Android device, at every emit site (extends each emitter's existing event test)history.usage.oldestTs()is the earliest of the three tables' times, each table the earliest in one case, andundefinedwhen all are emptyhistory.usage.requestRowsanddeviceFactsreturn rows withfrom ≤ time < to(one atfromin, one attoout), in their stated orderhistory.usage.minuteBucketsreturns, per bucket, the largest minute peak, the summed integral and summedknownMsfor the asked key only; it carries a value across minutes with no row and from beforefrom, stops at a gap marker, carries nothing over a crash's downtime, and leaves RAM absent where no minute had it- the four reads in one read transaction see the same commit while the writer commits between them
- a stored version below the code's is migrated and keeps its rows; a migration that throws, or a stored version above the code's, makes
start()reject naming the projection and versions, with every row kept - a failed check rebuilds in batches of 1 000 with other work running between them: rows older than the oldest event are kept, the rest equal the live rows, an event emitted mid-rebuild is projected once, and
status()givesrebuiltAtafter; a start with nothing to fix gives neither field - a stop mid-rebuild leaves the mark and the next start rebuilds; a rebuild that throws leaves no rows for the events' window,
rebuildFailedtrue,writingtrue, norebuiltAt, one error, and later events are still written - prune deletes request, minute, gap, device fact and held lease rows older than
metrics.retentionand keeps one at the cutoff, at start and one hour later - a config with
metrics.retentionshorter thaneventLog.retention, or not a positive integer, fails the load naming the key; with none, it is the larger of 90 days andeventLog.retention; a gateway reads it with no warning;config.getreports it status.getcarrieshistory.rebuiltAtandrebuildFailedon a worker and a gateway, andsimlock statusprints the rebuilt line with the local time and the could-not-rebuild line only when each is set (extends The event history is a SQLite file, history.db #629's status lines test)- (e2e) after a scripted run on the fake driver,
simlock daemon stopandstart, the request rows read throughhistory.usagematch the requests a test counts fromsimlock events --since 1h; withmetrics.retentionandeventLog.retentiona few seconds, the rows of the first run are gone after the next start - (e2e) a
history.dbwhose projection table lost a column while the daemon was stopped is rebuilt at the next start, a lease is granted meanwhile, andsimlock statusprints the rebuilt line; one with a newer stored version makessimlock daemon startfail and leaves the file unchanged - A relayed event whose timestamp is older than the event before it, within one run, is not read as a new run: the start catch-up gives the same fleet, key, worker and queue rows as the live writer.
Done when
- On the fake driver, after a scripted run with a grant, a
--no-waitrejection and a timeout,history.dbhas one request row per request with the times fromsimlock events --since 1h, minute rows for the minutes that changed, and a device fact row for each provision and boot. - After
simlock daemon stopandstart, the rows are the same. - With
eventLog.retentiona few seconds, request rows older than that are still there after the next start; withmetrics.retentionalso short, they are gone. - A broken projection table is rebuilt at the next start and
simlock statusprints the rebuilt line; a newer stored version stops the daemon from starting. docs/CONFIGURATION.mddescribesmetrics.retention, its default and that it may not be shorter thaneventLog.retention.
Out of scope
usage.get,simlock stats,HISTORY_NOT_KEPT,HISTORY_UNAVAILABLE, percentiles, buckets chosen, the read thread itself and one read per window (usage.getandsimlock statsfrom the projections #631).- The HTTP route, CSV, console (
GET /v1/statsand thesimlock stats --csvledger #632-Console Usage page #634).
Depends on
Written by an agent.
Activity
V3RON commentedon Oct 19, 2026
Handoff
Done: PR #695 has the red commit f1e680d. Green is partly done: 24/28 tests in
usage.test.tspass. That commit may not be pushed: the green agent ran out of budget, so the next stage must check the worktree.claude/worktrees/task-630and push once.Not done: 4 unit tests and 1 e2e fail. Each red test disagrees with the spec, and the decision is that the spec wins:
usage.test.ts:336: should run withfleet: false, because a gateway makes no rows from relayed events.:611: the*integral is 150000.:894: theoldestTscases are wrong; it is the minute start of the waiting row at T0.:1111: the comparison must account for the request emitted mid-rebuild.e2e/usage-projection.test.ts:92: scriptlatencyMs.makeReady.Also make
platformandProjectionHandle.rebuildrequired, and fix the fixtures.Blocked on: the session budget (10 h). Next:
/deliver 623in a new session, which picks up from green. #630 is still assigned to me; release it if needed.Written by an agent.
feat(history): project requests, minutes and device facts into histor…
fix(history): spec-aligned usage tests; platform and rebuild required (…
refactor(history): split the projector, recorder, start and write pat…
refactor(history): route events by table, fold figures in a helper (#630
fix(history): a relayed platform outside ios and android mints no key…
docs: daemon manual back to 150 lines; history manual names usage fil…
fix(history): a start keeps the levels its convergence just set; a ro…
fix(config): a metrics section without retention gets the default (#630)
fix(history): a rebuild that throws empties the events' window again (#…
6 remaining items
docs(history): the manual says what a start cuts and closes (#630)
fix(history): a replayed new run starts with what a fresh process hol…
test(e2e): the typed minute read (#630)
test(e2e): the typed minute read (#630)
V3RON commentedon Oct 19, 2026
Handoff
Done: PR #695 is green (28/28, audit 5/5 Done when) at f320357, pushed. Round 1 (15 fixes) and round 2 (3 fixes) applied; R1–R18 verified fixed.
Not done: one blocking finding open after the fix-check (rule 14 cap).
Findings:
projector.ts:81— the R16 fix reads aseqthat does not rise as a new run, so a replay forgets all state. The start catch-up replays the bus ring by timestamp, then seq (bus/index.ts:539). On a gateway, a relayed event stamped by a slower worker clock, or a clock stepped back, lowersseqwithin one run. That wipes the run's fleet levels, keys, workers and queues, so rebuilt rows differ from live ones. A probe passed at aa259b8 and fails now.Blocked on: a maintainer decision on how a replay detects a run boundary. Options: treat
daemon.startedas the boundary and ignore seq; replay by seq rather than by time; or an explicit run marker. Then an implement fix and a new review. #631–#634 wait on this.Written by an agent.
fix(history): the start catch-up writes the ring in seq order, so a r…
docs(history): name the projector by its full path (#630)
feat(history): history.db, usage projections, simlock stats, GET /v1/…