@akanjs/cli 3.1.0-rc.0 → 3.1.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.build-stamp +1 -1
  2. package/{CodeAgent-q7a4srt6.js → CodeAgent-rkjyxgec.js} +2 -2
  3. package/{CodeTui-ak23dxc9.js → CodeTui-h7pyj6xa.js} +1 -1
  4. package/TunnelShare-661cxz5e.js +14 -0
  5. package/{agent.command-f3gsc1gd.js → agent.command-3qxf4xcj.js} +5 -5
  6. package/akanCode.js +2 -2
  7. package/{application.command-3v2d53gd.js → application.command-m1kcd7jf.js} +54 -42
  8. package/{applicationBuildRunner-1yaqsrdq.js → applicationBuildRunner-dj0xkrep.js} +26 -33
  9. package/buildBatch.proc.js +24 -15
  10. package/{cloud.command-kk2rwkvw.js → cloud.command-9cp1t0f5.js} +63 -65
  11. package/cloud.script-dgstqt1m.js +24 -0
  12. package/{code.command-30sca09d.js → code.command-0ad6gw2a.js} +6 -6
  13. package/{context.command-k7eqacq6.js → context.command-0qx447mb.js} +17 -13
  14. package/context.runner-1vsjztjg.js +25 -0
  15. package/{dependencyScanner-06z2dmrr.js → dependencyScanner-3afj2vt1.js} +1 -1
  16. package/{guideline.command-dtczrqpf.js → guideline.command-cv5bmrx2.js} +2 -2
  17. package/guidelines/agentRule/agentRule.instruction.md +3 -3
  18. package/guidelines/conventions/conventions.instruction.md +457 -1069
  19. package/guidelines/cssRule/cssRule.instruction.md +28 -7
  20. package/guidelines/fieldRule/fieldRule.instruction.md +62 -0
  21. package/guidelines/framework/framework.instruction.md +4 -7
  22. package/guidelines/mcpRule/mcpRule.instruction.md +48 -20
  23. package/guidelines/modelConstant/modelConstant.instruction.md +2 -1
  24. package/guidelines/modelStore/modelStore.instruction.md +9 -0
  25. package/guidelines/pageRule/pageRule.instruction.md +80 -0
  26. package/guidelines/queryRule/queryRule.instruction.md +44 -11
  27. package/guidelines/runtimeRule/runtimeRule.instruction.md +113 -17
  28. package/guidelines/ssrRule/ssrRule.instruction.md +13 -0
  29. package/guidelines/transportRule/transportRule.instruction.md +135 -13
  30. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +4 -4
  31. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +68 -7
  32. package/incrementalBuilder.proc.js +10 -7
  33. package/index-192ha4xh.js +1173 -0
  34. package/{index-c76k723e.js → index-1p98exm2.js} +2 -2
  35. package/{index-rwq3ekqc.js → index-1xj2t7cb.js} +7 -7
  36. package/{index-nz15k0b6.js → index-20bxxyvn.js} +80 -80
  37. package/{index-qt22xstj.js → index-35hc95h5.js} +5 -3
  38. package/{index-jyv387qw.js → index-4mhy6qme.js} +4 -4
  39. package/{index-ww9bhp67.js → index-4pf8g7ff.js} +1 -1
  40. package/{index-2mx692qq.js → index-5edzv8fn.js} +5 -5
  41. package/{index-k9rrten6.js → index-5nd1wtkv.js} +92 -94
  42. package/index-62md4dwy.js +260 -0
  43. package/index-665zp9rs.js +271 -0
  44. package/{index-2etcv0sc.js → index-86jt2a0e.js} +1 -1
  45. package/{index-2t4tqd1c.js → index-8fp3b47h.js} +419 -418
  46. package/{index-c2yrsxdh.js → index-9z426msb.js} +109 -328
  47. package/{index-ctanya41.js → index-awbdfcf2.js} +4 -4
  48. package/{index-3hx1z96c.js → index-bqbf654f.js} +1 -1
  49. package/{index-tn4rpqp1.js → index-bspm21y1.js} +73 -49
  50. package/index-cpb3nvma.js +667 -0
  51. package/{index-p9ystjrp.js → index-d47ypebv.js} +97 -55
  52. package/{index-0bm04n5g.js → index-d72hfmdm.js} +15 -13
  53. package/{index-e0cvze1s.js → index-fmzcs8v6.js} +4 -4
  54. package/{index-y8q31011.js → index-fyq1z6kt.js} +68 -177
  55. package/{index-kjvrq3nc.js → index-g1mbppa9.js} +331 -250
  56. package/{index-gnyf1fqy.js → index-j237xtew.js} +6 -6
  57. package/{index-fm46xrf1.js → index-j4mkhz93.js} +3 -3
  58. package/{index-6jjtnhkw.js → index-jw1zm273.js} +74 -34
  59. package/{index-q958tqqg.js → index-m9jj544v.js} +1 -1
  60. package/index-mdjc93e1.js +33 -0
  61. package/{index-c1x0be5j.js → index-pydbnfez.js} +2 -2
  62. package/{index-7bymavqk.js → index-qwa5k1j8.js} +1 -1
  63. package/{index-v29746vb.js → index-tamvyvj3.js} +3 -3
  64. package/{index-05g3jayg.js → index-trpasv9q.js} +260 -259
  65. package/{index-2ahj896a.js → index-tvehn2sp.js} +1 -1
  66. package/{index-55mynnqs.js → index-va06nr10.js} +1434 -1173
  67. package/index-vk0pa0mn.js +240 -0
  68. package/{index-aprt7pkz.js → index-w44cy4vs.js} +2271 -3369
  69. package/index.js +22 -22
  70. package/{library.command-gaga5bmq.js → library.command-erp2f56n.js} +3 -3
  71. package/{localRegistry.command-9e84e32q.js → localRegistry.command-y176p558.js} +15 -11
  72. package/{module.command-7vpf6db4.js → module.command-gs2r615e.js} +5 -5
  73. package/{package.command-twmvvf69.js → package.command-j10r76ax.js} +4 -4
  74. package/package.json +21 -21
  75. package/{page.command-5asq7fdf.js → page.command-4h4a8pnh.js} +3 -3
  76. package/{pipeline.command-d0v38572.js → pipeline.command-a2nrk26m.js} +221 -220
  77. package/{primitive.command-dyrs2fm8.js → primitive.command-w6t0asqz.js} +6 -6
  78. package/{projectTypecheck-cvtkrzp2.js → projectTypecheck-f534enqj.js} +1 -1
  79. package/{quality.command-6emsqpbg.js → quality.command-77x7vbbx.js} +4 -4
  80. package/{repair.command-8t6aegv5.js → repair.command-y6sstev7.js} +5 -5
  81. package/{scalar.command-bnggy1x7.js → scalar.command-20b8cy0k.js} +4 -4
  82. package/{subspace.command-f02j5tge.js → subspace.command-5c7yasd7.js} +424 -364
  83. package/templates/app/env/env.client.type.ts +2 -3
  84. package/templates/app/page/styles.css.template +8 -0
  85. package/templates/appSample/lib/task/Task.Util.tsx +7 -7
  86. package/templates/appSample/lib/task/Task.Zone.tsx +1 -1
  87. package/templates/appSample/ui/QuantityControl.tsx +2 -2
  88. package/templates/crudSinglePage/page.tsx +1 -1
  89. package/templates/lib/cnst.ts +0 -1
  90. package/templates/workspaceRoot/.gitignore.template +23 -52
  91. package/templates/workspaceRoot/biome.json.template +1 -1
  92. package/{tunnel.command-9x8nfn75.js → tunnel.command-2kw8vt9g.js} +8 -4
  93. package/{workflow.command-jx0wag5r.js → workflow.command-2djwmpqf.js} +10 -10
  94. package/{workspace.command-cmj6mqyk.js → workspace.command-6qfgq4b4.js} +38 -34
  95. package/TunnelShare-21mm6dr5.js +0 -9
  96. package/cloud.script-r7eyfey4.js +0 -20
  97. package/context.runner-hqd1ppqe.js +0 -21
  98. package/index-6w0rbr9d.js +0 -68
  99. package/index-n68jx79w.js +0 -120
  100. package/index-r2tpeb3n.js +0 -392
