diff --git a/README.md b/README.md index 8611a8f..dbf8085 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ without bundling server-runtime or coordinator-only dependencies. Version 4 is a complete replacement for the legacy callback-based MiakAPI 3 client. It is currently an alpha while the Miakapp 4 stack is being completed. +**Building a home with a coding agent?** Read +[docs/agent-guide.md](docs/agent-guide.md) first. It covers the judgment the +reference below does not: what the coordinator must authorize, why component +requirements and coordinator grants intersect silently, and which failures must +never be retried. Start from [`templates/home`](templates/home). + ## Coordinator requirements - Bun 1.2.23 or newer (primary coordinator runtime) diff --git a/docs/agent-guide.md b/docs/agent-guide.md new file mode 100644 index 0000000..5b1ff93 --- /dev/null +++ b/docs/agent-guide.md @@ -0,0 +1,326 @@ +# Building a Miakapp home + +This guide is written for a coding agent — Claude Code, Codex, or any successor — +that has been handed someone's house and asked to make it work. You are expected +to read the existing installation, write a coordinator, write an interface, test +both, and publish. The owner is not a home-automation developer and should not +have to become one. + +Everything below is true of the code in this repository. Where a rule exists for +a reason that is not obvious, the reason is given, because a rule whose purpose +you cannot see is one you will optimize away. + +## 1. What you are building + +Three artifacts, and no more: + +| Artifact | Where it runs | What it owns | +| --- | --- | --- | +| Coordinator | the owner's machine, under Bun | state, events, functions, **authorization** | +| Component | a sandboxed Worker in the household's browser | the interface | +| `miakapp.yaml` | neither; it is the contract | what the component may ask for | + +The coordinator is trusted. The component is not. The relay between them is +platform-untrusted but not blind: it terminates TLS, stores plaintext state and +enforces routing, so self-hosting it does not give end-to-end confidentiality. +Do not tell the owner otherwise. + +Start from `templates/home`. It is a complete working home — one lamp, one +temperature — with the three files already in the right relationship, and its +own `README.md` covering the mechanics of copying it out. This guide covers the +judgment the template cannot. + +## 2. The division of responsibility + +**The coordinator authorizes everything.** The relay proves *who* is calling and +attaches non-spoofable caller metadata. Deciding whether that person *may* act is +the coordinator's job and nobody else's. In the template that decision is the +first line of the function body: + +```ts +if (call.source.kind !== 'user' || call.source.id !== options.ownerUserId) { + throw new ApplicationCallError(2003, 'Only the owner may drive the lights'); +} +``` + +Removing that check does not produce an error anywhere. It produces a home that +anyone enrolled can drive. There is no second layer that will catch it for you. + +**The component trusts nothing it was not given.** It has no network, no storage +and no DOM. It cannot reach the lamp except through a call the coordinator +declared, and it cannot read a state path the coordinator did not grant. This is +why it is safe to let a component be rewritten often and reviewed lightly, and +why it is not safe to move a decision into it. + +**You own the UI; the coordinator owns the facts.** The coordinator exposes state +and actions. What the household actually sees — the layout, the wording, the +language, which controls are prominent — is yours to design from the semantic +vocabulary in §5. Do not push presentation choices into the coordinator, and do +not push authorization into the component. + +## 3. Before you write anything: characterize the house + +You are almost never starting from an empty building. Read what is already there +before you design anything: + +- existing hubs and brokers — Node-RED, MQTT, Zigbee/Z-Wave coordinators, an + HTTP-speaking hub; +- what each device actually reports, and how often; +- which values are *measurements* (temperature, power) and which are + *commanded* (a lamp, a valve) — they have different failure modes; +- which actions are physically consequential: anything that heats, locks, + unlocks, opens or closes. + +Write down what you found before you write the configuration. The state paths you +choose become a disclosure boundary and a public interface at the same time, and +renaming one after the household has used it is not free. + +## 4. The coordinator + +`templates/home/coordinator/home.ts` is the shape to copy: the configuration is a +**pure function of its options**, so it can be tested without a relay, a control +plane or a network. `coordinator/main.ts` is the only file that touches the +outside world. Keep that split. It is what makes `test/home.test.ts` possible, +and the authorization rules are exactly the thing you want under test. + +Four declarations: + +```ts +{ + state: { 'zone.salon.light.on': false }, // paths and initial values + stateAccess: [{ userId, patterns: ['zone.salon.*'] }], + events: [{ topic, directions: EventDirection.publishToUsers }], + eventAccess: [{ userId, publish: [], subscribe: [topic] }], + functions: { async 'lighting.set'(call) { /* authorize, act, return */ } }, +} +``` + +`stateAccess[].patterns` is the disclosure boundary. A user sees exactly those +paths and nothing else. Widen it deliberately, one path at a time, and never with +a wildcard that happens to be convenient. + +Three ordering and failure rules that are easy to get wrong: + +**State first, event second.** Write the state, *then* publish the event: + +```ts +await coordinator.state.set([{ path: STATE.lightOn, value: on }]); +await coordinator.events.publish(EVENT_LIGHT_CHANGED, { on }); +``` + +A subscriber that reacts to the event and immediately reads the state must never +see the old value. The reverse order is a race that will reproduce once a month +and waste a day. + +**Reject bad arguments with a typed application error.** `ApplicationCallError` +carries a numeric code the component can branch on. Do not throw a bare `Error` +for a caller mistake; the distinction between "you asked wrongly" and "something +broke" is the one the interface needs most. + +**A home has several coordinators.** Names are namespace sharding by convention. +The relay keeps ownership tables for topics, state paths and functions, detects +collisions and rejects with `4409`. If you claim `lighting.set` for the whole +house, you have taken a name another integration may need; scope what you own. + +## 5. The component + +Import from `@miakapp/component`. The whole public surface is one module, and the +whole rendering vocabulary is `ui.*`: + +``` +screen stack grid section layout +text status progress media output +button toggle input select interaction +``` + +You return one complete semantic tree per render; the trusted host draws it with +its own components. You do not ship CSS, you do not ship a DOM, and you cannot +style your way around the host. This is a constraint worth accepting rather than +fighting: it is what lets the host stay accessible, themed and localized without +auditing your code. + +Handlers are functions, not identifiers to wire up by hand: + +```ts +ui.toggle({ + id: 'salon-light', + label: 'Lampe du salon', + value: asBoolean(home.state.get(LIGHT_ON)), + disabled: home.staging || !healthy, + pending, + onChange: (next) => void setLight(next), +}) +``` + +Handlers are registered for the render that created them, so a stale tree cannot +fire an action against new state. + +The limits are real and enforced: 1 024 nodes, depth 32, 30 renders per second, +32 outstanding calls, 8 KiB per text node. If you are approaching any of them you +are building a dashboard the household will not read. + +### Two states the interface must never hide + +**Staleness.** `home.state.stale` means the snapshot may no longer reflect the +house. Show it. Per RFC 0002 §12.2 it is exposed, never hidden — a thermostat +reading that is silently forty minutes old is worse than one labelled uncertain. + +**Staging.** `home.staging` is true while a release is staged: rendering works, +calls and events do not. Disable the controls and say why, as the template does. +An interface that looks live and silently does nothing is the worst outcome +available. + +### An unknown outcome is never retried + +This is the rule that will most tempt you to break it, so it is stated plainly: + +```ts +try { + await home.call('lighting.set', { on }, { deadlineMs: 10_000 }); +} catch (error) { + // Deliberately not retried. The call may already have reached the lamp, + // and the next state snapshot settles the question. + failure = error instanceof Error ? error.message : 'La commande a échoué'; +} +``` + +A failed call is not a call that did not happen. `CallOutcomeUnknownError` exists +precisely to name the case where the effect is undetermined, and the physical +world does not have a rollback. Surface it, let the next state snapshot settle +it, and let the person decide. The same principle has a CLI counterpart: exit +code 7. + +## 6. The intersection rule + +Every name a component may touch appears in **two** places, and the effective +grant is the intersection: + +| `miakapp.yaml` → `requires` | `coordinator/home.ts` | +| --- | --- | +| `state_read` | `stateAccess[].patterns` | +| `event_subscribe` | `eventAccess[].subscribe` | +| `event_publish` | `eventAccess[].publish` | +| `call` | `functions` | + +Asking for more than the coordinator grants **does not fail loudly at +publication**. The component simply never receives that path, and the interface +renders a hole — an empty card, a control that does nothing, a temperature that +is permanently unavailable. This is the single most common way a Miakapp home +breaks, and it breaks quietly. + +So: assert the correspondence in a test. `templates/home/test/home.test.ts` does +exactly this, and it is the reason a mismatch fails in CI rather than in +someone's living room. When you add a path, change four things in one commit — +the state declaration, the access pattern, the `requires` entry, and the test. + +## 7. The loop + +```bash +bun run check # typecheck → bundle → test → validate the artifact offline +``` + +Run it before every publication and in CI. It costs nothing and catches the +artifact rules the runtime would reject anyway. + +Do not treat a check that exists as a check that runs. On 2026-09-14 this +repository had a template and a component example that were both verified +locally and built by nobody, because the workflow called `bun run check` and not +`check:packages`; when CI finally exercised them they failed three times in a +row on defects that had been sitting there invisibly. If you add a package, add +it to the job that runs in CI, then watch one run go green before believing it. + +Reproduce CI with the toolchain version it pins, not the one you have. Bun 1.4 +self-references the root package and resolves `bunx` from `node_modules`; the +pinned 1.2.23 does neither, so a green local run proves nothing about the runner. +Install the pinned version alongside yours: + +```bash +BUN_INSTALL=/tmp/bun1223 curl -fsSL https://bun.sh/install | bash -s bun-v1.2.23 +``` + +Note also that `bun test foo/` is a **substring filter**, not a directory scope. + +## 8. Publishing + +The CLI builds, validates, publishes and rolls back. **It never owns Git.** It +writes no history, rewrites no source it did not generate, and invents no +control-plane endpoint. The repository is the owner's. + +```bash +miakapp init --home --control-plane +miakapp check +miakapp publish --expected-generation +miakapp activate --sha256 --expected-generation +miakapp rollback --sha256 --expected-generation # alias of activate +miakapp release # read one finalized release +miakapp upload # reconcile a lost request +``` + +`--expected-generation` is a compare-and-swap on the home's component pointer: the +generation you believe it currently holds. It is `0` for a home that has never +published. It is required, and it is what stops two agents from silently +overwriting each other. + +Rollback is `activate` pointed at a digest you already trust. Keep the digest of +every release you shipped; a rollback you can perform in one command is worth +more than an incident you can explain. + +### Driving the CLI as a program + +Every failure maps to exactly one stable exit code and one stable +machine-readable kind. New kinds may be added; existing codes never change +meaning. Pass `--json` and you get exactly one closed object on stdout. + +| Code | Kind | Meaning | +| --- | --- | --- | +| 0 | `success` | | +| 1 | `usage` | the invocation was wrong | +| 2 | `project` | `miakapp.yaml` is missing or invalid | +| 3 | `artifact` | the built bytes violate an artifact rule | +| 4 | `authorization` | the Home Key is missing, wrong or unscoped | +| 5 | `contract` | the control plane rejected the request | +| 6 | `conflict` | `--expected-generation` did not match; re-read, do not retry | +| 7 | `unknown_outcome` | **effect undetermined — reconcile with a read** | + +Branch on the code, not on the prose. On 7, call `miakapp upload ` or +`miakapp release ` and reconcile before acting again. Never retry a 7 +with a fresh capability. + +## 9. Secrets + +`MIAKAPP_HOME_KEY` comes from the environment. It is never a command-line +argument, never printed, never written into a project file. No command accepts +one, deliberately: an argument lands in shell history, in a process listing and +in most CI logs. + +```bash +export MIAKAPP_HOME_KEY="$(your-secret-manager read miakapp/home-key)" +``` + +If you are about to write a secret into `miakapp.yaml` so something works, stop: +that is the failure this design exists to prevent. + +## 10. Before you tell the owner you are done + +- [ ] Every physically consequential function authorizes its caller on its first + line, and a test proves an unauthorized caller is refused. +- [ ] Every name in `miakapp.yaml` is covered by the coordinator, and a test + asserts the correspondence. +- [ ] `home.state.stale` and `home.staging` are visible in the interface. +- [ ] No call path retries an unknown outcome. +- [ ] State is written before its event is published. +- [ ] `bun run check` is green, and CI ran it — not just you. +- [ ] The Home Key exists only in the environment. +- [ ] You recorded the digest of what you published, so rollback is one command. + +## Where to read further + +- `templates/home/README.md` — the mechanics of the template itself. +- `docs/rfcs/0001` (Miakapp-V3) — wire protocol: ownership, collisions, `4409`. +- `docs/rfcs/0002` — component runtime and the staleness rule. **The broker is + the authority for the guest ABI, not the RFC**: `component-runtime/src/runtime-broker.ts` + and `contract.ts` define the exact payloads, and the broker terminates the + instance rather than answering when a field is wrong. +- `docs/rfcs/0003` — the coordinator SDK surface. +- `docs/rfcs/0004` — Home Key bootstrap, scopes, publication. +- `docs/rfcs/0005` — the trusted browser client.