Repo: lemonfiber-companion
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Draft
The mobile companion app. PHP on NativePHP, Hippocratic 3.0.
Implements: area N — the fourth
surface, against the
web API contract, by way of
sdk-php.
What this repo is
A native mobile application for iOS and Android, built with NativePHP in SuperNative mode: Blade compiled to a binary representation that SwiftUI and Jetpack Compose render directly. There is no web view, no DOM and no JavaScript bridge (ADR-0017).
It is a fourth surface under G1, not a companion in the weaker sense of a viewer. The parity rule applies: an action reachable from a terminal is reachable here, subject to the two qualifications ADR-0017 records — reachability is a reported condition, and first-run setup does not cross the device boundary.
One application serves both audiences. The credential that signs in decides which (N3-R1).
What it does not contain
No API client. It consumes sdk-php, which owns that job
(ADR-0013). A
second client here would be a second source of truth for the contract, which
ARCH-R58 forbids.
No way around the SDK. Not “prefers the SDK” — the app issues no HTTP request of its own at all (N1-R16). Where the SDK does not yet expose something a screen needs, the work stops and the gap is raised against the SDK and the contract (N1-R17). It is not worked around, not fetched directly “just this once”, and not approximated from a neighbouring endpoint.
That rule is load-bearing for anyone — person or agent — building here unsupervised. A blocked screen is a smaller problem than a fourth consumer the contract does not know it has, and being blocked is a finding worth reporting rather than a problem to solve locally.
No business logic. It renders the core’s answers. A decision made here that
the core does not also make is the drift G1-R2 exists to prevent.
No second permission model. What a household member may do is the core’s answer, rendered (N3-R2).
No generated contract of its own. The envelope shapes arrive through the
SDK, already generated from the contract/web-api/ directory
(ADR-0014).
What it owns
| Pairing, session exchange, and multi-stack handling | N1 |
| The operator’s screens | N2 |
| The household’s screens | N3 |
| Device permissions, secure storage, app lock, notifications | N4 |
| What the phone keeps between launches | N27 |
| The top bar, the tabs, the menu and the words on them | N28 |
| Keys for apps, minted as claims and proved by a device key | N29 |
Standards
Held to the same bar as sdk-php, because it is the same language
and the same project:
- PHP 8.5, strict types throughout.
- PHPStan at the level
sdk-phpruns, with the same strict, deprecation and ergebnis rule sets. - Pest, including its architecture plugin — the boundary rules above are tests, not conventions.
- Pint for formatting, Rector for upgrades, composer-dependency-analyser for unused and shadow dependencies.
- Git hooks under
.githooks, installed by composer settingcore.hooksPath. They run only the fast checks — a hook that takes a minute is one people turn off, and one that is off enforces nothing (OPS-R51). - A DCO sign-off on every commit and a
Spec:citation naming a requirement, enforced by the shared governance workflow every repository in this project calls (spec-check).
How it is laid out
The application is modular: a composition root holding nothing but bindings, and
modules under app-modules/ that each declare what kind they are —
kernel, capability, design, surface, adapter, or stand-in for the
development-only fakes in modules/dx. That declaration
generates the module’s dependency rules, so a module added later is governed the
moment it exists rather than when somebody remembers to write its test.
Each module is a composer package with its own manifest, which is what makes the
SDK rule structural rather than advisory: modules/sdk and the development-only
modules/dx are the only manifests requiring lemonfiber/sdk-php, so a screen that names the SDK is a shadow
dependency and fails resolution. N1-R16 stops being a rule a reviewer applies.
A capability that keeps something between launches holds its store inside itself,
walled off rather than split into a module of its own: the framework only in its
src/Internal/Store and its database/migrations, the store reached only through a
port the capability declares and the composition root binds, and its tables named for
it and read by nobody else
(ADR-0035).
The architecture document in that repository lists every rule beside the mechanism enforcing it, and a test reads that column and fails when the two disagree — in either direction. A rule table that can quietly go out of date is worse than none, because people stop reading it once they trust it.
The decisions that shape it
| It is a fourth surface, reached over the network, rendering natively | ADR-0017 |
| A paired fingerprint decides which machine it will talk to | ADR-0018 |
| A screen paints what it knows before it reaches the stack | ADR-0019 |
| An action the stack did not receive did not happen | ADR-0020 |
| What the phone keeps: decided and stored by the capability it belongs to, its store walled inside it, sealed with a key the platform holds | ADR-0035 |
It follows the main repos rather than gating them
This repo carries no version of its own yet, and locks no goal on the release train. Area N is specified in full because the specification leads everywhere in this project; the app is built against what the main repositories have already shipped rather than the other way round.
All twenty-six of its features are accepted. Every requirement in area N is
citable, and the only ones a version locks are the stack’s side of pairing, which
0.17.0 locks: N1-R18, N1-R47 to N1-R49 and N1-R62. The rest of N1 is not
scheduled, and every other feature in the area requires it. They wait in
check_goal_coverage.AWAITING_A_VERSION until the app joins the train, beside the
two outside area N the app keeps: G2-R14, a name this product gives rendered as
declared in every locale, and G3-R16, a touch target the platform’s own minimum.
So it tracks main and takes no releases while it catches up. When it has, it is
pinned with the rest of the repositories the way every other one is
(pins), and from that point it moves on the
train like everything else.
What this deliberately avoids: a version manifest locking N1–N4 would make
1.0.0 — “everything specced is built” — wait on an application that is
following the core rather than leading it. The specification is not the thing
holding anything up here, and a manifest would make it look like it was.
Distribution
Builds and signing run in Bifrost, NativePHP’s cloud build service, rather than on a machine holding distribution certificates.
That is a supply-chain surface this project did not previously have, and it is
named here rather than left in a workflow file: a signing identity that lives in
somebody else’s cloud is a thing an operator of this project’s own standards is
entitled to know about. L1’s attestation requirements reach it.
Why PHP for a mobile app
The honest answer is that it is the same language as an SDK this project already maintains to a high standard, so the app is written against a client that is already tested rather than against a second one in a language nobody here is holding to that bar.
The less honest answer would be that PHP is the natural choice for mobile. It is not, and the decision is recorded with its trade-offs in ADR-0017 rather than presented as obvious.
Related
sdk-php— the client this consumeslemonfiber-web— the other surface behind the same contractlemonfiber— the core, and the API it serves