@@ -1,4 +1,4 @@
1
- # Runtime Rule — Serving, Processes, Logging, Image, Native Apps, Desktop Server, Assets, Build Pipeline
1
+ # Runtime Rule — Serving, Processes, Dev Server, Logging, Image, Native Apps, Desktop Server, Assets, Build Pipeline
2
2
 
3
3
  How an Akan app is built, what it serves, how many processes it runs, where its logs go, and what ends up in
4
4
  its image. Everything here is declared in `akan.config.ts` or narrowed by an env at boot; none of it is reached
@@ -114,6 +114,58 @@ worker per web-serving replica.
114
114
  `AkanServer`, whose graph the gateway never runs; through it the process evaluated 35MB of SSR renderer and
115
115
  SQLite driver to spawn children and relay bytes. Keep entrypoint imports at the leaf.
116
116
 
117
+ ## The Dev Server And Local Runs — `akan start`
118
+
119
+ - After changing application source code, test the app with `bun run akan start <appName>`; test production build
120
+ generation with `bun run akan build <appName>`.
121
+ - **`akan start` takes several apps** — `akan start a,b`, `akan start a b`, `akan start all`, or no argument at
122
+ all for a checkbox that remembers the last pick.
123
+ - **It opens a full-screen view by default, at any app count.** A rail of apps with the processes inside each
124
+ one — `host` for the dev host, gateway and RSC worker, plus a row per replica the gateway forwards — and the
125
+ selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each app's
126
+ digit), `↑↓` scroll (shift for a page, `G` back to following), `/` greps, `e` shows stderr only, `c` clears,
127
+ `y` copies, `Y` copies the log path, `o` opens the browser, `r` restarts that app, `q` quits. Under `--share`,
128
+ `s` copies the public URL — the selected app's, or every app's from the merged row — and the header carries it
129
+ ahead of the local one, because the line `--share` printed before the view took the terminal is gone by its
130
+ first repaint. **`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized
131
+ terminal downgrades to that on its own.
132
+ - **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
133
+ Ink repaints one frame in place, so the full-screen view leaves a bordered screenshot in the scrollback and takes
134
+ the rest of the session with it — a drag-selection is cleared by the next repaint, and what survives carries
135
+ borders and lines truncated to the pane. The file is the same output with no ANSI, nothing truncated, and every
136
+ process of the app in arrival order, including the dev host's own build output, which reaches no other file. The
137
+ previous session is kept beside it as `dev.prev.log`. Hand somebody — or an agent — the path rather than a paste:
138
+ it costs one line instead of a transcript, and it can be re-read after a fix. In the view, `y` copies the lines
139
+ the filters have already narrowed and `Y` copies the path.
140
+ - **How many apps boot at once is the machine's answer, not a constant.** A cold boot is an app's RSS peak (~1.8GB
141
+ per app, most of it the build worker — the base build, then the SSR registry's), so the default wave is what
142
+ memory and cores allow — half the memory budget divided by that peak, and one app per four cores — which boots a
143
+ laptop's apps together and still staggers them inside a small container. The session says which in one line.
144
+ The next wave starts once an app serves and its boot builds have settled, or 30 seconds after it serves, while
145
+ the app shows as ready — and `--open` opens it — as soon as it serves. `--concurrency <n>` overrides the wave
146
+ size, and `AKAN_MEMORY_LIMIT` lowers the budget it derives from.
147
+ - **`--kill` frees the dev ports first** — it resolves each port's listener, walks up to the top of that akan dev
148
+ tree and signals it, so another checkout's server or a stale orphan on the same port is reclaimed. A holder that
149
+ is not recognisably an akan process is reported and left alone.
150
+ - **A dev server watches its own app directory and the libs it actually depends on**, transitively, resolved once
151
+ at boot from the synced manifests — so a save under a lib the app never imports rebuilds and reloads nothing,
152
+ and neither does a save in a sibling app. An import that makes a new lib a dependency needs a fresh
153
+ `akan start`, because it needs a `sync` first anyway. Before the first `sync` the manifests do not exist yet and
154
+ the whole `libs/` container is watched, which is the safe direction.
155
+ - **A built artifact runs from the generated app directory** with the Akan runtime environment variables:
156
+
157
+ ```bash
158
+ cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
159
+ AKAN_PUBLIC_APP_NAME=<appName> AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation \
160
+ AKAN_PUBLIC_BASE_PATHS=<basePaths> bun main.js
161
+ ```
162
+
163
+ - **A server a test boots in-process takes `start({ listen: false, schedules: false })`** to run no internal
164
+ (init, interval, cron, queue worker); `listen: false` alone still runs them, as a batch replica does. A server
165
+ process a test spawns from source resolves the workspace from its cwd and gets the root `.env` from Bun, so
166
+ spawn it inside the repo with `bun --no-env-file`, or spawn the built `dist/apps/<app>` output, which needs
167
+ neither.
168
+
117
169
  ## Logging — Records, Request Context And Live Tail
118
170
 
119
171
  Every `Logger` call builds a `LogRecord` before any text exists: `at`, `level`, OTel `sev`, `name`, `message`,
@@ -152,6 +204,18 @@ normalized to `info` with one warning.
152
204
  - **The generated Dockerfile sets `AKAN_LOG_TO_FILE=0`.** A container's writable layer is ephemeral and nothing
153
205
  collects a file from it; stdout is the collection path. A deployment that wants the files back sets
154
206
  `AKAN_LOG_TO_FILE=1`.
207
+ - **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
208
+ solo replica) its only writer** — every other server process turns its console off and forwards from
209
+ `AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
210
+ `raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
211
+ - **`AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs`** (SSE, `Last-Event-ID` resume, explicit `gap` events);
212
+ unset, the route does not exist.
213
+ - **`AKAN_LOG_CANONICAL=1|slow`** writes one `ok|error <endpoint> ms= status=` line per call; **`AKAN_LOG_FLIGHT=1`**
214
+ keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it failed or ran past
215
+ `AKAN_LOG_FLIGHT_MS`; an `x-akan-debug` header lowers one request to `trace` — in `local`, or with the
216
+ `AKAN_LOG_DEBUG_HEADER` secret.
217
+ - **Structured values go in `LogRecord.attrs` via `Logger.emit(...)`**; a key naming a secret is `[redacted]`
218
+ before the record exists.
155
219
  - **Never log per delivered record in anything that delivers records.** The control socket and the hub do not,
156
220
  and a subscriber asking for everything is the test.
157
221
 
@@ -257,9 +321,10 @@ const config: AppConfig = {
257
321
  Intel Macs are not a target, so `--arch x64` on macOS is refused.
258
322
  - **A release is signed only with `--sign` and notarized only with `--notarize`** (`build-desktop`,
259
323
  `publish-update`); the environment names the identity and never decides to use it, so a shell exporting a Developer
260
- ID signs and uploads nothing unasked. Before either, the server the app carries is searched for every secret value
261
- of the workspace's server env files and refused with the paths, and one line names the identity and the service
262
- the app goes to.
324
+ ID signs and uploads nothing unasked. Every release build and every `publish-update` of a target that carries a
325
+ server, signed or not, first searches that server for every secret value of the workspace's server env files and
326
+ refuses with the paths; an env file it cannot evaluate stops the release too, and a debug build is not searched.
327
+ One line then names the identity and the service the app goes to.
263
328
  - **A macOS release for download is signed with a Developer ID and notarized.**
264
329
  `AKAN_NATIVE_MACOS_IDENTITY` names a keychain identity, or `AKAN_NATIVE_MACOS_CERTIFICATE` +
265
330
  `_CERTIFICATE_PASSWORD` a `.p12` the build imports into a keychain of its own (a CI runner);
@@ -280,12 +345,15 @@ const config: AppConfig = {
280
345
  key. List the online signing key first and offline spares after it (up to three): a phone binary trusts all of
281
346
  them, and a key may revoke only the keys listed before it, so a leaked key cannot lock out its spares. A desktop
282
347
  app trusts the first; `publicKey` is the one-key form. `akan publish-update <app>` builds and signs a release
283
- into `updates/`: upload that folder to `url`, the manifests last, or let `--upload s3://<bucket>/<prefix>` do it
284
- (credentials and endpoint from `S3_*` or `AWS_*`; a release file the bucket holds is skipped). A phone reads only
285
- `<url>/<platform>/<channel>/<nativeApi>.json`, so each store generation on a channel takes its own fixes.
348
+ into `updates/`: upload that release's files to `url` and its manifest last, or let
349
+ `--upload s3://<bucket>/<prefix>` do it (credentials and endpoint from `S3_*` or `AWS_*`; a release file the bucket
350
+ holds is skipped), which sends that release alone, and nothing when it is missing or fails a check. Never upload the
351
+ folder whole: a phone's is also where `pack-update` writes by default.
352
+ A phone reads only `<url>/<platform>/<channel>/<nativeApi>.json`, so each store generation on a channel takes its
353
+ own fixes.
286
354
  `akan pack-update` writes an unsigned phone bundle for whoever holds the key to sign and records it in
