Repository navigation
state.db opens and checks at start, and the worker's registry lives in it #761
Description
Part of #754.
Scope
A worker keeps its devices, leases, lease requests and components in state.db, one row per record in real columns, and writes only the rows a change touches. Each daemon opens and checks its own state.db at start. It refuses to start with state.json in its folder, with a damaged file, or with a schema version newer than its own. This task also builds the state module the later tasks add their owners to, the DeviceDataTable seam, and the fake driver's table. The iOS and Android tables are task 2. The stack merges together (delivery rule 15), so a real driver's devices are not storable until task 2 lands on top.
Kept whole, at about 40 test files: the registry cannot store a device without the state module, the seam and a device-data table, and every test that builds a registry switches to the new helper in the same change. Those tests also change their driverData to the fake's one shape, { fakeDeviceId }. While doing so, drop a test that only repeats what another proves, or that guards a race the synchronous commit removes. The maintainer asked for that pruning (spec session, 2026-10-20).
In short
The worker's registry moves from state.json to state.db. A renew, a grant or any other change commits as one SQLite transaction that writes only the rows it changed, so a renew writes one lease row however many lease requests are stored. Every refusal is the daemon's start error, logged as Daemon failed to start, exit 1. The exact messages are in the feature's Shared values.
flowchart LR
A[startDaemon] --> B["openStateDatabase (new)"]
B -->|old file, damage, lock, newer version| X[throw: Daemon failed to start]
B --> C["Registry.load from rows (changed)"]
C --> D[createCore, leasing, server as today]
D --> E["each change: one transaction, changed rows only (changed)"]
- device-data table: a driver's own table, one row per device, e.g.
fake_device_data(device_id, fake_device_id)fordriverData: { fakeDeviceId: "d1" }. - replay: a repeat of a
lease.requestwith the sameIdempotency-Key. After this task, its answer'sdeviceis the device as it is at replay time. Itslease,environmentandtimingare as first answered.
If this goes wrong, a user would notice a worker that forgets or changes a device, lease or request after a restart, or one that will not start.
Technical spec
Facts
Commit 8db8112b.
| # | Fact | Source |
|---|---|---|
| F1 | state.json is written whole on every commit through writeFileAtomic, mode 0600, holding devices, physicalDevices, leases, leaseRequests, components and unknown keys |
src/core/registry.ts:958-988 |
| F2 | Memory is assigned after the awaited write, then commit listeners run | src/core/registry.ts:989-993 |
| F3 | Registry.load reads state.json if present; bad JSON throws Invalid JSON in registry state, a bad shape throws Invalid registry state |
src/core/registry.ts:213-215, :1002-1016 |
| F4 | Load-time fixes for old records: ownerId ?? requesterId, lastRenewedAt, ttlMs from defaultTtlMs, idChosenByRequester === true, warm → reclaiming, missing leaseIdentity → reusable, missing mode → full, retired keys featureProfile and lease mode, unknown fields kept per record |
src/core/registry.ts:1557-1571, :1379, :1471-1493, :1104-1163, :1017-1028 |
| F5 | defaultTtlMs is read on load only, by ADR 0004's record fix |
src/daemon/main.ts:258-261 |
| F6 | RegistryOptions: filesystem, clock, idGenerator, eventBus, statePath?, defaultTtlMs?, leaseIdentity?, leaseRequestLimits? |
src/core/registry.ts:51-75 |
| F7 | Every #commit caller: recordComponent, deleteComponent, registerDevice, transitionDevice, completeReclaimWithoutPurge, enterQuarantine, recordQuarantineRetryFailure, recoverFromQuarantine, strandQuarantine, deleteQuarantined, markForeignStateDetected, markForeignProvenanceDetected, clearForeignStateDetected, markRecoveryAttempt, clearRecovery, markDeviceMissing, endLeaseAndMarkDeviceMissing, endLeaseAndMarkDeviceAbsent, rewriteAbsentReason, enrollPhysical, reenrollPhysical, unenrollPhysical, createLease, beginRelease, renewLease, createLeaseRequest, settleLeaseRequest, failOpenLeaseRequests |
src/core/registry.ts:245-937 (grep #commit) |
| F8 | Record fields: DeviceRecordFields (src/core/domain.ts:216-245), VirtualDevice (:247-255), PhysicalDevice (:257-266), VirtualSpec (:29-48), PhysicalSpec (:59-67), QuarantineCause (:208-212), LeaseRecord (:296-330), LeaseTiming, LeaseGrant (:333-345), LeaseRequestRecord (:377-403), DeviceRequest (src/core/driver.ts:19-31), ComponentRecord (src/core/registry.ts:91-96), ComponentReceipt = Readonly<Record<string, string>> (src/core/driver.ts:513) |
as cited |
| F9 | stored() strips physical from a virtual record and its spec; withoutCauseUnlessQuarantined drops quarantineCause outside quarantined |
src/core/registry.ts:1181-1191 |
| F10 | enrollPhysical writes driverData: {}; PhysicalInspection has no driver data |
src/core/registry.ts:655; src/core/driver.ts:225-240 |
| F11 | Device records are never removed: unenrol and delete move them to deleted |
src/core/registry.ts:725-738; src/core/AGENTS.md "deleted records stay in the file" |
| F12 | Lease-request retention: open kept, settled kept 10 min after settling, at most 10,000; pruned inside createLeaseRequest's commit |
src/core/lease-request-store.ts:48-71; src/core/config.ts:59, :64; src/core/registry.ts:890-901 |
| F13 | A replay answers the stored grant | src/leasing/lease-request-book.ts:402-405; a request is found by grant.lease.id at :335 |
| F14 | lease.request's answer carries no driverData (grantedDeviceSchema); list.get devices (admin) and doctor orphans do |
src/contract/schemas.ts:260-263, :339-340; :194-200, :816-838 |
| F15 | Worker start order: Registry.load :261, loadInstanceId :276, discoverDrivers :283, createCore :340; createAuth before the mode branch :222; gateway branch :233; leftover sweep over dataDirectory and dirname(statePath) :473-478 |
src/daemon/main.ts |
| F16 | A thrown start error is logged Daemon failed to start with message to the role's daemon.log, exit code 1 |
src/daemon/main.ts:1486-1493, :1503-1506 |
| F17 | SIMLOCK_DRIVERS_MODULE replaces discovery; the module must export createDrivers or start fails |
src/daemon/main.ts:1085-1092, :1295-1312; src/daemon/main.test.ts:2564 |
| F18 | Both fakes write driverData: { fakeDeviceId } |
src/core/fake-driver.ts:454, :544; e2e/fake-driver/fake-driver.ts:452, :566, :751, :769 |
| F19 | NodeSqliteOpener.open pre-creates the file 0600 unless read-only, then new DatabaseSync(path, { readOnly }); a missing module throws SqliteUnavailableError |
src/ports/sqlite.ts:28-39 |
| F20 | History's configureDatabase sets busy_timeout 5000, auto_vacuum=INCREMENTAL, WAL, synchronous=NORMAL, and also creates history's own tables; inTransaction runs a plain BEGIN; passesQuickCheck and isLockError are pure. All are imported only inside src/history |
src/history/database.ts:5-69 |
| F27 | Test driverData values today: {} 177×, { opaque: true } 4×, { driverOnly: "value" } 3×, null 2×, undefined 2× |
rg -o "driverData: [^,}]*" src --glob '*.test.ts' | sort | uniq -c |
| F28 | Three test groups hold a registry write in flight with HeldWritesFilesystem to prove a transition committed meanwhile keeps its own |
src/core/testing.ts:62-96; src/core/doctor.test.ts:562-591, :2561-2591; src/leasing/lease-health-monitor.test.ts:694-750 |
| F29 | e2e may import only dist/<module>/index.js (past-index, past-index-unbuilt), and lint covers e2e |
.dependency-cruiser.mjs:21-35, :49-58; package.json:60 |
| F30 | More e2e reads of state.json: e2e/physical-enrollment.test.ts:669 reads enrolled apps; e2e/daemon-folders.test.ts:111 asserts the gateway folder has none |
as cited |
| F21 | History's version messages | src/history/versions.ts:27, :38 |
| F22 | Tests that build a registry: 28 files call Registry.load, 36 pass statePath; JSON reads of state.json in src/leasing/lease-request-book.test.ts:48, :245 and src/leasing/lease-reconciler.test.ts:375; restarts at src/core/registry.test.ts:195, :354, :535, src/leasing/create-leasing.test.ts:1724, :1767, src/daemon/main.test.ts:897, :971 |
rg -l "Registry.load" src, rg -l statePath src |
| F23 | e2e helpers write and read state.json directly: seedPhysicalState (e2e/helpers/physical-state.ts:67-119), patchPhysicalRecords, physicalRecords (e2e/helpers/physical-presence.ts:166-185), used by 6 e2e files |
rg -l "physical-state|physical-presence" e2e |
| F24 | src/daemon/test-relay-support.ts:181, :354 pass statePath |
as cited |
| F25 | Components are any src/ directory with an index.ts, guarded by past-index |
.dependency-cruiser.mjs:1-36 |
| F26 | docs/CLI.md documents breaking changes under the command they touch |
docs/CLI.md:1449 "Breaking in 0.3.0" |
Modules touched
Searches: Registry.load, statePath, state.json, RegistryOptions, defaultTtlMs, driverData, #commit, createDrivers over src/, e2e/ and docs/.
- New
src/state/(index.ts,testing.ts,AGENTS.md):openStateDatabase, the old-file check, the checks and migrations. src/ports/sqlite.ts:SqliteSchemaOwnertype;:memory:is not pre-created;passesQuickCheckandisLockErrormove here fromsrc/history/database.ts, which then imports them.configureDatabasestays history's: the state module sets its own pragmas (busy_timeout 5000, WAL,synchronous=NORMAL) and creates only its own tables.src/core/registry.ts,src/core/driver.ts(DeviceDataTable),src/core/fake-driver.ts(fakeDeviceDataTable),src/core/testing.ts(the registry test helper,describeDeviceDataTable),src/core/index.ts.src/daemon/main.ts,src/daemon/test-relay-support.ts;e2e/fake-driver/index.ts(exportsdeviceDataTables, with its own table defined ine2e/fake-driver/, F29);e2e/helpers/physical-state.ts,e2e/helpers/physical-presence.ts,e2e/physical-enrollment.test.ts:669,e2e/daemon-folders.test.ts:111(asserts the gateway'sstate.dbhas nodevicestable).src/core/testing.ts:HeldWritesFilesystemand the three test groups of F28 go. A synchronous commit has no write to hold, and the commit-interleaving test below replaces them.- Comments naming
state.jsonthat the Done whenrgfinds:src/core/lease-request-store.ts:33,src/core/domain.ts:26,:238,src/core/instance-identity.ts:6,:34,src/gateway/drain-store.ts:17,src/ports/paths.ts:7,src/ports/logger.ts:121,src/contract/schemas.ts:268(a comment only; the console lane then runs),src/drivers/ios/index.ts:2137,src/daemon/error-code.ts:92,docs/internal/flows/startup.md:127,src/leasing/AGENTS.md:106. - The 28 to 36 test files of F22.
- Docs:
docs/CLI.md(new section),docs/internal/ARCHITECTURE.md:29and the module table,docs/internal/flows/startup.md:17,:46,src/core/AGENTS.md,src/daemon/AGENTS.md,src/leasing/AGENTS.md:92,src/ports/AGENTS.md,src/history/AGENTS.mdif a helper moves.
flowchart LR
daemon["daemon/main.ts *"] --> state["state (new) *"]
daemon --> core["core Registry *"]
daemon --> e2efake["e2e fake drivers module *"]
state --> ports["ports/sqlite.ts *"]
core --> ports
core --> dd["DeviceDataTable in core/driver.ts *"]
history[history] --> ports
Surface and extension points
src/state/index.tsexportsopenStateDatabase(options): Promise<StateDatabase>and its types. Options:dataDirectory,sqlite: SqliteOpener,filesystem(old-file check only),owners: readonly SqliteSchemaOwner[],oldFiles: readonly string[],upgradeStep: string(the role's<step>of the old-file message in the feature's Shared values),logger.StateDatabase:database: SqliteDatabase,transaction<T>(work: () => T): T,close().testing.tsexportsopenMemoryStateDatabase(owners)(:memory:through the real adapter).- Extension point (architecture rules 19, 20):
DeviceDataTablehas four implementations named (fake now, iOS and Android in task 2, Vega later). It differs from rule 20's shape in two ways, both because a table belongs to a driver (rule 2): the registry is keyed by platform, not by name, and each driver's own test runs the shared suite with its own examples. Definition:DeviceDataTable extends SqliteSchemaOwnerwithwrite(database, deviceId, driverData): void(insert or replace) andread(database, deviceId): unknown(throws when the row is missing). Its registry is theReadonlyMap<Platform, DeviceDataTable>the composition root passes toRegistry.load. The conformance suite isdescribeDeviceDataTable(name, table, examples)insrc/core/testing.ts: each implementation's own test file runs it with that driver's example values, since only the driver knows its shapes. This task runs it forfakeDeviceDataTable. The registry never names a platform (architecture rule 21). Registry.load(options)takesstate: StateDatabase,deviceData: ReadonlyMap<Platform, DeviceDataTable>,clock,idGenerator,eventBus,leaseIdentity?,leaseRequestLimits?.filesystem,statePathanddefaultTtlMsgo away.registryOwner(theregistrySqliteSchemaOwner) is exported from core for the composition root.
The tables
Owner registry, version 1. Every table is STRICT, and a required field is NOT NULL. Every value is a column. Optional fields are NULL when absent. Booleans are 0/1.
| Table | Key | Columns |
|---|---|---|
devices |
id |
platform, physical, driver_device_id, state, model, os_version, spec_mode (VirtualSpec.mode), image_tag, class, mode (DeviceRecord.mode), lease_identity, created_at, last_lease_ended_at, ready_at, shutdown_at, foreign_state_detected_at, foreign_provenance_detected_at, recovering_since, recovery_attempts, deferred_reclaim_lease_id, quarantined_at, quarantine_attempts, quarantine_next_retry_at, quarantine_cause_kind, quarantine_cause_stalled_state, quarantine_cause_message, address, reclaiming_since, absent_reason |
device_enrolled_apps |
device_id, position |
app |
leases |
id |
device_id, requester_id, owner_id, granted_at, ttl_ms, ttl_deadline, last_renewed_at, id_chosen_by_requester |
lease_requests |
id |
requester_id, owner_id, idempotency_key, lease_id, fleet_request_id, platform, model, os_version, mode, image_tag, class, physical, created_at, state, settled_at, failure_code, failure_message |
lease_grants |
request_id (references lease_requests) |
device_id (references devices), lease_id, requester_id, owner_id, granted_at, ttl_ms, ttl_deadline, last_renewed_at, id_chosen_by_requester, estimated_provision_ms, estimated_boot_ms, estimated_reclaim_ms, estimated_ready_ms |
lease_grant_environment |
request_id, name |
value |
components |
platform, version |
installed_at |
component_receipt |
platform, version, key |
value |
Owner fake-device-data, version 1: fake_device_data(device_id key, fake_device_id TEXT NOT NULL), STRICT, in src/core/fake-driver.ts; write refuses anything but { fakeDeviceId: string } with Invalid fake driver data for device <id>. Every unit test's driverData becomes { fakeDeviceId: <its device's id> } (F27). The e2e fake module defines its own e2e-fake-device-data table with the same columns and rule, since e2e cannot import src/core/fake-driver.ts (F29). One fake table serves both platforms in a map.
Rules:
- A virtual device has exactly one row in its platform's device-data table. A physical device has none and loads with
driverData: {}(F10). - A commit writes the rows whose record object differs from the committed one (records are immutable), deletes the rows of records no longer present (pruned requests), and writes the device-data row of every device row it writes, all in one
StateDatabase.transaction. Memory and listeners update after it returns (F2). A throw rolls back and leaves memory as it was. - Load reads every table. A row with a value outside its type, a virtual device with no device-data row, a device whose platform has no table in the map, or a grant naming a missing device throws
RegistryLoadErrornaming the table and key. - In memory a granted request keeps its lease, environment and timing. Its
grant.deviceis the current device record, read when the grant is read (F13). - The load-time fixes of F4 and the unknown-field passthrough are removed.
Start (src/daemon/main.ts)
Before createAuth (F15), both roles: openStateDatabase on the role's data directory. Worker owners: registryOwner plus each table of the device-data map. Gateway owners: none in this task. Worker old files: state.json. Gateway: none in this task. Its steps, in this order, each refusing with the feature's Shared values message:
- Any old file present → refuse.
node:sqliteunavailable → refuse.- Open, then set the state module's own pragmas:
busy_timeout=5000,journal_mode=WAL,synchronous=NORMAL. A lock error past 5 s → lock refusal. Any other open error → damage refusal (open: <message>). PRAGMA quick_checknot exactly oneokrow → damage refusal (quick_check: <first row>).- In one
BEGIN IMMEDIATEtransaction: createschema_versionsif absent, then read each owner's version inside it. A stored version aboveowner.version→ newer-version refusal. Below it → run its steps and set its version. A throw rolls back the whole transaction, every owner's steps included → migration refusal. Reading the versions inside the write transaction is what makes two daemons starting at once migrate once.
Before step 1, startDaemon probes the daemon socket (refuseWhenDaemonAnswers in src/daemon/connection-host.ts): a socket that answers throws DaemonAlreadyRunningError and nothing touches state.db, so a second daemon never migrates a live daemon's file. A lock error at steps 3, 4 or 5 past 5 s gives the lock message, never the damage one. A start that fails after the open (e.g. the socket claim is lost) closes state.db before it throws.
The device-data map comes from one function, resolveDeviceDataTables(options) in src/daemon/main.ts, which startDaemon calls once and passes to both openStateDatabase (each distinct table once, as an owner) and Registry.load. Its order: StartDaemonOptions.deviceDataTables (new, a ReadonlyMap<Platform, DeviceDataTable>; the 37 main.test.ts starts that inject drivers pass the fake table for both platforms, and so do the drivers-injecting starts in src/agent-device/edge.test.ts, src/agent-device/gateway-front.test.ts, src/daemon/gateway-fleet.e2e.test.ts, gateway-modules.test.ts, lease-requires.e2e.test.ts, optional-modules.test.ts, server.test.ts, stream-gateway.e2e.test.ts and worker-capabilities.e2e.test.ts); else, with SIMLOCK_DRIVERS_MODULE, the module's deviceDataTables export (the same map type), read before the state opens, and a module without it fails start, as a missing createDrivers does (F17); else productionDeviceDataTables(), which is empty in this task (task 2 fills it). statePath leaves StartDaemonOptions. The leftover sweep keeps dataDirectory only. state.db is never deleted or renamed by Simlock. state.close() runs last in each role's dispose, after eventHistory.stop() (src/daemon/main.ts:553-569, :904-909); dispose runs after stopAuxiliary has stopped HTTP and the uplinks.
Contract and event changes
None: no operation, schema, error code or event changes (searched src/contract for statePath, state.json: none). driverData stays in list.get and doctor answers, from the device-data row (F14).
Other code on the same state
Searched state.json, statePath, Registry over src/ and e2e/ (F22, F23, F24). Every registry write stays inside SerializedDecision.run as today (src/core/AGENTS.md rule). The commit is now synchronous, so no other commit can run between its read and its write. The CLI reads no registry state (src/cli imports no Registry). History writes its own file (ADR 0032). Tasks 3 and 4 add owners to the same state.db, through Shared values.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
openStateDatabase |
ok | registry loads | — |
| old file present | refuse, nothing opened or created | — | |
node:sqlite missing |
refuse | — | |
| locked | refuse with the lock message | 5 s, from the open | |
| open fails, quick_check fails | refuse with the damage message; the file is untouched | — | |
| newer version, migration throws | refuse; the migration step rolled back | — | |
Registry commit |
ok | rows written, memory updated, listeners run | — |
| SQLite throws (disk full, I/O) | rollback, memory unchanged, the caller's operation fails as a failed write does today | busy wait 5 s, from the statement | |
DeviceDataTable.write / read |
throws | as a commit or load throw | — |
Registry.load |
bad row | RegistryLoadError → start refuses |
— |
Restart in the middle of a commit: the transaction either committed or did not (WAL). Two daemons on one folder: the second loses the socket claim as today, and its open waits on the lock at most 5 s.
Rules in play
- ADR 0036 §1–§5: the whole task.
- ADR 0019 §4, "the same … write that commits the lease marks that request granted":
createLeasestays one transaction. - ADR 0018 §2, "a grant and the device's move to
leasedare one … write": one transaction. - ADR 0022 §1, physical records "in their own top-level list": now
devices.physical = 1, with enrolled apps in a child table. - ADR 0027, every component guarded:
src/stateis found by itsindex.ts. - Architecture rules 3 and 21, "Adding a driver must require zero core changes": a driver adds a table to the map.
- Architecture rule 9, ports: SQLite only through
SqliteOpener;src/ports/AGENTS.md"sqlite.tsis the only file that namesnode:sqlite". - Architecture rule 18:
src/stateexports one factory and its types. - Safety rule 1, registry-only destruction: nothing destroys a device that
state.dbdoes not hold; the upgrade step orphans nothing. src/core/AGENTS.mdrule "OnlyRegistrywritesstate.json" becomes "OnlyRegistrywrites theregistrytables".
Tests
Seams: Registry.load over openMemoryStateDatabase (model: src/core/registry.test.ts), openStateDatabase (new src/state/state.test.ts, model: src/history/history.test.ts, temp dir), and startDaemon (model: src/daemon/main.test.ts:2564).
- A registry restarted on the same
state.dbreturns every field of a virtual device (driverData: { fakeDeviceId }), a physical device (enrolled apps in order), a lease, a request in each ofopen,granted,failed,cancelled, and a component (receipt pairs) exactly as committed, with optional fields both set and absent (temp file; extends the restart test atsrc/core/registry.test.ts:195). - A renew with 1,000 settled requests stored changes exactly one row (
SELECT total_changes()before and after). - A grant writes the lease, the device's move to
leasedand the request'sgrantedresult in one transaction: aDeviceDataTable.writethat throws during the grant leaves no lease, the devicereadyand the requestopen, both in memory and after a restart. - A commit that throws in SQLite leaves
snapshotand the commit listeners as before the call, and the caller's promise rejects. - Pruning deletes the rows of dropped requests and their grant and environment rows (retention and
maxRecordscases; extends the existing pruning test). - A replayed answer's device is the current device record, before and after a restart, while its lease, environment and timing are as first granted.
- Load refuses with
RegistryLoadErrornaming the table and key for: a virtual device with no device-data row, a device whose platform has no table, a grant naming a missing device, astatevalue not in the type. - A physical device loads with
driverData: {}and has no device-data row. - A commit's write runs with no
awaitbetween reading the registry's current records and swapping them in memory: two transitions of two devices started in one tick both persist, in memory and after a restart. This replaces the F28 groups. describeDeviceDataTableasserts, for each example:writethenreadreturns it deep-equal with noundefinedkeys, a secondwriteof another example replaces it, andreadof a missing id throws. Run forfakeDeviceDataTable.resolveDeviceDataTablesanswers the option's map when given, else the drivers module's export, elseproductionDeviceDataTables().startDaemonpasses the same map to the open and toRegistry.load: a fake-table device committed before a restart loads after it.openStateDatabaserefuses with each Shared values message, word for word, for: an old file present (and creates nostate.db), a file failing quick_check (bytes overwritten in the page area), a file that is not SQLite, a lock held past 5 s by another connection, a stored version one above the owner's, a migration step that throws (its version bump is rolled back).openStateDatabaseon an empty folder createsstate.dbmode 0600 and records each owner's version. A second open runs no migration.- The versions are read inside the write transaction: with an opener whose connection, on its
BEGIN IMMEDIATE, first migrates the same file synchronously on a second raw connection (the owners' steps run there, versions set), the first open runs no migration step (steps counted). - A lock held past 5 s by another connection at the quick_check and at
BEGIN IMMEDIATEgives the lock message, not the damage one. - A second
startDaemonon a folder whose daemon runs rejects withDaemonAlreadyRunningErrorand leavesstate.db'sschema_versionsrows and bytes unchanged, even with an owner at a newer version (a new case besidesrc/daemon/main.test.ts:2564). startDaemonas a worker withstate.jsonin its folder throws the old-file message with the worker's step, and creates nostate.db.- The built daemon entry (
node dist/daemon/main.js,SIMLOCK_HOMEa temp dir holdingstate.json) exits 1 and itsdaemon.logends with aDaemon failed to startline whosefields.messageis that message (a new case besidee2e/daemon-folders.test.ts:170, which already runs the entry and checks itsDaemon failed to startline). startDaemonwith aSIMLOCK_DRIVERS_MODULEthat has nodeviceDataTablesexport fails start naming it (extendssrc/daemon/main.test.ts:2564).- A worker started with
startDaemonand fake drivers, leased, stopped and started again, lists the same device and lease (extendssrc/daemon/main.test.ts:897). - The e2e physical-device flows pass with
seedPhysicalState,patchPhysicalRecordsandphysicalRecordsreading and writingstate.db(the 6 files of F23, unchanged assertions).
Done when
pnpm test:changedpasses.pnpm test:e2e e2e/physical-enrollment.test.ts e2e/daemon-folders.test.ts e2e/physical-devices-core.test.ts e2e/physical-leasing.test.ts e2e/physical-reclaim.test.ts e2e/physical-presence-loss.test.ts e2e/physical-presence-loss-recovery.test.ts e2e/physical-gateway.test.tspasses.rg -n "state\.json" src docs e2e --glob '!docs/internal/adr/**'finds only the old-file check, its tests and thedocs/CLI.mdupgrade section.docs/CLI.mdhas### Breaking: state moves to state.dbundersimlock daemon, with the upgrade step (release every lease,simlock nuke --delete-deviceswith the older Simlock, removestate.json, add physical devices again withsimlock device add), how to read the refusal (simlock daemon logs), and the downgrade step (release every lease andsimlock nuke --delete-devicesbefore starting an older Simlock).
Out of scope
- The iOS and Android device-data tables, and filling the production map (task 2).
- Tokens and
tokens.json(task 3), drained workers andworkers.json(task 4). - The CLI printing the refusal (task 5).
- Any import of
state.json.
Depends on
- docs(adr): 0036 daemon state is a SQLite database, and each owner keeps its own tables #755 (ADR 0036)
Written by an agent.
Activity
test: openStateDatabase refuses with each Shared values message (#761)
feat(state): a daemon refuses an old file, a damaged file, a newer ve…
test(daemon): every start that injects drivers passes the fake device…
test: a lock held past 5 s gives the lock message, not the damage one (…
test: the versions are read inside the write transaction (#761)
test: a renew with 1,000 settled requests stored changes exactly one …
test: a grant writes the lease, the device's move to leased and the r…
test: a commit that throws in SQLite leaves snapshot and listeners as…
test: pruning deletes the rows of dropped requests and their grant an…
test: a replayed answer's device is the current device record (#761)
18 remaining items
test(daemon): the option wins over SIMLOCK_DRIVERS_MODULE when both a…
docs: the state.db upgrade section sits under simlock daemon; two com…
fix(core): a fractional quarantine retry time and lease_grants ttl ar…
fix(e2e): the e2e fake table refuses data with extra keys (#761)
test(daemon): several leases prove the startup lease end comes before…
test(daemon): the restart lease-end test gets the time a loaded runne…
test(daemon): the restart lease-end test fixes the host's cpus and me…
fix(daemon): a second daemon refuses before it opens a live daemon's …
test(daemon): a daemon that loses the claim at listen never runs afte…
V3RON commentedon Oct 20, 2026
Handoff
Done. PR #775 (task/761, head d2b520d) has passed review round 2 and fix-checks through 6c4df08. The whole stack #782 (#755 → #775 → #783 → #784 → #785 → #786) is synced with no conflicts. Every other PR in it has passed review and is ready. The ADR PR #755 has passed its spec review.
Not done. Commit fc5076f fixes R15 to R17 from the last fix-check. Nobody has reviewed R15 (
src/daemon/optional-modules.test.ts:633, the race test split into "at the probe" and "at the claim" cases), because rule 14's round cap was used up. The implementer checked it with a mutation that firesonSocketClaimedwhenhost.startthrows, and the claim case went red. R16 and R17 (a flow doc line and a test comment) passed a coordinator diff check. The gate has not run.Findings. In delivery I added a socket probe before
state.dbopens (refuseWhenDaemonAnswers, R13). The decision is pending you; see the "Decision taken" comment on #755. A race between the probe and the claim remains when two binaries of different versions start at the same moment (review note).Blocked on. The maintainer checking R15, then
.agents/scripts/merge-pr.sh 786.Written by an agent.
feat(state): the worker's registry lives in state.db (#775)