ADR-0035: What the phone keeps, and how
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted Date: 2026-09-29 Decided: 2026-09-29, by the maintainer, Wessel Verheij: readings, preferences and markers kept in the app’s SQLite database, each decided and stored by the capability it belongs to, its store walled inside it, with every payload sealed before it reaches a store, under a key the platform’s secure storage holds. Revised the same day, by the maintainer: the store moved from an adapter module per owner into the owner itself, because modules will keep more and more local data and a module per kind of kept data does not scale.
Context
The companion app keeps two things between launches today, and both are in the
platform’s secure storage: the stacks it is paired with, and a session per stack
(N4, N4-R5).
Everything it reads from a stack lives only as long as the process does.
The spec already asks for more than that. A screen paints its first frame from what
the app holds before it reaches the stack (N1-R25,
ADR-0019); a reading kept from
an earlier session may be shown on opening with when it was read (N1-R24, N1-R9);
a spinner is allowed only where the app holds nothing to show (N1-R28); and the
period after which the app asks for the passcode again is the operator’s to set
(N4-R19). With nothing kept, every cold start is a spinner, and the lock period has
nowhere to live.
Three facts constrain how it is kept.
What a stack says is private. Its readings name services, members of the household and what they asked for. The paired-stack list went into secure storage rather than a file for exactly this reason: a file in the app’s own storage is readable by a device backup, by a rooted device, and by whatever a restore puts it back onto.
On Android the framework’s own key sits beside the data. NativePHP generates an
APP_KEY per install. On iOS it keeps it in the Keychain; on Android it keeps it as a
plain file in the app’s private storage, next to the database, so encrypting with it
there would put the key in the same place as what it locks. One way of sealing has to
hold on both.
The app is modular, and one store would not stay small. Every module that decides
something about a stack would add its tables, queries and migrations to whichever
module held the store, until that module held everything
(30-repos/lemonfiber-companion.md, How
it is laid out).
More and more will be kept. Readings, preferences and markers are the first kinds of local data; modules will keep more as the app grows. Whatever it costs to hold one kind is paid again for every kind that follows.
A capability holds no framework. The companion’s architecture keeps its capability modules free of Laravel, so that a decision cannot be run wrong, and lets only what stores touch storage (its rules A1 and A7). Keeping data brings Laravel’s database into some module, and where that module sits decides how far the rule bends.
Decision
-
The phone keeps three kinds of thing, and nothing else.
- Readings, the newest per stack of each kind: health (the summary, the problems and what stopped in the queue), what runs (services and forms), updates (the version running, the offer, the history and what became of the last update) and household (members and their requests).
- Preferences: the lock period, how long readings are kept, the order of the stacks, where the operator was (stack and tab), and per stack which kinds of notification and which kinds of new the operator wants.
- Markers: per stack and kind, an identifier of the newest item the operator has seen, so that new since you looked can be worked out. A marker is an identifier, never a second copy of the content.
It never keeps a credential, a session token or pairing material (
N1-R23), an action the stack did not receive (N1-R41), the confirmation of an action (N1-R24), an agreement (N13-R7) or a repair offer, which is something one agrees to and so must always be fresh. -
Each owner decides and stores, its store walled inside it. The capability a thing belongs to decides what is kept, when it is pruned and what counts as new:
healthits readings and the problem markers,updatesits readings and the release markers,householdits readings and the request markers,stackswhat runs, where the operator was, the order of the stacks and how long readings are kept,connectionthe lock period anddevicethe notification kinds. It stores them too. Its decisions ask a port it declares in its ownInternal, and its store implements that port insrc/Internal/Store/— one query class over Laravel’s database — beside the module’s own migrations indatabase/migrations. The composition root binds the port to the store. The owner’s tables carry its prefix (health_readings), no module reads another’s tables, and one owner that needs another’s data asks that owner’s capability. The kernel does not grow a port per owner, and no module is added per kind of kept data. Four walls keep the framework in the store and the store behind its port:- The framework only in the store.
Illuminate\Database, and any otherIlluminate\*, is named in a capability only undersrc/Internal/Storeanddatabase/migrations. The rest of the module stays free of the framework. - The store only through its port. Nothing else in the module names a class
under
Internal/Store; only the composition root binds it. - Sealed in, sealed out. A store class accepts and returns only sealed payloads, a stack’s keyed hash and the bookkeeping beside them (decision 4).
- Tables named by their owner. A table carries its owner’s prefix, is created only in that module’s migrations and is named in no other module.
- The framework only in the store.
-
Queries are intents, written once. A store port speaks the app’s language —
keep,newest,forget— and never a row or a column. Behind it, in the owner’s store, one class holds one private, named method per query over Laravel’s query builder with an injected connection, and turns rows into values in one place. There is no Eloquent model, no facade and no hand-written SQL. Every store port is proven twice: one contract suite runs against its fake and against its SQLite store. -
Every payload is sealed with a key the platform holds. On first use the app generates a 32-byte data key and keeps it in the platform’s secure storage at the narrowest accessibility, readable only while the device is unlocked, as a session is. Payloads are encrypted with Laravel’s
Encrypter, AES-256-GCM, constructed with that key — never withAPP_KEYor theCryptfacade. What a query needs stays readable: the kind, when it was read and the version of its shape. The stack is stored as a keyed hash rather than its identity, so a row on disk cannot be tied to a stack without the key. One kernel port,Sealed, does the sealing: thesealmodule implements it, under keysvaultholds in secure storage. The owning capability seals before it asks its store: a store port accepts a sealed payload and a stack’s keyed hash, never a plain value, so a store never sees what it keeps. -
Every row carries the version of its shape (
N1-R32). A reading of a shape this build does not know is discarded, because it can always be read again; a preference is the operator’s own choice, so every change to a preference’s shape ships with a migration and its test (N1-R33). Nothing here touches a pairing or its fingerprint (N1-R34). -
A kept reading is shown for what it is. It is drawn on opening with when it was read (
N1-R9,N1-R24) and replaced when a fresh one arrives. Until then, what acts on the stack is shown but cannot be used, with the reading’s age beside it — offered, not hidden (N1-R3). Readings older than the operator’s setting — any whole number of days from 1 to 365, or until the stack is forgotten, 30 by default — are deleted at launch and whenever the setting changes. -
Without the key, nothing is kept. Where the device has no secure storage the app keeps nothing between launches and says why on its settings screen, as it does for a session (
N4-R6). Where the key cannot be read — a restore onto another device, a reset keychain — the unreadable data is deleted, a new key is made, the pairings stay (N1-R34) and the app says once that its saved data was cleared. -
What is new is collected, not duplicated. A kernel interface,
SaysWhatIsNew, is implemented by each module that keeps markers, and the What’s new screen asks every one of them. A module with a markers table and no implementation is a failing test, not a list somebody has to remember. -
The rules above are tests. They join the companion’s architecture document, each beside the test that enforces it:
- the framework only in the store: database code lives only in a capability’s
src/Internal/Storeand itsdatabase/migrations, with no Eloquent anywhere, no raw SQL and no facade in a query class, andIlluminate\*is named nowhere else in a capability (the companion’s A1 and A7, amended to say so); - the store only through its port: nothing in the module outside
Internal/Storenames a class in it, and only the composition root binds it; - sealed in, sealed out: no store class takes or returns a type but a sealed payload, a stack’s keyed hash and the bookkeeping beside them; and a test writes a value and reads the raw database file to find no trace of it;
- tables named by their owner: a table carries its owner’s prefix, is created only in that module’s migrations and is named in no other module’s code;
- sealing goes through one port: the
Cryptfacade and theEncrypterclass appear only inseal’s implementation ofSealed; - no store method takes a session, a credential or pairing material, and no kept
value holds one (
N1-R23); - every kept value declares the version of its shape, and every shape ever written
has a test that migrates or discards it (
N1-R32,N1-R33).
- the framework only in the store: database code lives only in a capability’s
Alternatives considered
| Option | Why it lost |
|---|---|
One store: a kernel port and one implementation in vault |
The first design. Every module that keeps something would add its tables, queries and migrations to vault, which would grow with every feature and become the place every change has to go through. A store inside each owner keeps each small and keeps the boundary rules meaningful. |
One adapter per owner: a kernel port and a *-kept module for each |
The second design, and the maintainer’s reason for leaving it: modules will keep more and more local data, and a module per kind of kept data does not scale. Every new kind would mean a new module, a new manifest and a new kernel port, for a class and a migration. What the separate module bought — the framework kept out of the decisions, the store reached only through a port — the four walls of decision 2 keep inside the owner, each held by a test. |
| Each store seals | Every store would receive plain values and have to remember to seal them. Sealing in the capability makes a plain value unrepresentable at a store port. |
Laravel’s Crypt with the framework’s APP_KEY |
On Android the key is a plain file in the same private storage as the database, so anything that can read one can read the other, and it stops only somebody opening the file by hand. A key the app makes and keeps in secure storage holds on both platforms. |
| Keep only what names nobody, in a plain file excluded from backups | Would leave out the household’s readings, and relies on the device’s sandbox alone: a rooted device reads it. |
| An encrypted database file (SQLCipher) | Not something NativePHP’s SQLite offers; it would mean patching the packager’s native build on both platforms for what per-value encryption already gives. |
| Eloquent models with encrypted casts | The encrypted cast is bound to the application’s encrypter unless overridden, models would cross into screens, and a model is a second place a row’s meaning is decided. |
| One key per stack | Forgetting a stack could destroy its key and make its rows unreadable at once. It adds a key to manage per stack for what deleting the rows in the same act already achieves. |
Consequences
The app’s database gains tables, one module’s worth at a time. Each module’s migrations run at launch, as the queue’s already do. A module that keeps nothing has no migrations and no store.
A capability that keeps something requires the database. Its manifest names
illuminate/database, and a dependency analyser reads a package per manifest rather
than per directory, so it is the walls, tested over each file’s path, that keep the
framework in src/Internal/Store and the migrations.
Opening the app shows the last thing it knew. The first frame is the kept reading with its age, and actions wait for a fresh one. A stack out of reach shows what it last said and when, beside the reason it cannot be reached now.
A backup of the device carries only sealed data. The data key stays in secure storage, which a backup does not restore to another device; such a restore is the key cannot be read case, and the app starts its saved data again.
Settings the operator changes now persist, which is what makes the lock period of
N4-R19 buildable.
A kept reading is not a live one. Every surface that draws one has to say when it
was read and hold back its actions, and the tests of N1-R9 and N27 hold every
screen to that.
Related
- N27 — what the phone keeps, as requirements
- N1 —
N1-R9,N1-R23toN1-R25,N1-R28,N1-R32toN1-R34,N1-R41 - N4 —
N4-R5,N4-R6,N4-R19: secure storage and the lock period - ADR-0019 — a screen paints what it knows first
- ADR-0020 — why an undelivered action is never kept
30-repos/lemonfiber-companion.md— the modules and their kinds