287
355
  `<out>/pack.json`, exiting 2 when `--against` says it needs a new store build; `akan push-update` packs and uploads to
288
- an akasys project that holds the key, and `akan register-binary` registers a store build after `release-*`. A
356
+ the Akan Cloud project that holds the key, and `akan register-binary` registers a store build after `release-*`. A
289
357
  release that hangs is rolled back while the app runs, and `report: { url }` has a phone post what became of each
290
358
  release (applied, confirmed, rolled back and why) at its next launch, so rollback rates are visible. `devtools: true`
291
359
  opens the web inspector of a release build for an internal test target. The channel is the backend env the binary was built for unless `channel` names one;
@@ -307,10 +375,11 @@ const config: AppConfig = {
307
375
  their page paints, so a dark board never flashes white, and `desktop.publisher` names who publishes a Windows app in
308
376
  Apps & features and its executable's company (the app name when unset, never Bun's own).
309
377
  - **A third-party page shown first-party** — a scoreboard, a menu board — is the `external-view` plugin, listed in
310
- `native.plugins`: a webview over the window with a browser profile of its own, held to the origins it was opened
311
- for, with an init script, `evaluate`, a trusted `sendKey`, `capture`, and `loadFailed` / `gone` (it reloads, or is
312
- rebuilt, by itself) / `consoleMessage` events. Every method on Windows; macOS 14+ lacks `sendKey`, `setMuted` and
313
- `capture`.
378
+ `native.plugins`: a webview over the window with a browser profile of its own, its top level held to the origins it
379
+ was opened for while its frames load web content from anywhere, with an init script that runs in the top document
380
+ only, `evaluate`, a trusted `sendKey`, `capture`, and `loadFailed` / `gone` (it reloads, or is rebuilt, by itself;
381
+ the reason is `terminated` on macOS, and `rebuildFailed` when a rebuild failed and the view is closed, to be opened
382
+ again) / `consoleMessage` events. Every method on Windows; macOS 14+ lacks `sendKey`, `setMuted` and `capture`.
314
383
 
315
384
  ## A Desktop App's Server — `bin` And `trustedDependencies`
316
385
 
@@ -326,7 +395,7 @@ has none, or none when it has one, is refused, and switching means a reinstall.
326
395
  tied to the image's system, code only a process the app never starts loads — and whatever only they pull in; the
327
396
  image keeps them, and the server's own bundle must not import them. A package another dependency still installs
328
397
  stops the build, naming who needs it. So does a native addon that would not load on a user's computer: no binary
329
- for the app's OS and CPU, a link outside the system, or a `binding.gyp` its install never compiled. It runs as an API-only edge server on SQLite, on a loopback port the launcher picks — the last session's when
398
+ for the app's OS and CPU, a link outside the system, or a `binding.gyp` its install never compiled. It runs as an API-only edge server in database mode `single` (SQLite), on a loopback port the launcher picks — the last session's when
330
399
  it is free, which is not a guarantee, so a provider that needs an exact redirect URI signs in through a cloud
331
400
  server's adapter. It refuses a `Host` other than its own, but any program on the computer can still call that port:
332
401
  guard its endpoints as a network server's. It runs in one process, so a `main.ts` or an env asking for replicas (`replica`,
@@ -416,10 +485,14 @@ in those alone, and `modules: ["@edge"]` (or `AKAN_MODULES=@edge`) names every m
416
485
  inject, cascade to and hold a relation to. `desktop: { server: { profile: "edge", modules: ["@edge"] } }` builds the
417
486
  carried server that way: `env/env.server.<env>.edge.ts` is bundled in place of `env/env.server.<env>.ts`, which never
418
487
  enters it, and the build evaluates the profile file with no app env and refuses one whose secret-named keys hold a
419
- value — a `process.env.X ?? "literal"` counts as the literal. Each lib's env defaults (`env.server.testing.ts`) are
488
+ value — a `process.env.X ?? "literal"` counts as the literal. Signing, encryption, master and session keys, `pwd`, a
489
+ `…dsn` and a connection string are secret-named, and a URL holding a password or a secret-named query parameter is a
490
+ secret under any key. The profile may spread `libEnv`, and the lib defaults it reaches are checked as the bundle
491
+ carries them; a profile that imports the file it replaces is refused. Each lib's env defaults (`env.server.testing.ts`) are
420
492
  swapped the same way: for the lib's `env/env.server.testing.edge.ts`, held to the same check, or for the defaults with
421
- every secret-named value left out, which the build names in a warning. `akan build <app> --profile edge` does the
422
- same for an image. A module listing only edge does not mount under `akan start` (local): list `local` too to develop
493
+ every secret-named value left out, which the build names in a warning — and a default a JSON snapshot cannot carry (a
494
+ function, Map, Set, Date, class instance, bigint or non-finite number) is refused unless the lib has its profile file.
495
+ `akan build <app> --profile edge` does the same for an image. A module listing only edge does not mount under `akan start` (local): list `local` too to develop
423
496
  it there.
424
497
 
425
498
  **A file the user picks reaches the server as a grant, never as a copy or a path.** With `"file-picker"` in
@@ -444,6 +517,29 @@ page API is
444
517
  hook; `volume` and `filePicker` also come from `akanjs/client/native` itself. A capability the shell
445
518
  lacks is added there: an app's own plugin runs as Bun code in the plugin host and cannot add a native shell op.
446
519
 
520
+ **A plugin the app or lib owns lives in `native/<id>/`**, the folder named after the plugin id: `native-plugin.json`
521
+ beside its page API (`src/index.ts`, `definePlugin` from `akanjs/client/native`), its desktop part (`src/desktop.ts`,
522
+ `defineDesktopPlugin` from `akanjs/native/desktop`), Kotlin and Swift. Every mobile and desktop target ships it; a
523
+ lib's reach only the apps that depend on it, and the app's own wins an id. Its page API is imported by a `webkit/`
524
+ hook (`../native/<id>/src`) and nothing else; pages and components call the hook, the same way they reach
525
+ `akanjs/client/native`. A plugin throws `AkanNativeError`, the error its bridge carries back to the page, which is
526
+ why `no-throw-raw-error` exempts a root `native/` folder.
527
+
528
+ **A page's storage and browser APIs follow the shell, not the browser.** A native shell serves the page from
529
+ `app://localhost` on iOS, macOS and Linux, which keeps no cookies: `document.cookie` reads `""` and a write is
530
+ dropped, so the value exists in the browser and vanishes in the app. Web Storage does work in the shells, but it
531
+ bypasses the store akanjs picks per platform (the shell's Preferences in an app) and throws during SSR. So app and lib
532
+ code never touches `document.cookie`, `localStorage` or `sessionStorage` (`no-document-cookie`, `no-web-storage`): it
533
+ uses `getCookie` / `setCookie` / `removeCookie`, `storage`, and `secretStorage` for tokens and credentials, all from
534
+ `akanjs/client`. `setCookie` writes `path=/; SameSite=Lax; Secure`, and an options object overrides only the keys it
535
+ names — pass `sameSite: "none"` only for a cookie a cross-site POST must carry back. Browser APIs an app WebView lacks
536
+ stay in a `webkit/` hook that branches on `isNativeApp()` from `akanjs/client/native`, which is where the app side goes
537
+ too (`no-web-only-api-outside-webkit`, a warning): `navigator.share` / `canShare` (Android's WebView has none),
538
+ `navigator.serviceWorker` and `Notification.requestPermission` / `.permission` (no app page has them),
539
+ `navigator.geolocation` (use `useGeoLocation` from `akanjs/webkit`) and `navigator.vibrate` (iOS has none; use
540
+ `haptics`). They are legal on the web, so the rule does not ban them; a root `native/` folder is out of its scope,
541
+ since a plugin's web part is where those APIs belong.
542
+
447
543
  ## Database Modes — `database` In `akan.config.ts`
448
544
 
449
545
  | mode | database | cache · queue · pubsub | runs as |
@@ -556,7 +652,7 @@ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve,
556
652
 
557
653
  Akan Cloud builds and deploys each app as `build` → `docker` → `deploy`, and `ota` after them when asked. **An app's own stages go in
558
654
  `apps/<app>/akan.config.ts` under `pipeline`; stages around every app go in the workspace root's
559
- `akan.config.ts`**, typed `WorkspaceConfig` (`before`, `after`, `post`, `env`, `timeout`). Both are `import type`
655
+ `akan.config.ts`**, typed `WorkspaceConfig`, under its own `pipeline` (`before`, `after`, `post`, `env`, `timeout`). Both are `import type`
560
656
  objects (or `(ctx) => …`), and `akan pipeline dry-run | validate | plan` turns them into the plan Akan Cloud runs —
561
657
  locally, running nothing.
562
658
 
@@ -38,6 +38,19 @@ A third-party client package and the `ui/<Folder>/index_.tsx` + `lazy()` boundar
38
38
  not flagged. Interaction-driven `fetch.*` inside an `onClick` is not flagged either — only mount-time loads are,
39
39
  because those are the ones the server could have performed.
40
40
 
41
+ ## Lint Rules Behind The Boundary
42
+ - `no-async-component-in-ui` (`{apps,libs}/**/ui/**/*.tsx`) — React has no async client component, so a `ui/`
43
+ component that awaits breaks as soon as a client parent renders it, and the load drops below the route, which
44
+ could have started it before the first byte. Only a PascalCase binding whose own initializer is `async` is matched,
45
+ so an async handler inside a synchronous component and `lazy(async () => import(…))` are untouched.
46
+ - `no-init-fetch-in-client` — `fetch.init<Model><Suffix>` and `fetch.get<Model>Init<Suffix>` compose the slice's list
47
+ and insight queries into the hydration snapshot `Load.Units` / `Load.View` seed the store from. From a route it
48
+ resolves before the first byte; after hydration it is two extra round-trips for a shell the browser already
49
+ painted. Load it in the page and pass the result (or the unawaited `x<Model>Init<Suffix>` promise) down as `init`,
50
+ and reload from the client through the generated `st.do.init<Model><Suffix>()`. The rule is gated on the real
51
+ `"use client"` directive plus `*.store.ts` and matches by shape (`init` + `Capitalize<refName>` +
52
+ `Capitalize<suffix>`), so a hand-written `initPayment` is out.
53
+
41
54
  ## Implementation Playbook
42
55
 
43
56
  **① Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
@@ -1,13 +1,20 @@
1
- # Transport Rule — Guards, Sockets, Binary Frames, HTTP Verbs
1
+ # Transport Rule — Guards, Sockets, Deadlines, Rate Limits, Server-To-Server Calls, Bytes, HTTP Verbs
2
2
 
3
3
  How a call reaches a signal: what a guard may read on either transport, how a websocket carries its credential
4
- and its per-connection state, when a pubsub payload leaves JSON for a binary frame, and which HTTP verb a
5
- mutation is mounted under. `conventions` carries the invariants — this is the full contract behind them.
4
+ and its per-connection state, how long a call may take and what it may cache or count, how one server calls
5
+ another, which body types and bytes cross the wire, when a pubsub payload leaves JSON for a binary frame, and which
6
+ HTTP verb a mutation is mounted under. `conventions` carries the invariants — this is the full contract behind them.
6
7
 
7
8
  ## Guards And Transports
8
9
 
9
10
  - Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` (`pkgs/akanjs/signal/signalContext.ts`) instead of branching on `getHttpContext()` / `getWebSocketContext()`.
10
11
  - Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is unguarded unless it declares its own `guards` in its signal option.
12
+ - **Every endpoint that declares no guards is named in a boot `warn`**, since it answers anyone over HTTP and
13
+ WebSocket — a named slice's `init()`, a root slice, a `pubsub`/`message`/`query`/`mutation`. Write
14
+ `guards: [Public]` to keep one open on purpose.
15
+ - **A generated verb is taken off HTTP with `guards: { cru: false }`** (or `get`/`create`/`update`/`remove:
16
+ false`), and a verb whose guards resolve to none is not mounted at all. `mcp: { cru: false }` does **not** do
17
+ this: it keeps the verbs off the agent shelf while HTTP serves them to whoever passes their guards.
11
18
  - A pubsub room is authorized once, at subscribe. When a socket's credential changes the framework re-runs each room's guards and unsubscribes the ones that now fail (`SignalResolver.revalidateWsRooms`), so guards must stay side-effect free and safe to re-run. Each re-check runs on a fresh `SignalContext`, so a guard that memoizes per context answers anew.
12
19
  - **Arguments are parsed after the `"account"` guards** (`SignalContext.init` reads the body, `exec` parses): the order is middlewares → `account` guards → parse → `resource` guards → internal arguments → handler, whatever order `guards: [...]` lists them in. A caller an account guard refuses never reaches a scalar parser, which is what keeps a slow parser from being a pre-auth denial of service. A guard or middleware that reads `context.args` / `getArg()` earlier still gets the value, parsed on first read — a mismarked account guard keeps working. One visible consequence: an unauthenticated call with a malformed argument answers 401/403 instead of 400.
13
20
  - A websocket carries its credential in the handshake snapshot on `ws.data` (`AppWsData`); clients that hold the token in memory send it with `fetch.setJwt(...)`, which forwards an auth frame over the socket.
@@ -24,7 +31,8 @@ mutation is mounted under. `conventions` carries the invariants — this is the
24
31
  - **A device or another server proves itself with a machine token.** `MachineToken.issue({ kind, id, version },
25
32
  { expiresAt? })` in `akanjs/server` signs an EdDSA JWT (`typ: akan-machine+jwt`, `aud: <app>/<environment>`) with
26
33
  `AKAN_MACHINE_PRIVATE_KEY`; it never expires unless asked. `akan machine-keygen <file>` writes that key to a file
27
- and prints only the public one. A server with either key verifies the bearer of every call — HTTP, and the
34
+ and prints only the public one. A server with either key (`AKAN_MACHINE_PRIVATE_KEY` or
35
+ `AKAN_MACHINE_PUBLIC_KEY`) verifies the bearer of every call — HTTP, and the
28
36
  websocket's auth frame — after every lib middleware, and sets `account.machine = { kind, id, version }` from a
29
37
  verified token; it takes off a `machine` any other middleware or a session's claims put there, so a staff token
30
38
  can never pass for one, and a session middleware already reads the EdDSA token as anonymous. `.with(Machine)` hands
@@ -47,14 +55,37 @@ mutation is mounted under. `conventions` carries the invariants — this is the
47
55
  change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
48
56
  neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
49
57
  the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
58
+ - **A subscribe the client leaves before it finishes joins nothing.** When an `unsubscribe` for the room, or the
59
+ socket's close, arrives while the subscribe is still running, the subscribe answers `subscribe: false` and runs its
60
+ `unsubscribe` cleanup (and its `disconnect` one when the socket closed) instead of joining the room.
61
+
62
+ ## Writing A Guard
63
+
64
+ - **Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts`** that `implements Guard` with an
65
+ `async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn`
66
+ then `false`. Admin bypass goes first.
67
+ - **Keep `static name = "User";` on guard classes.** `fetch` serializes guard names and the API explorer filters
68
+ on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
69
+ - **Every guard class declares `static scope: GuardScope`, required with no default.** `"account"` means the
70
+ verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what
71
+ lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments
72
+ (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays
73
+ visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than
74
+ defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`.
75
+ What a mismarked guard does to a listing, and `static agents = false` on `Person`: `mcpRule`.
76
+ - **Guards ship with the library that owns the model** and are imported by its own signals through the package
77
+ path, so a mounting app inherits authorization and cannot forget it.
78
+ - The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`; never trust a client-supplied
79
+ id. Services re-check ownership even when a guard already gated the call — two independent gates.
80
+ - `srvkit/guards.ts` earns real comments: explain what would leak without each guard.
50
81
 
51
82
  ## Request Deadlines
52
83
 
53
84
  - **`{ timeout: <ms> }` in an endpoint's signal option is the whole configuration surface**, and it reaches both
54
85
  ends of the call. Server side, the `Timeout` middleware races the handler against it and answers
55
86
  `base.error.gatewayTimeout` (504) once it is spent. Client side, `FetchSerializer` ships the value on the
56
- serialized endpoint and `fetch.<endpoint>()` uses it as that request's `AbortSignal.timeout` budget, so the
57
- browser does not abandon a call the server is still allowed to be working on.
87
+ serialized endpoint and `fetch.<endpoint>()` uses it as that call's deadline — every retry of the call and every
88
+ wait between them included — so the browser does not abandon a call the server is still allowed to be working on.
58
89
  - **The middleware is registered by default and has no default of its own.** An endpoint that declares no
59
90
  `timeout` is passed straight through — a default here would put a deadline on every endpoint in the app that
60
91
  nobody asked for, which is why the middleware used to be opt-in and, being opt-in, made the declared value
@@ -62,7 +93,9 @@ mutation is mounted under. `conventions` carries the invariants — this is the
62
93
  five-minute budget that is a five-minute hold on the event loop for a call that answered in a millisecond.
63
94
  - **Without a declaration the client's own default applies — 30 seconds** (`DEFAULT_TIMEOUT_MS`,
64
95
  `fetch/client/httpClient.ts`), and the server imposes nothing. Nothing else bounds a request: `Bun.serve` runs
65
- with `idleTimeout: 0`, so a long call is only ever ended by one of these two.
96
+ with `idleTimeout: 0`, so a long call is only ever ended by one of these two. An endpoint whose real work runs
97
+ longer — provisioning, a firmware flow, an external orchestration — therefore has to declare one, or it dies
98
+ mid-flight with a transport error that does not say whether the server ever received it.
66
99
  - **Precedence is caller, then endpoint, then client default.** `fetch.x(…, { timeout: 5_000 })` overrides what
67
100
  the endpoint declared; `{ timeout: false }` waits as long as the runtime will. An app moves its own default
68
101
  with `fetch.instance.setTimeout(ms)` — `lib/useClient.ts` is generated, so the `timeout` option on
@@ -92,13 +125,65 @@ mutation is mounted under. `conventions` carries the invariants — this is the
92
125
  - **The entry is the handler's result, not the response.** The lookup wraps the handler only, so `resolveReturn`
93
126
  still runs per call on the cached value — field masking, `hidden`/`secret` stripping and relation resolution are
94
127
  never cached, and a relation still costs its own load. The cache saves the handler, not the serialization.
128
+ - **A hit hands `resolveReturn` an instance of the declared return model**, rebuilt from an entry that keeps its
129
+ `Map`s, bytes and dates, so a `resolveField` reads the same methods and `Dayjs` values on a hit as on a miss. It is
130
+ the declared model, not the service's document: a method only the document has is not there on a hit, and a return
131
+ declared as a `Light` model shows `resolveField` its light fields only. The entry still holds `hidden` and `secret`
132
+ values for its TTL, as the handler returned them.
133
+ - **A write drops the answers it made stale.** Plug the adaptor into the service and call it after the write:
134
+
135
+ ```ts
136
+ export class BannerService extends serve(db.banner, ({ plug }) => ({
137
+ endpointCache: plug(EndpointCacheControl),
138
+ })) {
139
+ override async _postUpdate(banner: db.Banner) {
140
+ await this.endpointCache.invalidate("bannerListInPublic");
141
+ await this.endpointCache.invalidate("bannerInsightInPublic");
142
+ return banner;
143
+ }
144
+ }
145
+ ```
146
+
147
+ `invalidate(endpoint)` drops every cached answer of that endpoint, across instances. A slice's `init({ cache })`
148
+ caches its `<model>Insight<Slice>` as well as its list, so drop both or the count stays stale.
149
+ `invalidate(endpoint, args)` drops the one call with those arguments: pass every declared argument in order as the
150
+ endpoint receives it, `null` for an omitted search argument (search arguments are nullable, so an omitted one is
151
+ stored as `null`). A slice list also carries `skip` / `limit` / `sort`, so drop it with the endpoint-wide form.
152
+ A mismatched list misses silently and still answers `true`.
153
+ - **How it is keyed:** every entry key carries its endpoint's generation, a random token the endpoint-wide form
154
+ replaces in one write. Nothing is scanned, and a miss already running when you invalidate writes under the old
155
+ token, where nobody reads it. A token is never reused, so a generation that expires (after a day) or is evicted
156
+ starts a fresh one instead of reviving dropped answers, and an endpoint name nothing serves leaves no key behind for
157
+ good. **A `{ cache }` TTL is capped at one day**, the generation's life: an entry outliving its token could never be
158
+ read, so a longer TTL is written as a day. Entries under an old token stay in the cache until their own TTL, and a
159
+ Redis that BullMQ shares has to run `noeviction`, so nothing frees them early: give an endpoint you invalidate often
160
+ a short TTL. The one-call form
161
+ deletes that entry, so a miss for those arguments already running can write it back; reach for the endpoint-wide
162
+ form when that matters. It answers `false` (logged as an error) when the cache failed. A cached call reads the
163
+ generation before its entry.
95
164
  - **A cache backend that is down does not take the endpoint down.** A failed read is warned and the call runs
96
165
  uncached; a failed write is warned and dropped.
97
- - The default middleware chain is `Logging → Timeout → <lib middlewares>`, and the call inside it runs an
98
- `account` rate limit → account guards → argument parse → resource guards → internal arguments → cache →
99
- handler: a hung cache backend is bounded by the same deadline as the handler it stands in for. **`Retry` was removed** — an automatic re-run of an endpoint
166
+ - A call runs the cross-site check and an `ip` rate limit, then the body read, then the default middleware chain
167
+ `Logging → Timeout → <lib middlewares>`, and inside it an `account` rate limit → account guards → argument
168
+ parse → resource guards → internal arguments → cache → handler: a hung cache backend is bounded by the same
169
+ deadline as the handler it stands in for. **`Retry` was removed** — an automatic re-run of an endpoint
100
170
  whose failure it cannot classify replays whatever the first attempt already did.
101
171
 
172
+ ## Repeated Mutations
173
+
174
+ - **A mutation sent again under the same `Idempotency-Key` header runs once.** The key is checked after the
175
+ resource guards and the internal arguments, and it is scoped to the endpoint, the caller and a digest of the
176
+ arguments, so one caller's key never hands another caller its answer. A repeat while the first run is still going
177
+ answers 409 `base.error.idempotencyInFlight`.
178
+ - **A repeat answers the first response as it was sent** — resolved, masked and serialized — for 24 hours. What
179
+ is kept is that wire form, never the handler's result, so no `hidden` or `secret` field reaches the cache.
180
+ - A handler that throws frees the key, so a repeat runs it again. A failure after the handler returned is kept and
181
+ replayed instead, for the same 24 hours and a transient one (a relation read during a database blip) included:
182
+ the handler's writes stand, so running it again would repeat them.
183
+ - **Only an HTTP call is deduplicated.** A signed-out call, one whose handler returns a `Response` (whose headers,
184
+ such as a session's `Set-Cookie`, cannot be replayed), a websocket call, and a call through MCP all run every
185
+ time.
186
+
102
187
  ## Rate Limits
103
188
 
104
189
  - **`{ rateLimit: { calls, windowMs?, by? } }` in an endpoint's signal option** counts calls per caller per window
@@ -107,9 +192,9 @@ mutation is mounted under. `conventions` carries the invariants — this is the
107
192
  checked after the middlewares and before the guards, keyed by `accountKey(account)`; a caller it names nothing
108
193
  for — an anonymous one — is counted by address. Leaving a pubsub room is never counted.
109
194
  - **The answer is 429 `base.error.tooManyRequests` with `data: { seconds }`** and a `Retry-After` header; a WS
110
- call gets the same payload as its error frame, and the HTTP client restores a proxy's own non-JSON 429 under the
111
- same key (seconds from its `Retry-After`, else 60). The first refusal per endpoint per process is warned once;
112
- later ones log at `debug`, so a flood does not become a log flood.
195
+ call gets the same payload as its error frame, and the HTTP client restores a proxy's own 429 — a page, or JSON
196
+ naming no error key of ours — under the same key (seconds from its `Retry-After`, `0` included, else 60). The first
197
+ refusal per endpoint per process is warned once; later ones log at `debug`, so a flood does not become a log flood.
113
198
  - **Keys and addresses.** The address is `context.getClientIp()` — the trusted-proxy answer, never a raw header
114
199
  from an untrusted peer. Callers whose address is unknown share one `unknown` budget (fail closed), and the first
115
200
  such call is warned about, since it usually means the proxy in front records no `X-Real-IP`/`X-Forwarded-For`
@@ -134,6 +219,43 @@ mutation is mounted under. `conventions` carries the invariants — this is the
134
219
  `{ ok: true } | { ok: false, retryAfterMs }` and is never switched off — throw the module's own `Err` with the
135
220
  wait when it refuses.
136
221
 
222
+ ## Calling Another Server
223
+
224
+ - **A server-side `fetch.clone({ origin })` opens no websocket unless `connect: true`**, and neither does a clone
225
+ of such a clone: it makes one-off calls to another server, and a socket it opened would reconnect forever with
226
+ nobody to close it. It never carries the visitor's session either: a call to another origin sends only the `jwt`
227
+ its caller gave the clone.
228
+ - **Retry a call with `retry`, never with a loop of your own.** `fetch.clone({ origin, retry: {} })` (or a call's
229
+ own `{ retry }`) tries again on an unreachable server, a 502/503/504 and a 429 after its Retry-After, backing off
230
+ with jitter until the call's one deadline runs out. A mutation retries under one `Idempotency-Key` it mints, or
231
+ the `idempotencyKey` you name, and the server runs it once per key, caller and arguments (Repeated Mutations
232
+ above); a signed-out call, one whose handler returns a `Response`, and a call through MCP run every time. A 4xx
233
+ is never retried.
234
+ - **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })`
235
+ restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller
236
+ with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a
237
+ `new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to
238
+ `Internal Server Error` on the way out.
239
+ - `String(err)` and `${err}` read `Err: <key> {…data}`, the data as one line of JSON with secret-named keys masked,
240
+ so a ``logger.warn(`…: ${String(error)}`)`` keeps the exit code or status the `Err` carried.
241
+
242
+ ## Body Types And Bytes
243
+
244
+ - `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
245
+ Numbers are `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
246
+ - **`Upload` is valid only inside a mutation flagged for file upload**:
247
+ `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a
248
+ model field type. The other body args of such a mutation arrive typed as in a JSON body. A file's `type` on the
249
+ server is what its name implies, not its bytes — check the bytes when the type matters.
250
+ - **`Binary` is raw bytes on the wire.** It is `Uint8Array` on both sides — a Node `Buffer` is one, so a server
251
+ handler may pass one straight in — and it accepts base64 in either direction, so the same declaration serves a
252
+ JSON body and a binary frame. A `Binary` return travels as base64 in an HTTP body. It is never a model field
253
+ (`fieldRule`).
254
+ - **Never send bytes as `Any`.** `Any` passes a `Buffer` through untouched and `JSON.stringify` then spells it
255
+ `{ type: "Buffer", data: number[] }`, which `JSON.parse` never restores; the result is 3.6x the wire and a shape
256
+ that only breaks at the first byte-offset read. A return declared `Any` that turns out to be bytes fails instead
257
+ of reaching the caller as `{}`, and MCP refuses a `Binary` return for the same reason it refuses `Any`.
258
+
137
259
  ## Binary Pubsub
138
260
 
139
261
  - **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON `{ type: "pub" }`
@@ -230,10 +230,10 @@ Six worked recipes carry the full code for the most frequent Akan changes, toget
230
230
  | Recipe | Covers |
231
231
  |---|---|
232
232
  | 1. Adding a field to a model | the five `constant.ts` classes, the dictionary entry, the store form, `akan sync` |
233
- | 2. Injecting a dependency into a service | `service<srv.XService>()`, `plug(Adapter)`, `env(...)`, `use<T>()` |
233
+ | 2. Injecting a dependency into a service | `service<srv.XService>()`, `plug(Adapter)`, `env(...)`, `use<T>()`, writing an `adapt()` adapter |
234
234
  | 3. Creating and using a slice | `slice()` guards, the generated `init<Model><Suffix>`, `Load.Units` hydration |
235
235
  | 4. Creating a mutation endpoint | the document chain method, the service call, the signal `guards`, the store action |
236
- | 5. Internal triggers | `internal()` with interval and cron |
236
+ | 5. Internal triggers | `internal()` with interval and cron, queue retries and the dead letter |
237
237
  | 6. Creating an insight | the `XInsight` class, the aggregation, the dashboard tile |
238
238
  | Data Flow Summary | how a value travels page → store → signal → service → document → DB |
239
239
  | Auto-Generated API Reference | what signal, service, store, and document each generate — never hand-write these |
@@ -244,7 +244,7 @@ existing content first and change only the relevant sections — never rewrite a
244
244
  ## Modeling & Query Gotchas
245
245
 
246
246
  A short list of things the type system does not always catch. The full rules for text search, `cascade`, and the
247
- query-level writes are in the convention set above; these are the shapes that build green and fail later.
247
+ query-level writes are in `queryRule`; these are the shapes that build green and fail later.
248
248
 
249
249
  - **Slices return a query, not a list.** A slice `exec` must return `this.<model>Service.query<Filter>(...)`, never
250
250
  a `list<Filter>(...)` / `listBy...(...)` array. Returning an array type-checks but fails at runtime with
@@ -258,7 +258,7 @@ query-level writes are in the convention set above; these are the shapes that bu
258
258
  **bare** — `this.userModel.pickById(id, { passwordHash: true })`, likewise `pickOne` / `find` / `findOne` /
259
259
  `findById`. A generated filter accessor takes it **inside the option object** —
260
260
  `this.userModel.findByAccountId(accountId, statuses, { select: { passwordHash: true } })`. Handing `{ select: … }`
261
- to the facade selects a field named `select`, which no model has: the value arrives empty, with no error.
261
+ to the facade projects a field named `select`, which no model has, and throws `Unknown document field path`.
262
262
  - **`cascade` names a direction, and the wrong one is a data loss** — `removeRef` on the relation the owner holds,
263
263
  `removeWith` on the child's reference to its owner. A query-level removal fires no hooks and therefore no cascade.
264
264
  - **`q.search()` is a filter node, not a slice requirement.** `bySearch: filter().arg("text", String).query((text,
@@ -66,11 +66,12 @@ const form = st.use.taskForm();
66
66
 
67
67
  ### Recipe 2: Injecting a Dependency into a Service
68
68
 
69
- Three patterns: injecting an **external adapter** (`use<>()`), another **module's service** (`service<>()`),
70
- or a **predefined framework adapter** (`plug()`). A field named `<refName>Service` resolves to the service
71
- registered under `<refName>` — the `Service`/`Signal` suffix is required and stripped to derive the lookup key.
69
+ Four patterns: injecting an **external adapter** (`use<>()`), another **module's service** (`service<>()`),
70
+ a **predefined framework adapter** (`plug()`), or **an adapter you own** (`adapt()` + `plug()`). A field named
71
+ `<refName>Service` resolves to the service registered under `<refName>` — the `Service`/`Signal` suffix is required
72
+ and stripped to derive the lookup key.
72
73
 
73
- > **For a new adapter you own, prefer the `adapt()` shape in pattern C over the `option.ts` registration in
74
+ > **For a new adapter you own, prefer the `adapt()` shape in pattern D over the `option.ts` registration in
74
75
  > pattern A.** An `adapt()` class self-registers and is injected with `plug(TheClass)`, so it never touches
75
76
  > `option.ts`. Pattern A is the legacy constructor-style shape: recognise it, keep it working, and migrate one
76
77
  > only when you are already changing it.
@@ -141,12 +142,64 @@ export class TaskService extends serve(db.task, ({ plug }) => ({
141
142
  }
142
143
  ```
143
144
 
144
- For a custom adapter class (not a predefined role), pass the class itself, e.g. `ipfsApi: plug(IpfsApi)`.
145
+ To reach the file itself (a `Bun.file()` response with ranges, a CLI tool, a size check), ask the role where it
146
+ lives: `this.storage.resolveLocalPath?.(path, access)` (`access` optional) answers the absolute disk path
147
+ `BlobStorage` uses, picking the root as its writes do with `access` and by the `private/` prefix without it. It
148
+ answers `null` for a key that is rooted, holds a NUL, or leaves that root — refuse that key, never fall back to
149
+ `readData()` for it. Fall back only when the adaptor has no `resolveLocalPath` at all (object storage). **A key
150
+ that reaches you from a request passes `access: "public"`**: without it a `private/` key resolves under the private
151
+ root, so a public route would serve private files. Never cast the role to reach `root` / `privateRoot`.
152
+
153
+ For a custom adapter class (not a predefined role), pass the class itself, e.g. `ipfsApi: plug(IpfsApi)`. Two framework
154
+ adapters are plugged that way: `rateLimit: plug(RateLimit)` to count a business key, and
155
+ `endpointCache: plug(EndpointCacheControl)` to drop an endpoint's `{ cache }` answers after the write that made them
156
+ stale: `await this.endpointCache.invalidate("bannerListInPublic")`, plus `"bannerInsightInPublic"` for a slice's
157
+ count.
145
158
  Injecting a file/image field is usually simpler than calling
146
159
  storage directly: declare `image: field(File).optional()` (or `images: field([File])`) on the model and let the
147
160
  store's generated `upload<Field>On<Model>(fileList)` action handle the upload. Add `{ cascade: "removeRef" }` to that
148
161
  field when the file belongs to the model alone, and removing the model removes the file and its stored object.
149
162
 
163
+ **D. An adapter you own — `adapt()` in `srvkit/`**
164
+
165
+ An injected singleton is an `adapt()` class in `srvkit/`. Write new adapters this way.
166
+
167
+ ```typescript
168
+ export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, memory }) => ({
169
+ discordApi: use<DiscordApi>(),
170
+ workspaceRoot: env(() => `~/build/${getEnv().environment}`),
171
+ masterHost: plug(MasterHost),
172
+ tokenMap: memory(Map, { of: String }),
173
+ })) {}
174
+ ```
175
+
176
+ - Inject it with `plug(TheClass)` from a service or from another adapter. Destructure only the injectors you use,
177
+ and write the registration key `as const`.
178
+ - **Do not register an `adapt()` class in `lib/option.ts`.** It self-registers, and `plug(Class)` uses the class
179
+ itself as the token. `option.ts` is now only for legacy constructor-style adapters and for widening the options
180
+ type.
181
+ - `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in
182
+ `override async onInit()`.
183
+ - **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site. If
184
+ there is not exactly one per process that a service needs injected, it is a plain class.
185
+ - **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })`
186
+ serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the
187
+ same declaration. Never hand-encode JSON into a `String` memory. A memory is scoped to the service or adaptor
188
+ that declares it; `ttl` (milliseconds) sets how long each write lives unless the `set` call names its own
189
+ `expireAt`, and a `Map` entry expires on its own.
190
+
191
+ Conventions that hold for both adapter shapes (A and D):
192
+
193
+ - Route every remote call through one private `#api<T>(path, init?)` with `signal: AbortSignal.timeout(20_000)`.
194
+ - Paginate with `for (let page = 1; ; page += 1)` broken by `if (pageItems.length < 100) break;`.
195
+ - Resolve secrets as `process.env.X ?? options.x ?? deterministicGenerator(...)` **inside a function**, never at
196
+ module scope.
197
+ - Extend a function by appending an optional trailing parameter with a default, never by changing arity.
198
+ - Parameters: up to three required primitives positional; optional flags in a trailing `{ … } = {}`; four or more
199
+ parameters, or any two same-typed strings, in one named destructured object.
200
+ - Release locks in `finally`. Load heavy optional dependencies through a module-level memoized promise
201
+ (`puppeteerLoad ??= import("puppeteer")`).
202
+
150
203
  ---
151
204
 
152
205
  ### Recipe 3: Creating and Using a Slice
@@ -245,7 +298,11 @@ async startTask(taskId: string) {
245
298
 
246
299
  // 6. <Model>.Util.tsx — Reusable button component
247
300
  export const Start = ({ taskId }: { taskId: string }) => (
248
- <button className={buttonRecipe({ variant: "primary", size: "xs" })} onClick={() => st.do.startTask(taskId)}>
301
+ <button
302
+ type="button"
303
+ className={buttonRecipe({ variant: "primary", size: "xs" })}
304
+ onClick={() => st.do.startTask(taskId)}
305
+ >
249
306
  Start
250
307
  </button>
251
308
  );
@@ -285,6 +342,10 @@ async cleanupStaleTasks() {
285
342
  - `initialize()` — runs once on service startup
286
343
  - `process(Return).msg(Type)` — message queue consumer
287
344
 
345
+ A `process` job's `backoff: { type: "exponential", delay, maxDelay }` caps the wait between attempts, and the
346
+ internal's `onFailed(job, error)` runs once a job has failed for good — its last attempt, or bullmq's stall limit —
347
+ the dead letter, on either queue.
348
+
288
349
  ---
289
350
 
290
351
  ### Recipe 6: Creating an Insight (Aggregation / Dashboard Stats)
@@ -492,7 +553,7 @@ akan sync automatically generates APIs across all layers. Only write custom logi
492
553
  | Auto-Generated | Description |
493
554
  |---------------|-------------|
494
555
  | `this.<model>Model` | Auto-injected model adaptor |
495
- | `get<Model>(id)`, `load<Model>(id)` | Single document lookup |
556
+ | `get<Model>(id)`, `load<Model>(id)` | Single document lookup: `get` throws `NoDocumentError` for a missing or removed id, answered 404 `base.error.documentNotFound`; `load` returns `null` |
496
557
  | `create<Model>(data)`, `update<Model>(id, data)`, `remove<Model>(id)` | CRUD operations — named after the model, e.g. `createTask`/`updateTask`/`removeTask` (there is no literal `createModel`) |
497
558
  | `list<Query>(args)`, `find<Query>(args)`, `pick<Query>(args)` | Filter-based queries |
498
559
  | `exists<Query>(args)`, `count<Query>(args)`, `insight<Query>(args)` | Filter-based helpers |