@akanjs/cli 3.1.0-rc.4 → 3.1.0-rc.6

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 (89) hide show
  1. package/.build-stamp +1 -1
  2. package/{CodeAgent-ymte1tv8.js → CodeAgent-rkjyxgec.js} +2 -2
  3. package/{CodeTui-4rhdm1qw.js → CodeTui-h7pyj6xa.js} +1 -1
  4. package/TunnelShare-661cxz5e.js +14 -0
  5. package/{agent.command-r5x1mch5.js → agent.command-3qxf4xcj.js} +5 -5
  6. package/akanCode.js +2 -2
  7. package/{application.command-3dkr7rdz.js → application.command-m1kcd7jf.js} +12 -11
  8. package/{applicationBuildRunner-h0j2swx1.js → applicationBuildRunner-dj0xkrep.js} +16 -9
  9. package/buildBatch.proc.js +7 -7
  10. package/{cloud.command-4kyxfw0y.js → cloud.command-3jzym8q7.js} +17 -16
  11. package/cloud.script-r4nskbc6.js +24 -0
  12. package/{code.command-sjvzt8fc.js → code.command-0ad6gw2a.js} +6 -6
  13. package/{context.command-j9frz0dz.js → context.command-0qx447mb.js} +17 -16
  14. package/context.runner-1vsjztjg.js +25 -0
  15. package/{dependencyScanner-06z2dmrr.js → dependencyScanner-3afj2vt1.js} +1 -1
  16. package/{guideline.command-1epw6hey.js → guideline.command-cv5bmrx2.js} +2 -2
  17. package/guidelines/conventions/conventions.instruction.md +450 -1084
  18. package/guidelines/cssRule/cssRule.instruction.md +16 -1
  19. package/guidelines/fieldRule/fieldRule.instruction.md +57 -0
  20. package/guidelines/framework/framework.instruction.md +4 -7
  21. package/guidelines/mcpRule/mcpRule.instruction.md +11 -8
  22. package/guidelines/modelConstant/modelConstant.instruction.md +2 -1
  23. package/guidelines/modelStore/modelStore.instruction.md +9 -0
  24. package/guidelines/pageRule/pageRule.instruction.md +80 -0
  25. package/guidelines/queryRule/queryRule.instruction.md +35 -8
  26. package/guidelines/runtimeRule/runtimeRule.instruction.md +89 -2
  27. package/guidelines/ssrRule/ssrRule.instruction.md +13 -0
  28. package/guidelines/transportRule/transportRule.instruction.md +76 -8
  29. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +4 -4
  30. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +49 -4
  31. package/incrementalBuilder.proc.js +4 -4
  32. package/{index-jw6st2rd.js → index-192ha4xh.js} +356 -307
  33. package/{index-1hkr7mrb.js → index-1p98exm2.js} +2 -2
  34. package/{index-8byzcvxp.js → index-1xj2t7cb.js} +7 -7
  35. package/{index-er3hekv9.js → index-20bxxyvn.js} +80 -80
  36. package/{index-xe3x2kw5.js → index-35hc95h5.js} +1 -1
  37. package/{index-p5nrc5he.js → index-4mhy6qme.js} +4 -4
  38. package/{index-0qc5nd61.js → index-5edzv8fn.js} +5 -5
  39. package/{index-k9rrten6.js → index-5nd1wtkv.js} +92 -94
  40. package/index-62md4dwy.js +260 -0
  41. package/{index-z3v7abrk.js → index-665zp9rs.js} +38 -15
  42. package/{index-1t2xw0p0.js → index-6xn2n442.js} +4 -4
  43. package/{index-jg3fj8h0.js → index-86jt2a0e.js} +1 -1
  44. package/{index-k33me5sp.js → index-8fp3b47h.js} +419 -418
  45. package/{index-shastb7m.js → index-9z426msb.js} +17 -12
  46. package/{index-gdybjw3x.js → index-awbdfcf2.js} +4 -4
  47. package/{index-3hx1z96c.js → index-bqbf654f.js} +1 -1
  48. package/{index-jppthsff.js → index-cpb3nvma.js} +57 -58
  49. package/{index-b60ccq2s.js → index-d47ypebv.js} +17 -15
  50. package/{index-gc05vqd2.js → index-d72hfmdm.js} +13 -13
  51. package/{index-akha197v.js → index-fmzcs8v6.js} +4 -4
  52. package/{index-28ftaz3d.js → index-fyq1z6kt.js} +29 -27
  53. package/{index-vwmv64ad.js → index-g1mbppa9.js} +238 -236
  54. package/{index-hedt951c.js → index-h47cf1gg.js} +7 -7
  55. package/{index-hfqs9jvc.js → index-j4mkhz93.js} +3 -3
  56. package/{index-08a5ga6n.js → index-jw1zm273.js} +28 -30
  57. package/{index-q958tqqg.js → index-m9jj544v.js} +1 -1
  58. package/{index-8935h3n1.js → index-mdjc93e1.js} +1 -1
  59. package/{index-2wyn17fa.js → index-pydbnfez.js} +2 -2
  60. package/{index-bjh4wbtk.js → index-qwa5k1j8.js} +1 -1
  61. package/{index-81a1ywp6.js → index-tamvyvj3.js} +3 -3
  62. package/{index-dez4t5sd.js → index-trpasv9q.js} +260 -259
  63. package/{index-2ahj896a.js → index-tvehn2sp.js} +1 -1
  64. package/{index-2k0t8p3p.js → index-va06nr10.js} +1086 -1075
  65. package/{index-9c3cd767.js → index-vk0pa0mn.js} +20 -21
  66. package/{index-qyaqzh8e.js → index-w44cy4vs.js} +2216 -2216
  67. package/index.js +22 -22
  68. package/{library.command-n58dgakb.js → library.command-erp2f56n.js} +3 -3
  69. package/{localRegistry.command-f2768sww.js → localRegistry.command-xxpqgxjw.js} +15 -14
  70. package/{module.command-xzjg5k7n.js → module.command-gs2r615e.js} +5 -5
  71. package/{package.command-24sh1j1q.js → package.command-7343f74r.js} +4 -4
  72. package/package.json +2 -2
  73. package/{page.command-fqnm88na.js → page.command-4h4a8pnh.js} +3 -3
  74. package/{pipeline.command-vn6taz75.js → pipeline.command-a2nrk26m.js} +3 -3
  75. package/{primitive.command-4j1g1a0d.js → primitive.command-w6t0asqz.js} +6 -6
  76. package/{projectTypecheck-3y7ayty0.js → projectTypecheck-f534enqj.js} +1 -1
  77. package/{quality.command-5hqmcdwt.js → quality.command-77x7vbbx.js} +4 -4
  78. package/{repair.command-rw5tk460.js → repair.command-y6sstev7.js} +5 -5
  79. package/{scalar.command-0jpap45m.js → scalar.command-20b8cy0k.js} +4 -4
  80. package/{subspace.command-y7n9sghk.js → subspace.command-92xkc0s0.js} +11 -10
  81. package/templates/lib/cnst.ts +0 -1
  82. package/templates/workspaceRoot/.gitignore.template +24 -53
  83. package/{tunnel.command-5gjwh1f8.js → tunnel.command-2kw8vt9g.js} +8 -7
  84. package/{workflow.command-3t22f7eb.js → workflow.command-2djwmpqf.js} +10 -10
  85. package/{workspace.command-cq5xg2jq.js → workspace.command-5se6spqb.js} +28 -27
  86. package/TunnelShare-p8ymkww8.js +0 -13
  87. package/cloud.script-jc3hc5d9.js +0 -23
  88. package/context.runner-v0def5my.js +0 -24
  89. package/index-n68jx79w.js +0 -120
@@ -1,15 +1,12 @@
1
1
  ## Repo Overview
2
2
 
3
- - Akan.js is a full-stack TypeScript framework for building all-stack applications at once.
4
- - Write one line and deploy across web, app, server, database, and infrastructure.
5
- - Akan prioritizes actual business code by abstracting technical implementation details as much as possible.
6
- - The goal is minimal code, high performance, and type-safe services that can deploy to web, mobile, server, and DB infrastructure together.
7
- - This is a Bun-first Akan.js monorepo.
8
- - Main top-level areas are `apps/`, `libs/`, `pkgs/`, and `infra/`.
9
- - `apps/<app>` contains app-level pages, domain code, UI, env files, and `akan.config.ts`.
10
- - `libs/*` contains shared domain and utility libraries.
11
- - `pkgs/akanjs/*` contains framework, runtime, and tooling facets published through the single `akanjs` package. Prefer extending existing Akan facets before adding new framework-level patterns.
12
- - `infra/` contains Helm, deployment templates, edge configs, and Jenkins env/secret scripts.
3
+ - Akan.js is a full-stack TypeScript framework: write one line and deploy across web, app, server, database, and
4
+ infrastructure. It abstracts the technical implementation so what remains is business code — minimal, fast, and
5
+ type-safe everywhere at once.
6
+ - This is a Bun-first monorepo: `apps/<app>` (pages, domain code, UI, env files, `akan.config.ts`), `libs/*` (shared
7
+ domain and utility libraries), `pkgs/akanjs/*` (framework, runtime, and tooling facets published through the single
8
+ `akanjs` package — extend an existing facet before adding a framework-level pattern), and `infra/` (Helm,
9
+ deployment templates, edge configs, Jenkins env/secret scripts).
13
10
 
14
11
  ## Change Scope
15
12
 
@@ -28,1014 +25,447 @@ you fetch on demand — `get_guideline` with the name, or `akan guideline show <
28
25
  | name | covers |
29
26
  |---|---|
30
27
  | `ssrRule` | server-share targets, the `akan.ssr.*` warnings, the client-boundary playbook |
31
- | `runtimeRule` | `web` / `csr` surfaces, gateway vs solo processes, logging, the generated image, native apps and a desktop app's server, shipped assets, database modes |
32
- | `queryRule` | slices and hydration, the generated filter methods, full-text search, cascade removal |
33
- | `transportRule` | guards across HTTP and websocket, socket identity and cleanup, binary pubsub, mutation verbs |
34
- | `mcpRule` | MCP configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions |
35
- | `agentRule` | the in-page agent's chat options, zones, slash commands, transcript compaction |
36
- | `cssRule` | the full semantic token set for a theme pass |
37
- | `componentRule` | the `_overrides.tsx` slot list and drop-in component patterns |
38
- | `recipeRule` | authoring and consuming Tailwind-variant recipes |
39
- | `workspaceRecipes` | worked step-by-step recipes and the auto-generated API reference |
28
+ | `pageRule` | route files, the route chain, heads, root layouts in a CSR bundle, lib pages |
29
+ | `runtimeRule` | web surfaces, gateway vs solo, the dev server, logging, the image, native apps and a desktop server, database modes |
30
+ | `queryRule` | slices and hydration, filter methods, soft removal and the facade's writes, full-text search, cascade |
31
+ | `transportRule` | guards and writing one, deadlines, cache, rate limits, server-to-server calls, bytes, binary pubsub, verbs |
32
+ | `mcpRule` | MCP exposure, configuration, wire behaviour, OAuth, page prompts, MCP Apps |
33
+ | `agentRule` | the in-page agent: chat options, tools, forms, zones, slash commands, compaction |
34
+ | `fieldRule` | field types, `hidden` / `secret` / `visual`, immutable and `Date` fields, image and file fields |
35
+ | `cssRule` | the semantic token set, the color vocabulary lint enforces, lib-owned tokens |
36
+ | `componentRule` / `recipeRule` | `_overrides.tsx` slots and drop-in components / authoring and consuming recipes |
37
+ | `workspaceRecipes` | worked recipes (fields, injection and `adapt()`, slices, endpoints, internals, insights), the generated API |
38
+
39
+ Module structure and each module file have their own guide (`moduleOverview`, `modelConstant` … `modelZone`,
40
+ `scalarConstant`, `scalarDictionary`, `scalarModule`).
40
41
 
41
42
  ## Lint-Enforced Rules (These Break The Build)
42
43
 
43
- Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — that file scopes every grit plugin in
44
- `@akanjs/devkit/lint/` to the paths it applies to. Several rules produce output that looks wrong; do not "fix" it
45
- back.
46
-
47
- - **Never hand-order Tailwind classes.** `nursery/useSortedClasses` is an error and also sorts the string
48
- arguments to `cn()`. Sorter output such as `font-bold text-2xl text-foreground` or `border-foreground/5 border-t`
49
- is correct. Write the classes in any order, run the formatter, leave the result.
50
- - **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as no
51
- CSS and fail lint: raw palette classes (`bg-blue-500`), arbitrary color values (`bg-[#3b82f6]`), an arbitrary
52
- class built by interpolation, daisyUI legacy classes (`btn-primary`, `card-body`) and its dropped color slots
53
- (`bg-base-100`, `text-base-content`, `text-primary-content`, `bg-error` — use `background`/`muted`/`border`,
54
- `foreground`, `<color>-foreground`, `destructive`), and color literals in `style={{...}}`. Use semantic tokens
55
- (`bg-primary`, `text-foreground/70`). A legitimate fixed color (OS-chrome mockups, data-viz) takes a
56
- `// biome-ignore lint/plugin: <reason>`. (`no-raw-palette-class`, `no-arbitrary-color`, `no-daisyui-legacy-class`,
57
- `no-inline-color`, `no-interpolated-arbitrary-class`) Color literals in SVG color attributes (`fill`, `stroke`,
58
- `stopColor`, `floodColor`, `lightingColor`, `color`) and in `el.style.<prop> = …` / `el.style.setProperty(…)`
59
- writes are flagged as warnings for now; `currentColor`, `none`, `transparent`, `var(--…)` and `url(#id)` stay legal.
60
- - **Never `throw new Error`, and never construct a raw `Error` at all.** Throw `new Err("<module>.error.<key>")` and
61
- register the key as `[en, ko]` in that module's dictionary `.error({})`. A raw `Error` passed to `reject(...)`,
62
- returned or stored loses the key and the status just the same; that shape is flagged as a warning for now, a
63
- `throw` as an error. Import `Err` from `"../dict"` on the server and from `"@libs/<lib>/client"` or
64
- `"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts tests, `*.constant.ts`, `common/**`, `env/**`, and
65
- a root `native/` folder — `common/` and `env/` have no legal `Err` import path, so keep throwing code out of them,
66
- and a native plugin throws `AkanNativeError`, the error its bridge carries back to the page.
67
- - **Never import a third-party package** from `page/**`, from any barrel, or from any
68
- `*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
69
- (`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
70
- `common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft. A module's
71
- `*.signal.test.ts` / `*.service.test.ts` is held to the same rule (`no-import-external-library-in-test.grit`) but
72
- may import the runtime's `node:*` and `bun:*` modules (a temp dir, a socket, sqlite), which module code leaves to
73
- `srvkit/`.
74
- - **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
75
- `*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
76
- `#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
77
- - **No `console.log` / `console.debug`.** Only `assert`, `error`, `info`, and `warn` are allowed. Server code uses
78
- the injected `this.logger.*` or `new Logger("ClassName")`.
79
- - **Never call `logger.log()` / `Logger.log()`** (`no-deprecated-log-level.grit`). The ladder is `trace verbose
80
- debug info warn error`; `log` was a seventh tier *below* `info` that `AKAN_PUBLIC_LOG_LEVEL=info` silently
81
- dropped. The method is kept and emits at `info`, so a call reads like a distinct level and is not one — write
82
- `.info()`.
83
- - **Never write a `//!` marker in browser-reachable code** — `ui/`, `webkit/`, `common/`, `page/**/*.tsx`,
84
- `*.constant.ts`, `*.store.ts`, and the five module component suffixes (`no-bang-comment-in-client.grit`). Bun
85
- classifies `//!` and `/*!` as legal comments and keeps them through minification, so the note ships to every
86
- visitor. Use `// FIXME:` there; `//!` stays legal in server, `srvkit/`, and CLI files.
87
- - **Write `{name}`, never `{{name}}`, in a dictionary `.error()` / `.translate()` entry**
88
- (`no-double-brace-placeholder.grit`): interpolation replaces single braces only, so a doubled one renders as typed.
89
- - **Every endpoint and named slice names its guards** (`no-unguarded-endpoint.grit`, a warning). A
90
- `query` / `mutation` / `pubsub` / `message`, a named slice's `init()`, or a `slice()` guards map without `root`
91
- that declares no guards (or `guards: []`) answers every HTTP and websocket caller. Write `guards: [Public]` when
92
- anonymous access is the intent.
93
- - **Never touch `document.cookie`, `localStorage` or `sessionStorage` in app or lib code** (`no-document-cookie.grit`,
94
- `no-web-storage.grit`). A native shell serves the page from `app://localhost` on iOS, macOS and Linux, which keeps
95
- no cookies: `document.cookie` reads `""` and a write is dropped, so the value exists in the browser and vanishes in
96
- the app. Web Storage does work in the shells, but it bypasses the store akanjs picks per platform (the shell's
97
- Preferences in an app) and throws during SSR. Use `getCookie` / `setCookie` / `removeCookie`, `storage`, and
98
- `secretStorage` for tokens and credentials, all from `akanjs/client`. `setCookie` writes `path=/; SameSite=Lax;
99
- Secure`, and an options object overrides only the keys it names — pass `sameSite: "none"` only for a cookie a
100
- cross-site POST must carry back.
101
- - **Browser APIs an app WebView lacks live in `webkit/`** (`no-web-only-api-outside-webkit.grit`, a warning):
102
- `navigator.share` / `canShare` (Android's WebView has none), `navigator.serviceWorker` and
103
- `Notification.requestPermission` / `.permission` (no app page has them), `navigator.geolocation` (use
104
- `useGeoLocation` from `akanjs/webkit`) and `navigator.vibrate` (iOS has none; use `haptics`). They are legal on the
105
- web, so the rule does not ban them: it keeps them in a `webkit/` hook that branches on `isNativeApp()` from
106
- `akanjs/client/native`, which is where the app side goes too. A root `native/` folder is out of its scope: a
107
- plugin's web part is where those APIs belong.
108
- - **Never return a value from a store action** (`no-return-in-store-action.grit`). Every method of a `store(...)`
109
- class dispatches through `st.do.<action>()`, typed `void` / `Promise<void>`, so the value is unreachable — write
110
- it into state with `this.set({ ... })`. A bare `return;` guard, a `return` inside a nested callback, a getter,
111
- and a `static` helper are all still fine.
112
- - **Never declare a `static` on an `XObject` or `LightX` class** (`no-static-in-object-light-model.grit`). The full
113
- model `via(XObject, LightX, …)` mixes in their prototypes only, so a static there never reaches `X` — put it on
114
- `XInput` (or a scalar), `X`, or `XInsight`. The Object class is recognised by its `*Object` name and the Light
115
- class by its `Light*` name or its `[...] as const` field tuple.
116
- - **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
117
- - **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
118
- roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server
119
- has to hand across the boundary; take `bannerId: string` and read the model from the store instead. Only prop
120
- positions are read, and only shapes that are actually an instance. Exempt: an indexed enum access
121
- (`cnst.<Enum>["value"]`), a `ClientInit` / `ClientView` / `ClientEdit` or `ModelsProps<…>` type argument, a
122
- function-typed prop, and any `cnst` type that never leaves the file. Other indexed accesses
123
- (`ModelProps<"setting", cnst.LightSetting>`, `cnst.Banner["image"]`) stay flagged.
124
- - **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
125
- `onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}`, but
126
- the arrow is a fresh anonymous closure, so the control emits no `data-akan-action` and publishes no agent tool
127
- for the field — a silent failure in two lines that read the same. A wrapper that transforms the value, adds a
128
- statement, or writes a nested path with `writeOnX` stays legal; publish that one with an explicit `st.tool`.
129
- - **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
130
- `../map/map.constant` are the sanctioned exception.
131
- - **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
132
- `.tsx`) may not import a `*.document.ts` / `*.dictionary.ts` / `*.service.ts` / `*.signal.ts`, `srvkit/`, a
133
- package `server` entrypoint, or the `db` / `srv` / `sig` / `dict` / `option` / `useServer` barrels
134
- (`no-import-server-in-client.grit`). Server files (those four suffixes plus `srvkit/`) may not import a
135
- `*.store.ts`, a module component, `ui/`, `webkit/`, a package `client` entrypoint, or the `st` / `store` /
136
- `useClient` barrels (`no-import-client-in-server.grit`). Shared files — `common/` and `*.constant.ts` — are held
137
- to **both**, so they reach neither side. `import type` is erased before bundling and stays legal in every
138
- direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs/**`.
139
- - **Server-component discipline** is enforced on `page/**`, `*.Unit.tsx`, and `*.View.tsx`
140
- (`no-import-client-functions.grit`, `no-use-client-in-server.grit`, `non-scalar-props-restricted.grit`).
141
- - **Never write an async component outside `page/`** (`no-async-component-in-ui.grit`, scoped to
142
- `{apps,libs}/**/ui/**/*.tsx`). React has no async client component, so a `ui/` component that awaits breaks as
143
- soon as a client parent renders it, and the load drops below the route, which could have started it before the
144
- first byte. Await in the page — or hand an unawaited `fetch.*` field to a `Zone` as an `init` / `view` prop, or
145
- to a `<Load.Stream of={…}>` that awaits it behind a boundary of its own — and take the resolved data as a prop.
146
- Only a PascalCase binding whose own initializer is `async` is matched, so an async handler inside a synchronous
147
- component and `lazy(async () => import(…))` are untouched.
148
- - **Never call `fetch.init*` from a client file** (`no-init-fetch-in-client.grit`). `fetch.init<Model><Suffix>` and
149
- `fetch.get<Model>Init<Suffix>` compose the slice's list and insight queries into the hydration snapshot that
150
- `Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration
151
- it is two extra round-trips for a shell the browser already painted. Load it in the page and pass the result
152
- down as an `init` prop — or hand the unawaited `x<Model>Init<Suffix>` promise across — and reload from the client
153
- through the generated `st.do.init<Model><Suffix>()`. Gated on the real `"use client"` directive plus
154
- `*.store.ts`, and matched by shape (`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so a hand-written
155
- `initPayment` is out.
156
- - `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose: `key={idx}` for embedded scalars and
157
- short dependency arrays are intentional, not oversights.
158
- - **A grit plugin diagnostic is suppressed as `lint/plugin`, not `plugin`** — `// biome-ignore lint/plugin: <reason>`
159
- for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
160
- Biome's own category name suggests does nothing. The module-convention plugins apply to `apps/**` and `libs/**`
161
- only, so a plain package under `pkgs/` never needs the escape hatch.
162
- - **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome does not report the parse
163
- error; it falls back to discovery and aborts on whatever nested config the walk finds. Rename the file to
164
- `biome.jsonc` to document a disabled rule.
165
- - **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` for no limit). Biome's own default is
166
- 20 with no count, which reads as progress when the mix of findings merely changed.
44
+ `biome.json` extends `@akanjs/devkit/biome.base.json`, which scopes every grit plugin in `@akanjs/devkit/lint/`;
45
+ each plugin's header and diagnostic carry its full reasoning. Several rules produce output that looks wrong — do
46
+ not "fix" it back.
47
+
48
+ - **Never hand-order Tailwind classes** — `nursery/useSortedClasses` sorts them, `cn()` arguments included, and its
49
+ output (`font-bold text-2xl text-foreground`) is correct.
50
+ - **Stay inside the color vocabulary**: no raw palette (`bg-blue-500`), arbitrary or interpolated color classes
51
+ (`bg-[#3b82f6]`), daisyUI classes or slots (`btn-primary`, `bg-base-100`), or color literals in `style={{}}` —
52
+ they render no CSS. Use semantic tokens (`bg-primary`, `text-foreground/70`); a legitimate fixed color takes
53
+ `// biome-ignore lint/plugin: <reason>`. Slot replacements, SVG / `el.style` warnings, legal values: `cssRule`.
54
+ - **Never construct a raw `Error`.** Throw `new Err("<module>.error.<key>")` with the key registered as `[en, ko]` in
55
+ the module dictionary's `.error({})` — imported from `"../dict"` on the server, from `"@libs/<lib>/client"` /
56
+ `"@apps/<app>/client"` in UI. A `throw` is an error; a raw `Error` rejected, returned or stored loses the key and
57
+ status too (a warning for now). Exempt: tests, `*.constant.ts`, a root `native/` folder (plugins throw
58
+ `AkanNativeError`), and `common/**` / `env/**`, which have no legal `Err` import — keep throwing code out of them.
59
+ - **Never import a third-party package** from `page/**`, any barrel, a module logic file
60
+ (`*.{constant,dictionary,document,service,signal,store}.ts`) or module component
61
+ (`*.{Template,Unit,Util,View,Zone}.tsx`) — re-export it through a lib first (one-line shims in a lib's `common/`,
62
+ `webkit/`, `ui/` are load-bearing). `*.signal.test.ts` / `*.service.test.ts` too, though they may import `node:*`
63
+ and `bun:*`.
64
+ - **`#private` is banned in exactly `*.constant.ts`, `*.document.ts`, `*.service.ts`, `*.store.ts`** (by path, so it
65
+ stays the house style everywhere else, `srvkit/` and `adapt()` classes included).
66
+ - **No `console.log` / `console.debug`** (only `assert`, `error`, `info`, `warn`; server code uses `this.logger` or
67
+ `new Logger("ClassName")`), and **never `logger.log()` / `Logger.log()`** — it emits at `info` while reading like
68
+ its own level; write `.info()`. The ladder is `trace verbose debug info warn error`.
69
+ - **No `//!` or `/*!` in browser-reachable code** (`ui/`, `webkit/`, `common/`, `page/**/*.tsx`, `*.constant.ts`,
70
+ `*.store.ts`, module components) — Bun keeps both through minification. Use `// FIXME:`.
71
+ - **`{name}`, never `{{name}}`**, in a dictionary `.error()` / `.translate()` entry — only single braces interpolate.
72
+ - **Every endpoint and named slice names its guards** (warning) — a `query` / `mutation` / `pubsub` / `message`, a
73
+ named slice's `init()`, or a `slice()` map without `root` that names none (or `guards: []`) answers every caller.
74
+ Write `guards: [Public]` when anonymous access is the intent.
75
+ - **Never touch `document.cookie`, `localStorage` or `sessionStorage`** — a native shell's page keeps no cookies on
76
+ iOS, macOS and Linux, and Web Storage throws during SSR. Use `getCookie` / `setCookie` / `removeCookie`,
77
+ `storage`, `secretStorage` from `akanjs/client`. Browser APIs a WebView lacks (`navigator.share`, `serviceWorker`,
78
+ `Notification`, `geolocation`, `vibrate`) live in a `webkit/` hook behind `isNativeApp()` (warning). Both in full:
79
+ `runtimeRule`.
80
+ - **Never return a value from a store action** — actions dispatch through `st.do.*` as `void`; write state with
81
+ `this.set({ ... })`. A bare `return;`, a nested callback's `return`, a getter, and a `static` helper are fine.
82
+ - **No `static` on an `XObject` or `LightX` class** — `via()` mixes in their prototypes only; use `XInput` (or a
83
+ scalar), `X`, or `XInsight`.
84
+ - **Never redeclare a generated CRUD endpoint name** in `*.signal.ts`.
85
+ - **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** — the server would hand a class instance to a
86
+ client component; take `bannerId: string` and read the model from the store. Exempt: `cnst.<Enum>["value"]`, a
87
+ `ClientInit` / `ClientView` / `ClientEdit` / `ModelsProps<…>` type argument, a function-typed prop, a `cnst` type
88
+ that never leaves the file; `ModelProps<…>` and other indexed accesses stay flagged.
89
+ - **Never wrap a form setter in a pass-through arrow** — `(v) => st.do.setTypeOnTicket(v)` emits no
90
+ `data-akan-action` and publishes no agent tool. A wrapper that transforms, adds a statement, or calls `writeOnX`
91
+ is legal; publish it with an explicit `st.tool`.
92
+ - **No deep imports past a barrel**, except cross-module constants (`../map/map.constant`).
93
+ - **Never import across the client/server boundary** (`apps/**`, `libs/**`). Client files (`ui/`, `webkit/`,
94
+ `page/`, `*.store.ts`, every `.tsx`) never import a `*.document|dictionary|service|signal.ts`, `srvkit/`, a
95
+ package `server` entrypoint, or the `db` / `srv` / `sig` / `dict` / `option` / `useServer` barrels. Server files
96
+ (those four suffixes, `srvkit/`) never import a `*.store.ts`, a module component, `ui/`, `webkit/`, a package
97
+ `client` entrypoint, or the `st` / `store` / `useClient` barrels. `common/` and `*.constant.ts` obey both.
98
+ `import type` is legal everywhere; a mixed value-and-type import is not.
99
+ - **Server-component discipline** on `page/**`, `*.Unit.tsx`, `*.View.tsx` (`no-import-client-functions`,
100
+ `no-use-client-in-server`, `non-scalar-props-restricted`).
101
+ - **No async component under `ui/`** — React has no async client component. Await in the page, or hand an
102
+ unawaited `fetch.*` field to a `Zone` / `<Load.Stream of={…}>`, and take the data as a prop. An async handler in a
103
+ synchronous component and `lazy(async () => import(…))` are untouched.
104
+ - **Never call `fetch.init<Model><Suffix>` / `fetch.get<Model>Init<Suffix>` from a client file** (`"use client"` or
105
+ `*.store.ts`) — it is the hydration snapshot. Load it in the page and pass `init` down; reload with
106
+ `st.do.init<Model><Suffix>()`.
107
+ - `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose — `key={idx}` for embedded scalars and
108
+ short dependency arrays are intentional.
109
+ - **Suppress a grit diagnostic as `lint/plugin`** (`// biome-ignore lint/plugin: <reason>`,
110
+ `// biome-ignore-all lint/plugin: <reason>`); bare `plugin:` does nothing. `pkgs/` is out of scope.
111
+ - **`biome.json` is strict JSON** — a comment breaks config resolution without an error (Biome falls back to
112
+ discovery and aborts on a nested config); rename it `biome.jsonc` to document a disabled rule.
113
+ - **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` = all); Biome's own uncounted 20 reads
114
+ as progress when the mix merely changed.
167
115
 
168
116
  ## Coding Style (`**/*.{ts,tsx}`)
169
117
 
170
- - For large units of work, prefer declaring a class and running the flow through an instance instead of scattering many standalone functions.
171
- - Prefer class methods or `static` methods over unrelated top-level helper functions when the logic belongs to a class-level workflow.
172
- - Prefer ECMAScript `#private` fields and methods over TypeScript `private`, except in the four suffixes where `#private` is lint-banned (`*.constant.ts`, `*.document.ts`, `*.service.ts`, `*.store.ts`) — those use TypeScript `private`. `#private` is the house style under `srvkit/`, including `adapt()` classes.
173
- - In files that declare a class, avoid top-level functions or variables when they can reasonably live inside the class.
174
- - Prefer `const` function expressions over `function` declarations unless hoisting, overloads, generators, or framework conventions make `function` the better fit.
175
- - Prefer declaring only one class per file; split the file when two or more class declarations are needed.
176
- - For class-centered modules, prefer noun-style filenames that match the primary class name, such as `RouteClientBuilder.ts`, instead of verb-style wrapper filenames like `buildRouteClient.ts`.
177
- - Avoid keeping exported functions that only instantiate a class and immediately call one method. Prefer migrating callers to instantiate the class directly.
178
- - Except for React component files or convention files, TypeScript filenames should use camelCase.
179
- - In React components, keep one-off `className` strings inline. Only extract class name constants when the class is reused, conditionally composed, or too large to read comfortably in JSX.
118
+ - Run a large unit of work through a class instance; prefer class or `static` methods over unrelated top-level
119
+ helpers, and keep a class file's functions and variables inside the class when they reasonably can be.
120
+ - `#private` over TypeScript `private`, except in the four lint-banned suffixes, which use `private`.
121
+ - `const` function expressions over `function` declarations, unless hoisting, overloads, generators, or a framework
122
+ convention make `function` the better fit.
123
+ - One class per file, named after it (`RouteClientBuilder.ts`, not `buildRouteClient.ts`). Migrate callers off an
124
+ exported function that only instantiates a class and calls one method.
125
+ - TypeScript filenames are camelCase, except React component and convention files.
126
+ - One-off `className` strings stay inline; extract a constant only when reused, conditionally composed, or too large
127
+ to read in JSX.
180
128
 
181
129
  ### File Size And Duplication
182
130
 
183
- - Keep files small. The house median is well under 50 lines; split a component before it reaches ~150 lines rather than adding section comments.
184
- - Ship every scaffold file even when it is empty — `export class XInternal extends internal(srv.x, () => ({})) {}`, empty dictionary stages, the `// state` / `// action` markers in an empty store. They mark where things go.
185
- - Never add a sibling helper file inside `lib/<model>/`. Helpers go to `common/`, `webkit/`, `srvkit/`, or `ui/`.
186
- - Prefer duplication to premature abstraction at the leaf. Near-identical sibling modules and per-vendor pages are deliberately copied, not parameterised. Copy the file and change the literals; share enums by import only.
131
+ - Keep files small (house median well under 50 lines); split a component before ~150 lines rather than adding
132
+ section comments.
133
+ - Ship every scaffold file even when empty — `export class XInternal extends internal(srv.x, () => ({})) {}`, empty
134
+ dictionary stages, the `// state` / `// action` markers. They mark where things go.
135
+ - Never add a sibling helper file inside `lib/<model>/`; helpers go to `common/`, `webkit/`, `srvkit/`, or `ui/`.
136
+ - Prefer duplication to premature abstraction at the leaf: near-identical sibling modules and per-vendor pages are
137
+ copied with changed literals, not parameterised; share enums by import only.
187
138
 
188
139
  ### TypeScript Shape
189
140
 
190
- - Use `interface` for object shapes and `type` only for unions and aliases.
191
- - Declare `interface <ComponentName>Props` immediately above the component with no blank line between, and put `className?: string` first. Name it for the component (`CardProps`, `WorldProps`), never for the model. Do not export it unless a sibling imports it.
192
- - Never use a non-null assertion. Narrow with `?.`, an early return, or a type predicate such as `.filter((id): id is string => !!id)`.
193
- - Escape with `as unknown as T`. Never `as any`.
194
- - Never annotate a component's return type. Annotate a helper only when the return is a union, a tuple, or a type predicate.
195
- - Use `as const` on every `enumOf(...)` array, every `via(Model, [...] as const, …)` Light tuple, and every module-scope lookup map. Never use the TypeScript `enum` keyword. (`enumOf` infers its literal values even without `as const`, so a typo there fails to compile; write it anyway for consistency.)
141
+ - `interface` for object shapes, `type` only for unions and aliases.
142
+ - `interface <ComponentName>Props` sits directly above the component (no blank line), `className?: string` first,
143
+ named for the component (`CardProps`), never the model; exported only when a sibling imports it.
144
+ - No non-null assertions — narrow with `?.`, an early return, or a type predicate
145
+ (`.filter((id): id is string => !!id)`). Escape with `as unknown as T`, never `as any`.
146
+ - Never annotate a component's return type; annotate a helper only for a union, tuple, or type predicate.
147
+ - `as const` on every `enumOf(...)` array (even though `enumOf` infers its literals, so a typo already fails to
148
+ compile), `via(Model, [...] as const, …)` Light tuple, and module-scope lookup map; never the `enum` keyword.
196
149
  - Async functions carry no `Async` suffix.
197
150
 
198
151
  ### Test Code
199
152
 
200
- - Write TypeScript tests with Bun's test runner and import `describe`, `expect`, and `test` from `bun:test`.
201
- - In a signal test every `fetch.clone()` reports its own client address (`198.18.x.y`), so an `ip` rate limit counts
202
- each agent apart. Clone once per simulated user, signup steps included, rather than driving many users through
203
- the root `fetch`; `clone({ clientIp })` names one explicitly.
204
- - Keep tests colocated with the source they cover using `*.test.ts` or `*.spec.ts`, following the existing nearby pattern.
205
- - Prefer focused behavior tests for public contracts and edge cases over implementation-detail assertions.
206
- - Run package suites with `bun run akan test <pkg>` from the repo root, or `cd <pkg> && bun test --isolate`. Plain `bun test` without `--isolate` shares one global object across test files and fails dozens of tests from cross-file state pollution (`bunfig.toml` `[test] isolate` is not honored as of Bun 1.3), and running `bun test` from the repo root breaks subprocess stdio pipes.
207
- - Split signal tests in two. `<model>.signal.spec.ts` holds reusable fixtures built on `sampleOf(cnst.XInput)` with explicit `Promise<cnst.X>` return types and **no assertions**. `<model>.signal.test.ts` holds the assertions: `describe("<Model> Signal")`, `let` fixtures at describe scope, one `beforeAll`, story-ordered `it`s, and negatives via `await expect(p).rejects.toThrow()`.
208
- - **One suite per module file, named after it.** A module test is `<model>.<role>.test.ts` beside the file it covers
209
- (`chatMessage.signal.test.ts`, `chatMessage.service.test.ts`, `ChatMessage.Zone.test.tsx`). Never split one role
210
- across topic files such as `chatMessage.trim.signal.test.ts` / `chatMessage.security.signal.test.ts` — add a
211
- `describe` block to the existing suite. A suite that drives several modules (guards across models, an app's MCP
212
- exposure list) belongs to none of them and goes in `srvkit/`, like `libs/shared/srvkit/guards.signal.test.ts`.
213
- Never put a test at the `lib/` root. `akan sync` refuses all three shapes. Nothing imports a test, so a build never
214
- typechecks one; `akan typecheck <app-or-lib>` does, `bun:test` types included.
215
- - `lib/user/user.signal.spec.ts` is the one place agent types are re-exported and re-typed; import `UserAgent` / `AdminAgent` from there rather than from the owning lib directly.
216
- - A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
153
+ - Bun's runner (`describe`, `expect`, `test` from `bun:test`); colocated `*.test.ts` / `*.spec.ts`; focused behavior
154
+ tests of public contracts and edge cases. A placeholder `it` with a descriptive title is the floor; a
155
+ security-relevant behaviour gets a real suite.
156
+ - Run `bun run akan test <pkg>` from the root, or `cd <pkg> && bun test --isolate`. Plain `bun test` shares one
157
+ global object across files and fails dozens of tests from that pollution (`bunfig.toml` `[test] isolate` is not
158
+ honored as of Bun 1.3); from the repo root it breaks subprocess stdio pipes.
159
+ - Signal tests split in two: `<model>.signal.spec.ts` holds fixtures on `sampleOf(cnst.XInput)` with explicit
160
+ `Promise<cnst.X>` returns and **no assertions**; `<model>.signal.test.ts` holds `describe("<Model> Signal")`,
161
+ describe-scope `let` fixtures, one `beforeAll`, story-ordered `it`s, negatives via
162
+ `await expect(p).rejects.toThrow()`. Each `fetch.clone()` reports its own address (`198.18.x.y`), so an `ip` rate
163
+ limit counts it apart: clone once per simulated user, signup included (`clone({ clientIp })` names one).
164
+ - **One suite per module file**: `<model>.<role>.test.ts` beside the file it covers — never topic splits
165
+ (`chatMessage.trim.signal.test.ts`); add a `describe`. A suite spanning modules goes in `srvkit/`
166
+ (`libs/shared/srvkit/guards.signal.test.ts`), never the `lib/` root; `akan sync` refuses all three shapes. Only
167
+ `akan typecheck <app-or-lib>` typechecks tests (`bun:test` types included).
168
+ - Import `UserAgent` / `AdminAgent` from `lib/user/user.signal.spec.ts`, the one place agent types are re-typed.
217
169
 
218
170
  ## Comments
219
171
 
220
172
  Do not narrate code. Do document the thing the code cannot say. Both halves are the rule.
221
173
 
222
- - Never add a comment that restates the identifier, the signature, or the control flow.
223
- - Prefer clear names and structure so ordinary logic needs no explanation.
224
- - Do not add JSDoc, section banners, or "why/how" comments for ordinary logic.
225
- - Comment density tracks the layer, not the author: pages carry none, product `lib/` and `ui/` code stays under 1 %, and `srvkit/` adapters and `guards.ts` carry as much as the external constraints require.
226
- - A comment is warranted for: a vendor spec or protocol quirk; an infrastructure constraint; a third-party library gotcha; security reasoning; why a rule that looks arbitrary is correct; a math derivation; a domain field's business meaning; a state transition above a document chain method; why an obvious alternative was rejected.
227
- - In-code markers:
228
- 1. `TODO` — unfinished work that must be tracked in-code
229
- 2. `FIXME` — known broken or incorrect behavior that must be fixed
230
- 3. `XXX` — dangerous / surprising hazard that a reader must not miss
231
- 4. `//!` — disabled or must-fix code. **Server, `srvkit/`, and CLI files only.** Bun's bundler treats `//!`
232
- (and `/*!`) as a legal comment and keeps it through minification, so in browser-reachable code the note
233
- ships verbatim to every visitor. Use `// FIXME:` there instead; `no-bang-comment-in-client.grit` enforces it.
234
- 5. `//?` — an explanatory aside
235
- 6. `//*` — a design note
236
- 7. Deletion caution — warn why removing a line or block would break something non-obvious
237
- - Keep allowed comments one short line when possible.
238
- - Every suppression carries a reason: `// biome-ignore lint/<rule>: <why>`. Never a bare disable block.
239
- - Match nearby file style: if the surrounding code has few comments, keep it that way.
174
+ - Never restate the identifier, the signature, or the control flow; prefer names and structure. No JSDoc, section
175
+ banners, or "why/how" comments on ordinary logic.
176
+ - Density tracks the layer: pages carry none, product `lib/` and `ui/` stay under 1 %, `srvkit/` adapters and
177
+ `guards.ts` (what would leak without each guard) carry what the external constraints require.
178
+ - Warranted for: a vendor or protocol quirk; an infrastructure constraint; a library gotcha; security reasoning; why
179
+ an arbitrary-looking rule is correct; a math derivation; a domain field's business meaning; a state transition above
180
+ a document chain method; why an obvious alternative was rejected.
181
+ - Markers: `TODO` (tracked unfinished work), `FIXME` (known broken), `XXX` (a hazard a reader must not miss), `//!`
182
+ (disabled or must-fix — server, `srvkit/`, CLI only), `//?` (an aside), `//*` (a design note), and a deletion
183
+ caution saying why removing a line would break something non-obvious.
184
+ - One short line each. Every suppression carries a reason (`// biome-ignore lint/<rule>: <why>`), never a bare
185
+ disable block. Match the nearby density.
240
186
 
241
187
  ## TypeScript And Imports (`**/*.{ts,tsx}`)
242
188
 
243
- - Use Bun and ESM assumptions from the root `tsconfig.json`.
244
- - Prefer path aliases over deep relative imports when crossing package boundaries.
245
- - Use `akanjs/*` for framework facets, `@apps/*` for apps, `@libs/*` for shared libs, and `@contract/*` for contract code.
246
- - Respect existing client/server entrypoints — `@libs/<lib>/client` / `@libs/<lib>/server` and `@apps/<app>/client` / `@apps/<app>/server`.
247
- - Let Biome organize imports instead of manually reshuffling unrelated imports.
248
- - Namespace the generated barrels in backend `.ts` files: `import * as cnst from "../cnst"`, `* as db`, `* as srv`. Use `import type * as srv` in services so the runtime graph stays lazy, and a value import in signals.
249
- - In `.tsx` files use one flat named import from the package client path (`import { cnst, fetch, st, Ticket, usePage } from "@apps/<app>/client"`) — never a relative `../` import.
189
+ - Bun and ESM per the root `tsconfig.json`. Path aliases across packages: `akanjs/*`, `@apps/*`, `@libs/*`,
190
+ `@contract/*`, respecting the `/client` and `/server` entrypoints. Let Biome organize imports.
191
+ - Backend `.ts` namespaces the generated barrels — `import * as cnst from "../cnst"`, `* as db`, `* as srv` —
192
+ with `import type * as srv` in services (keeps the runtime graph lazy) and a value import in signals.
193
+ - `.tsx` uses one flat named import from the package client path
194
+ (`import { cnst, fetch, st, Ticket, usePage } from "@apps/<app>/client"`), never a relative `../` import.
250
195
 
251
196
  ## Formatting And Linting
252
197
 
253
- - Use Biome as the formatter and linter.
254
- - Format with `bun run akan lint <appName>` from the repo root.
255
- - Keep formatting consistent with `biome.json`: 2-space indentation, 120 line width, and double quotes for JS/TS.
256
- - Avoid adding new `console` usage except accepted methods such as `console.error`, `console.info`, and `console.warn`.
257
- - Do not make broad formatting-only changes in unrelated files.
198
+ - Biome formats and lints: `bun run akan lint <appName>` from the root; 2-space indent, 120 columns, double quotes.
199
+ No broad formatting-only changes in unrelated files.
258
200
 
259
201
  ## Client / Server Boundaries (`apps/**`, `libs/**`, `pkgs/akanjs/**`)
260
202
 
261
- - Use `"use client";` at the top of client component files.
262
- - A `"use client"` file re-exports by name (`export { A, B } from "./x"`), never with `export *`: the server knows a
263
- client module only by the export names it spells out, so `akan start` and `akan build` stop on a star re-export.
264
- - Be careful when importing client-only code from page or layout modules.
265
- - Keep page props serializable unless the existing route pattern clearly allows otherwise.
266
- - In domain UI the boundary is mechanical, not a judgment call: `Template`, `Zone`, and `Util` are always client components with `"use client"` on line 1; `Unit` and `View` are always server components and never carry the directive.
267
- - Preserve established domain file roles such as `.document.ts`, `.service.ts`, `.store.ts`, `.constant.ts`, and `.client.ts`.
268
- - When unsure, inspect nearby files in the same app or package before introducing a new boundary pattern.
203
+ - `"use client";` tops client component files. It is mechanical by role: `Template`, `Zone`, `Util` always carry it
204
+ on line 1; `Unit`, `View` never do. `usePage()` is legal in server files.
205
+ - A `"use client"` file re-exports by name (`export { A, B } from "./x"`), never `export *` — the server knows a
206
+ client module only by the names it spells out, so `akan start` / `akan build` stop on a star re-export.
207
+ - Keep page props serializable unless the route pattern clearly allows otherwise; be careful importing client-only
208
+ code into pages and layouts. Preserve file roles (`.document.ts`, `.service.ts`, `.store.ts`, `.constant.ts`,
209
+ `.client.ts`), and inspect nearby files before introducing a new boundary pattern.
269
210
 
270
211
  ## SSR First — Server Rendering Is The Default
271
212
 
272
- Akan is SSR-first. Every JSX element that renders on the server ships as HTML and costs nothing to hydrate;
273
- every element behind `"use client"` ships twice — as markup and as bundled JS that must re-run in the browser.
274
- The boundary is not about which file *may* be client, it is about **how little** ends up on the client side.
275
-
276
- **The default is server. `"use client"` is a cost you justify per component, not a habit.** A component earns the
277
- directive only by using a client-only capability: a React hook, a JSX event handler, the store (`st.use.*` /
278
- `st.do.*`), a browser global, or a client-only third-party package. Rendering markup, reading a param, calling
279
- `l()`, and mapping over data are all server work.
280
-
281
- Measure before and after with `akan quality ssr` (`--format json` for tooling). It prints the server render share
282
- per app and lib — server-rendered JSX elements over total — and the SSR warnings below. Treat **50% server share
283
- as the floor** for an app or lib and a **falling share as a regression**: if a change moves markup to the client,
284
- say why in the PR or move it back.
285
-
286
- ### What `akan quality ssr` Flags
287
-
288
- | Rule | Means |
289
- |---|---|
290
- | `akan.ssr.unnecessary-use-client` | The directive is there but nothing in the file needs it. Delete it. |
291
- | `akan.ssr.client-static-component` | A component in a client file renders real markup with zero client-only capability — pure server work sitting in the bundle. |
292
- | `akan.ssr.client-static-markup` | A large subtree wraps one or two interactive touches. Split it: interaction stays client, markup goes server. |
293
- | `akan.ssr.client-mount-load` | A `useEffect(…, [])` loads server data. The route can fetch it before the first byte. |
294
- | `akan.ssr.module-missing-server-view` | A module renders only from `Template`/`Zone`/`Util` and has no `Unit`/`View` at all. |
295
- | `akan.ssr.template-client-state` | A `Template` holds form state in `useState` instead of the store. |
296
-
297
- A third-party client package or an `index_.tsx` `lazy()` boundary is a legitimate reason for the directive and is
298
- not flagged. Interaction-driven `fetch.*` (a lookup inside `onClick`) is not flagged either — only mount-time loads
299
- are, because those are the ones the server could have done.
300
-
301
- ### Server-Side Implementation Playbook
302
-
303
- 1. **Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
304
- `children` untouched, so the markup inside never reaches the bundle.
305
- 2. **Split compound components so panels stay on the server.** `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in
306
- `akanjs/ui` is the shape: only the provider and menu hold state. Never one `"use client"` file with a mode
307
- `useState` and every panel body inlined.
308
- 3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes the
309
- result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it. Never a
310
- `useEffect(…, [])` that fetches on mount.
311
- 4. **Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and
312
- delegate to a server `View`.
313
- 5. **Hand each promise across, not the awaited value.** `fetch.init<Model><Suffix>` / `view<Model>` / `edit<Model>`
314
- are awaitable *and* destructurable: `const { xInitInY, xListInY } = fetch.initXInY(id)` hands out one promise
315
- per field with both queries already in flight. Give `xInitInY` to a `Zone` and `xListInY` to a
316
- `<Load.Stream of={…}>`; each renders behind its own boundary as its own data lands, so the page never waits
317
- for the slowest. `await` still returns the old shape and keeps the whole section in the shell — which is what
318
- SEO snapshots, prerendering and pre-hydration E2E read, so await what the page needs immediately and stream
319
- the rest. `xListInY` / `xInsightInY` hold hydrated model instances that React Flight refuses as client props:
320
- consume them in a server component, never as a `Zone` prop.
321
- 6. **Use named `ReactNode` slots, not just `children`** — a client shell that takes one slot per region lets a
322
- server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
323
- is the shape: `title` / `left` / `right` beside `back` and `children`, each filled by the server.
324
- 7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
325
- lookups belong in a module-scope `as const` map.
326
- 8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any of the page's
327
- markup renders. After its fetch the redirect is an in-page one (HTTP 200); `.config({ ssr: "block" })` makes it a 3xx.
328
- 9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
329
- `<details>`/`<summary>`, keeps both branches server-rendered.
330
- 10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
331
- `usePage()` and `l()` work in server components, so translation never forces a boundary.
332
-
333
- Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`.
213
+ Server-rendered JSX ships as HTML and costs nothing to hydrate; `"use client"` ships it twice (markup plus JS that
214
+ re-runs). **The default is server; the directive is a cost justified per component** by a React hook, a JSX event
215
+ handler, the store, a browser global, or a client-only package — never by markup, params, `l()` or mapping data.
216
+ `akan quality ssr` (`--format json`) prints each app's and lib's server share: **50% is the floor, and a falling
217
+ share is a regression** to justify in the PR or revert. A client package or an `index_.tsx` `lazy()` boundary
218
+ justifies the directive; a `fetch.*` inside `onClick` is not flagged, only mount-time loads.
219
+
220
+ Playbook (warning table, code, checklist: `ssrRule`): ① wrap the interaction and render `children` untouched;
221
+ ② split compound components (`Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel`) — never one client file with a mode
222
+ `useState` and every panel inlined; ③ fetch in the route and pass `init` / `view` to a `Zone`, never a
223
+ `useEffect(…, [])` load; ④ a store-reading `Zone` holds no markup and delegates to a server `View`; ⑤ hand each
224
+ promise across — `const { xInitInY, xListInY } = fetch.initXInY(id)`, `xInitInY` to a `Zone`, `xListInY` to
225
+ `<Load.Stream>` (model instances, server-only) — and `await` only what the shell must contain; ⑥ named `ReactNode`
226
+ slots (`Layout.Navbar`); ⑦ derived display logic on `Light<Model>`; ⑧ `getSelf({ unauthorize: "/signin" })` in
227
+ `_layout.tsx` (an in-page redirect after I/O; `.config({ ssr: "block" })` for a 3xx); ⑨ CSS (`data-*` +
228
+ `group-data-[…]`, `<details>`) over client state for visibility; ⑩ heavy islands behind `index_.tsx` + `lazy()`.
334
229
 
335
230
  ## Runtime & Build Configuration
336
231
 
337
- What an app serves, how many processes it runs, where its logs go, and what ships in its image. All of it is
338
- declared in `akan.config.ts` and narrowed — never widened — by an env at boot. Domain code reaches none of it.
339
- Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runtimeRule`.
340
-
341
- - **Web surfaces are `web: true | false | { csr: boolean }`.** `{ csr: false }` drops the CSR bundle the native
342
- apps ship, `false` is an API-only build that serves nothing under `page/`. There is no CSR-without-SSR option, by
343
- type — the CSR bundle inlines the stylesheet the SSR base artifact compiles. `AKAN_SSR` / `AKAN_CSR` narrow
344
- further at runtime and can never switch a surface the build left out back on. `akan start` ignores `web` and
345
- keeps the whole dev surface.
346
- - **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default everywhere —
347
- has nothing to balance, so there is no gateway and no unix-socket proxy hop. Two or more replicas, a batch-only
348
- replica, `AKAN_SOLO=false`, passing `replica` to `new AkanApp(...)`, or `akan start` all bring the gateway back.
349
- The RSC worker is always its own process (`--conditions react-server`). A solo process answers
350
- `/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` in the gateway's own shape, and owns the
351
- rotating log file; nothing supervises it but the orchestrator's probes.
352
- - **Route prefixes move together or not at all.** `new AkanApp({ prefix, websocketPrefix })` in `main.ts` moves
353
- the server's routes, the gateway's websocket upgrade and — through the SSR bootstrap script — the
354
- `fetchClient` in every tab the server renders. A prebuilt CSR shell or a native app bundle is never rendered by
355
- a server, so `api: { prefix, websocketPrefix }` in `akan.config.ts` is what those follow; declare it there too
356
- when the app ships either. Read the value with `getApiPrefix()` / `getWsPrefix()` from `akanjs/base` — never
357
- write `/api` or `/ws` as a literal, and never try to hand it down as a React prop: `FetchClient` fixes its
358
- origin before the first component renders. A blank prefix, a bare `/`, and one that shadows a basePath are all
359
- refused.
360
- - **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel** — the barrel re-exports
361
- `AkanServer`, whose graph the gateway never runs. Keep entrypoint imports at the leaf.
362
- - **Every `Logger` call builds a `LogRecord`** carrying `traceId`, `endpoint` and `origin` alongside the message,
363
- so one request's lines share a trace. Request context is on by default, production included
364
- (`AKAN_LOG_CONTEXT=0` opts out). **Give every sink a floor** — `Logger.addSink(sink, { minLevel })`; a sink with
365
- none makes every `verbose` call render. **Never log per delivered record in anything that delivers records.**
366
- Tail a running server with `akan logs <app>` (`--level --grep --endpoint --trace --child --role --origin
367
- --since`, `--json`) or `.tail` / `.trace <id>` inside `akan console`.
368
- - **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
369
- solo replica) its only writer** — every other server process turns its console off and forwards from
370
- `AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
371
- `raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
372
- `AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs` (SSE, `Last-Event-ID` resume, explicit `gap` events); unset,
373
- the route does not exist. `AKAN_LOG_CANONICAL=1|slow` writes one `ok|error <endpoint> ms= status=` line per call;
374
- `AKAN_LOG_FLIGHT=1` keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it
375
- failed or ran past `AKAN_LOG_FLIGHT_MS`; `x-akan-debug` lowers one request to `trace` — in `local`, or with the
376
- `AKAN_LOG_DEBUG_HEADER` secret. Structured values go in `LogRecord.attrs` via `Logger.emit(...)`; a key naming a
377
- secret is `[redacted]` before the record exists.
378
- - **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
379
- assembles one from; the string form takes no contributions. The generated image installs `ca-certificates` and
380
- `tzdata` and nothing else, so an app needing `ffmpeg` or Chromium declares it in `preRuns` / `postRuns`. A lib
381
- declares the steps its own runtime needs and every mounting app inherits them.
382
- - **Native apps are the `native` section** — iOS, Android and a desktop app (macOS, Windows, Linux), each shipping
383
- the CSR bundle on the native runtime. Shared fields (`appName`, `appId`, `version`, `indexPath`, `permissions`,
384
- `plugins`, `deepLinks`, `updates`, …) sit beside one section per platform: `ios`, `android`, `desktop`, and a
385
- platform section's own `indexPath` wins on that platform (`desktop: { indexPath: "/" }`). Without
386
- `targets` the app has one target, `default`, and an app with no basePaths leaves `basePath` out; a target takes
387
- the same shape and overrides the section field by field — objects merge key by key, a list or any other value
388
- replaces the section's. `akan start-ios` / `start-android` / `start-desktop` run a dev build that loads its pages
389
- from `akan start`; `build-ios` / `build-android` / `build-desktop` write
390
- `dist/native/<app>/<target>/build/<platform>`, a desktop app only for the OS that builds it — `--arch` picks
391
- a Windows or Linux app's CPU, and a macOS app is Apple silicon only. `--installer true` adds a Windows setup, a
392
- macOS dmg or a Linux AppImage; `--sign` signs with the identity `AKAN_NATIVE_MACOS_*` / `AKAN_NATIVE_WINDOWS_*`
393
- names and `--notarize` uploads the signed macOS app to Apple — the env alone does neither. With
394
- `updates: { url, publicKeys }` an installed app takes the signed releases `akan publish-update` makes: 1 to 3 keys,
395
- the online signing key first and offline spares after it (a key may revoke only those listed before it), of which a
396
- desktop app trusts the first, or `publicKey` for one. `akan update-keygen` makes the key and prints it for
397
- `publicKeys`.
398
- - **A desktop app that carries its server (`desktop: { server: true }` in `native`, or in one target) gets nothing
399
- from `docker`** — and `server: { omit: [...] }` leaves out packages only the image needs. That server is API-only, runs in database mode `single`, and listens on a loopback port any
400
- program on the computer can call, so guard its endpoints as a network server's. An executable the app spawns
401
- goes in `bin` — per platform, a download checked against its `sha256` or a file beside the config, put first on
402
- the app's PATH so the server's `spawn("ffmpeg")` finds it, and in `ctx.binDir` for a native plugin — and a package
403
- that builds itself at install goes in `trustedDependencies`, which the image honours too. Carry a static LGPL
404
- ffmpeg: a `--enable-nonfree` build may not be redistributed. A file the user picks with
405
- `filePicker.pickFiles({ forServer: true })` reaches it as a grant, never a copy or a path:
406
- `NativeFile.resolve(grant, "read")` in `akanjs/server` asks the shell for it. What it carries is readable on the
407
- user's computer — `private/` (each lib's too, under `private/libs/<lib>`), the one `env.server.<env>.ts` of its
408
- `--env` and its libs' server env defaults (`env.server.testing.ts`) — and it has no `public/`: read runtime files
409
- from `AKAN_APP_DIR`, never `process.cwd()`. **`server: { profile: "edge" }` bundles `env.server.<env>.edge.ts` in
410
- that file's place**, and the build refuses one holding a secret-named value; `server: { modules: ["@edge"] }`
411
- mounts only the modules whose `serve()` lists `operationMode: ["edge"]`, and what they reach.
412
- - **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
413
- trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
414
- the `akan.config.ts` that owns the font, written against that scope's own `public/`.
415
- - **Database modes are `database: { modes: [...] }`** — `single` (SQLite + Solid), `multiple` (one SQLite file on
416
- a host volume + Redis), `cluster` (Postgres + Redis). The build carries the drivers of every declared mode and a
417
- deployment picks one with `AKAN_DATABASE_MODE` — a declared one, named whenever the build declares several.
418
- Where the data lives (`POSTGRES_URL`, `REDIS_URI`, `SQLITE_DATABASE_PATH`) is the deployment's env, ahead of
419
- `env.server.ts`. `q.raw` is written in the dialect's own SQL, so an app running in two modes avoids it.
420
- - **Backups run through the image's own `main.js`**: `bun main.js ops snapshot` / `ops restore` (offline), and
421
- `/_akan/ops/*` for a control plane, mounted only when `AKAN_OPS_PUBLIC_KEY` is set and authenticated by a short
422
- EdDSA token that key verifies. `GET /_akan/app/info` is public. `operationMode` gates none of it.
232
+ Declared in `akan.config.ts`, narrowed — never widened — by an env at boot; domain code reaches none of it
233
+ (`runtimeRule`).
234
+
235
+ - **`web: true | false | { csr: boolean }`** — `{ csr: false }` drops the CSR bundle native apps ship, `false` is
236
+ API-only.
237
+ - **Route prefixes move together**: `new AkanApp({ prefix, websocketPrefix })` in `main.ts`, mirrored in
238
+ `api: { … }` in `akan.config.ts` for a CSR shell or native bundle. Read them with `getApiPrefix()` /
239
+ `getWsPrefix()` (`akanjs/base`); never write `/api` or `/ws`, never pass one as a prop.
240
+ - **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`**, not the barrel.
241
+ - Give every log sink a floor (`Logger.addSink(sink, { minLevel })`), never log per delivered record in anything
242
+ that delivers records, and tail with `akan logs <app>`.
243
+ - **`docker` is `string | { image, preRuns, postRuns, command }`**; the generated image installs only
244
+ `ca-certificates` and `tzdata`.
245
+ - **Native apps are the `native` section** (iOS, Android, desktop). A desktop app's carried server listens on a
246
+ loopback port any local program can call — guard it as a network server — and ships `private/` and its env
247
+ readable on the user's computer; read runtime files from `AKAN_APP_DIR`.
248
+ - **`database: { modes: [...] }`** (`single`, `multiple`, `cluster`), chosen by `AKAN_DATABASE_MODE`; an app running
249
+ in two modes avoids `q.raw`.
423
250
 
424
251
  ## React Components And Styling (`**/*.tsx`)
425
252
 
426
- - Components are `export const X = ({ … }: XProps) => { return (…); };` — arrow const with a block body. `export default` is reserved for pages, layouts, and `lazy()` targets.
427
- - Never `React.FC`, never `defaultProps`, never `PropsWithChildren`. Defaults go in the destructuring (`prefix = ""`); children are typed `children: ReactNode`.
428
- - `"use client"` on line 1 above the imports is mechanical by file role: every `.Zone.tsx`, `.Template.tsx`, and `.Util.tsx` has it; no `.Unit.tsx` or `.View.tsx` ever does. `usePage()` is legal in server files.
429
- - Conditional render is `cond ? <X/> : null`. Never `{cond && <X/>}` — in a `className` context it renders the literal string `"false"`. Early `return null` is for guard clauses only.
430
- - Never hand-roll loading, empty, or list states. Use `Load.Units` / `Load.View` / `Load.Edit` with `renderItem`, `renderList`, `renderView`, and `renderEmpty`; `<Empty />` for a bare placeholder; and `Model.New` / `Model.Edit` / `Model.SureToRemove` for CRUD modals. For data no `Load.*` covers, `<Load.Stream of={promise} fallback={…}>{(value) => …}</Load.Stream>` awaits one promise behind its own Suspense boundary; a resolved value renders in the shell with no boundary at all, so the same call site works either way.
431
- - Avoid hooks. `useState` is for modal-open, tab, draft-input, and drag state only — never for server data. `useEffect` must be a genuine effect such as subscribe-with-cleanup or one-shot init. Prefer `Tab` over a `useState` mode switch. `.Template.tsx` files contain zero `useState`.
432
- - Forms are entirely store-driven: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`, the setter passed by reference. Always use `Field.*`, never a bare `<input>` for a model field. Nested rows use `st.do.writeOnX("payments.3.name", v)` plus the generated `add<Field>OnX` / `sub<Field>OnX`. **Passing the setter by reference is also what makes the framework emit `data-akan-action` / `data-akan-state`** on the control — the annotation an in-page agent, an E2E selector, and an external browser agent all read. Wrapping it in an inline arrow (`onChange={(v) => st.do.setFieldOnX(v)}`) silently drops that: a closure the caller wrote says nothing about what it does. Never hand-write a `data-akan-*` attribute.
433
- - Read with `st.use.*` and write with `st.do.*`. Client components do not call `fetch.*`.
434
- - **An edit shell recovers its own form — never persist form values yourself.** `Load.Edit`, `Model.EditModal` and
435
- `Model.New` save the whole `<model>Form` as the user types and offer it back on the next open, so a modal closed
436
- by accident, a route change, or a killed app costs nothing. The scope is the record id for an edit and the seed
437
- plus the route for a new form, both keyed to the signed-in user, so another record or another parent never loads
438
- someone else's half-finished form. A new form's draft is applied on open with a "start over" chip; an edit whose
439
- record moved in the meantime is *not* applied, and asks. Turn it off with `draft={false}`, or name the scope
440
- yourself with `draft="<scope>"` when the context is in neither the id nor the seed. `field.secret` and
441
- `field.hidden` values are never saved. The old per-field `cache` / `cacheKey` props are deprecated and store
442
- nothing — they covered five control types, keyed on the translated label, and restored over server data.
443
- - Static class strings stay plain strings. Reach for `cn` only for a conditional or to merge an incoming `className`, and merge the caller last: `cn("base classes", cond && "extra", className)`. `cn` comes from `akanjs/client` (token-aware tailwind-merge) and is the only class-combining function — no `clsx` (removed), no raw `twMerge` imports, no object syntax (`{ x: cond }` → `cond && "x"`).
444
- - Multi-slot components take extra named props (`wrapperClassName`, `bodyClassName`), never a `classNames` object.
445
- - Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`. When a second module needs it, the classes become a variant axis of a recipe in `ui/Recipe/`; only a map from the enum to another recipe's variant name (`BadgeVariants["variant"]`) moves to `webkit/`.
446
- - Use `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
253
+ - `export const X = ({ … }: XProps) => { return (…); };` — `export default` only for pages, layouts, `lazy()`
254
+ targets. No `React.FC`, `defaultProps`, `PropsWithChildren`; defaults in the destructuring, `children: ReactNode`.
255
+ - `cond ? <X/> : null`, never `{cond && <X/>}` (renders `"false"` in a `className`); early `return null` only for
256
+ guard clauses.
257
+ - Never hand-roll loading, empty, or list states: `Load.Units` / `Load.View` / `Load.Edit` (`renderItem`,
258
+ `renderList`, `renderView`, `renderEmpty`), `<Empty />`, `Model.New` / `Model.Edit` / `Model.SureToRemove`.
259
+ Otherwise `<Load.Stream of={promise} fallback={…}>{(value) => …}</Load.Stream>` awaits one promise behind its own
260
+ boundary (a resolved value renders in the shell).
261
+ - Avoid hooks. `useState` only for modal-open, tab, draft-input, and drag state, never server data; `useEffect` only
262
+ for a genuine effect (subscribe-with-cleanup, one-shot init); `Tab` over a mode switch; zero `useState` in a
263
+ `.Template.tsx`.
264
+ - Forms are store-driven: `value={xForm.field}`, `onChange={st.do.setFieldOnX}` **by reference** — that emits the
265
+ `data-akan-action` / `data-akan-state` the in-page agent, E2E selectors and browser agents read. Always `Field.*`,
266
+ never a bare `<input>` for a model field; nested rows use `st.do.writeOnX("payments.3.name", v)` and the generated
267
+ `add<Field>OnX` / `sub<Field>OnX`. Never hand-write `data-akan-*`.
268
+ - Read with `st.use.*`, write with `st.do.*`; client components never call `fetch.*`.
269
+ - **Never persist form values yourself** — `Load.Edit`, `Model.EditModal` and `Model.New` save and recover the whole
270
+ `<model>Form` per record or route and user (`draft={false}` off, `draft="<scope>"` to name it; secret and hidden
271
+ values never saved; field `cache` / `cacheKey` props are deprecated no-ops — `modelStore`).
272
+ - Static class strings stay plain. `cn` (from `akanjs/client`) only for a conditional or to merge an incoming
273
+ `className`, caller last: `cn("base", cond && "extra", className)` — the only combiner: no `clsx`, raw `twMerge`,
274
+ or object syntax.
275
+ - Multi-slot components take named props (`wrapperClassName`), never a `classNames` object.
276
+ - Enum→class lookups: a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`, not
277
+ `Record<...>`. Shared by a second module, the classes become a recipe variant in `ui/Recipe/`; only an enum→variant
278
+ name map (`BadgeVariants["variant"]`) moves to `webkit/`.
279
+ - `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
447
280
 
448
281
  ## Naming And Language
449
282
 
450
- - Component exports are role names — `Card`, `Sticker`, `General`, `Preview`, `Admin`, `World`, `Mesh`, `Remove`. The model comes from the namespace (`<Floor.Unit.Card>`), so never write `FloorCard`. `Util` exports are the endpoint verb minus the model noun (`Serve`, `Refund`, `Complete`, `Terminate`).
451
- - Layer the verbs: the document chain method drops the model (`sign()`, `approve()`), the service keeps the bare verb, and the signal, store, and dictionary re-add it (`signScContract`). This keeps custom endpoints clear of generated CRUD and makes `st.do.X` read the same as `fetch.X`.
452
- - Slice and filter names are prepositional: `inOrg`, `inProject`, `inPeriod`, `byStatuses`, `ofPortfolio`. Never `getXInY`, never `listX`.
453
- - Handlers are `onX` props taking inline arrows. Do not extract a `handleX`.
454
- - Booleans are `is*` / `has*` / `can*` / `show*` / `disable*`. Counters are `*Num`, indices are `idx`, collections are `*List` or plural. `SCREAMING_SNAKE` is unused; module-scope tables are camelCase + `as const`.
455
- - Keep existing domain vocabulary and its typos. Transliterated domain terms and misspelled identifiers already in use are load-bearing — renaming them silently breaks callers that match on the name.
456
- - Identifiers, type names, endpoint names, and log messages are **English, always**. Everything a user reads goes through `l("model.field")` or `l.trans({ en, ko })` — never a hard-coded string in JSX, never `window.alert`.
457
- - Dictionary entries are `[en, ko]` pairs, and nearly every label also carries a `.desc([en, ko])` even when it repeats the label. English labels are Title Case, Korean is the plain domain term, and `.error()` Korean ends in `다.`.
283
+ - Component exports are role names (`Card`, `General`, `Preview`, `Admin`, `Remove`) — the namespace carries the
284
+ model (`<Floor.Unit.Card>`, never `FloorCard`). `Util` exports are the endpoint verb minus the noun (`Serve`,
285
+ `Refund`).
286
+ - Layer the verbs: the document chain method drops the model (`sign()`), the service keeps the bare verb, signal,
287
+ store and dictionary re-add it (`signScContract`) — clear of generated CRUD, and `st.do.X` reads like `fetch.X`.
288
+ - Slice and filter names are prepositional (`inOrg`, `byStatuses`, `ofPortfolio`), never `getXInY` or `listX`.
289
+ - Handlers are `onX` props taking inline arrows, never an extracted `handleX`.
290
+ - Booleans `is*` / `has*` / `can*` / `show*` / `disable*`; counters `*Num`; indices `idx`; collections `*List` or
291
+ plural; no `SCREAMING_SNAKE` — module-scope tables are camelCase + `as const`.
292
+ - Keep existing domain vocabulary and its typos — transliterated terms and misspellings in use are load-bearing, and
293
+ renaming silently breaks callers that match on the name.
294
+ - Identifiers, type names, endpoint names, and log messages are **English**. Everything a user reads goes through
295
+ `l("model.field")` or `l.trans({ en, ko })` — never a hard-coded JSX string, never `window.alert`.
296
+ - Dictionary entries are `[en, ko]` pairs, nearly every label with a `.desc([en, ko])` even when repetitive; English
297
+ in Title Case, Korean the plain domain term, `.error()` Korean ending in `다.`.
458
298
 
459
299
  ## Domain Module Conventions (`apps/**/lib/**`, `libs/**/lib/**`)
460
300
 
461
- - Organize business concepts as domain folders under `lib/`; keep related schema, service, signal, store, and UI files together.
462
- - Use lowercase logic files such as `<model>.constant.ts`, `<model>.document.ts`, `<model>.service.ts`, `<model>.signal.ts`, `<model>.dictionary.ts`, and `<model>.store.ts`.
463
- - Use PascalCase React component files such as `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.View.tsx`, `<Model>.Zone.tsx`, and `<Model>.Util.tsx`.
464
- - Treat `constant.ts`, `dictionary.ts`, and `signal.ts` as shared contract files that should avoid platform-specific dependencies.
465
- - Keep backend persistence/query logic in `.document.ts` and domain business orchestration in `.service.ts`.
466
- - Keep frontend state in `.store.ts`; use `Template` for forms, `Unit` for list/card items, `View` for details, `Zone` for composed page sections, and `Util` for domain-specific UI helpers.
467
-
468
- ### Module File Playbook
469
-
470
- **`<model>.constant.ts`** — five classes in order with one blank line between them and `enumOf("camelName", [...] as const)`
471
- classes above: `XInput → XObject → LightX → X → XInsight`. Write `XInsight` even when it is empty. Put display and
472
- predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`) — the Light class is the one both server
473
- and client hold, so shared logic belongs there instead of in a util module. **This is the most commonly missed rule
474
- in the codebase.** Collection-level helpers go `static` on the full model. Give any field whose business meaning is
475
- not obvious a short trailing comment.
476
-
477
- **A model instance's `Date` fields are prototype accessors, not own properties.** `user.createdAt` is still a `Dayjs`,
478
- built on first read from a native `Date` the instance holds under a symbol. So `Object.keys(user)` and `{ ...user }`
479
- do **not** include them, while `"createdAt" in user`, `for...in`, `JSON.stringify(user)`, `plainFieldsOf(user)`
480
- (`akanjs/common`), `immerify`, and `deepObjectify` all do. To copy a model, `new cnst.X().set(user)`, never a spread.
481
-
482
- **`<model>.document.ts`** — fixed order: `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
483
- with `sort: {}` always present. Chain methods validate → mutate → `return this`, and never `save()`; the caller saves,
484
- so chains compose (`org.removeUser(id).removeInvite(id).save()`). Put a one-line comment above each stating the
485
- transition. Atomic counters live on the Model class with the updater-callback form, returning `!!modifiedCount`.
486
- Indexes and derived totals go in `static override _onSchema`, not in the service. **Removal is always soft** — the
487
- framework has no hard delete for a model table, and `delete` is deliberately left unused so it can mean one later.
488
- Every read and query-level write sees live rows only (`removedAt IS NULL`). `{ withRemoved: true }` reaches removed
489
- rows: the third argument of `find` / `findOne` / `findById` (projection second, `null` when none), the last of
490
- `count` / `exists` / `updateMany`, or inside `updateOne` / `updateById`'s options. Without it, a `removedAt`
491
- condition only a removed row can meet (`q.exists("removedAt")`, a comparison) throws `can never match`. Removes,
492
- slices, filter methods, loaders and `pickById` stay live-only, a removed document is read-only (`save()` throws),
493
- and `updateById(id, { removedAt: null }, { withRemoved: true })` revives one without hooks.
494
- The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only the
495
- count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
496
- `removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
497
- so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`. The
498
- write-through pair is `pickAndWrite(id, data)` / `pickOneAndWrite(query, data)` — pick the document, write into it
499
- and save it, so the save hooks do run; reach for those when the write has to be seen by them. `upsertById(data, {
500
- preserveTimestamps })` writes a record whole under its own id, new or in place, with no hook: the write a sync or a
501
- backfill repeats safely, keeping the record's `createdAt`/`updatedAt` when asked.
502
-
503
- **`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
504
- explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
505
- `_postCreate`, not inline. Fire-and-forget is explicitly `void`-ed. Order deliberately: load every referenced document,
506
- then save, then notify. Return `null` / `false` for "not allowed" or "not found" and let the signal decide whether that
507
- is an error.
508
-
509
- **`<model>.signal.ts`** — `XInternal` → `XSlice` → `XEndpoint`, all three declared even when empty. `exec` is a one-liner
510
- delegating to the service.
511
-
512
- **`<model>.store.ts`** — write a custom action only for a toast, an optimistic update, or a multi-field write; most
513
- stores need none, because state and CRUD actions are generated. The body is three lines: `await fetch.X` →
514
- `this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
515
- `this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
516
- through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
517
- nothing** (`no-return-in-store-action.grit`). **Reach another store through `this`:**
518
- `import type { RootStore } from "../st"`, then `(this as unknown as RootStore).logout()` to call its action or
519
- `(this as unknown as RootStore).set({ … })` / `.get()` to write or read its state. Every store is mixed into one root
520
- at runtime, so the cast only tells the type what `this` already is. Keep it `import type` — `st.ts` imports every
521
- store, so a value import is a cycle.
522
-
523
- **`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
524
- `.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
525
- Name every argument in `.arg()`, including framework-supplied `skip` / `limit` / `sort`. Use `modelDictionary`,
526
- `scalarDictionary`, or `serviceDictionary` to match the module kind.
527
-
528
- **`<module>.abstract.md`** — a title line, one declarative sentence naming what the module owns, a `## Rules` list of
529
- two to five invariants the code cannot show, and an optional workflow arrow chain
530
- (`draft -> signed -> active -> completed`). Never restate field lists or types. Update it whenever an invariant or
531
- workflow changes.
301
+ A business concept is one `lib/` folder holding its logic files
302
+ (`<model>.constant|dictionary|document|service|signal|store.ts`) and components
303
+ (`<Model>.Template|Unit|View|Zone|Util.tsx`). `constant`, `dictionary` and
304
+ `signal` are shared contracts with no platform-specific dependency; persistence and queries live in `document`,
305
+ orchestration in `service`, client state in `store`; `Template` is forms, `Unit` list/card items, `View` details,
306
+ `Zone` page sections, `Util` domain UI helpers.
307
+
308
+ - **`<model>.constant.ts`** — `enumOf("camelName", [...] as const)` classes, then
309
+ `XInput → XObject → LightX → X → XInsight`, one blank line apart, `XInsight` even when empty. **Display and
310
+ predicate logic goes on `LightX`** (`isNew()`, `canWrite(user?)`), which server and client both hold, never a util
311
+ module — the most commonly missed rule. Collection helpers are `static` on `X`; a non-obvious field gets a short
312
+ trailing comment. `Date` fields are prototype accessors that a spread drops — copy with `new cnst.X().set(user)`
313
+ (`fieldRule`).
314
+ - **`<model>.document.ts`** — `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
315
+ `sort: {}` always present. Chain methods validate → mutate → `return this`, never `save()` (the caller saves, so
316
+ `org.removeUser(id).removeInvite(id).save()` composes), with a one-line transition comment above each. Atomic
317
+ counters live on the Model class with the updater callback, returning `!!modifiedCount`; indexes and derived
318
+ totals go in `static override _onSchema`. **Removal is always soft** and reads see live rows unless
319
+ `{ withRemoved: true }`. `updateById` / `removeById` fire no hooks — a cascading or `_postRemove` model removes
320
+ through `remove<Model>(id)`, and `pickAndWrite` writes through the save hooks (`queryRule`).
321
+ - **`<model>.service.ts`** — a few lines: load → chain → `return await ….save()` (keep the `return await`). Side
322
+ effects go in `override async _preUpdate` / `_postCreate`; fire-and-forget is `void`-ed. Load every referenced
323
+ document, then save, then notify. Return `null` / `false` for not allowed or not found; the signal decides
324
+ whether that is an error.
325
+ - **`<model>.signal.ts`** — `XInternal` → `XSlice` → `XEndpoint`, all three even when empty; `exec` is a one-liner
326
+ calling the service.
327
+ - **`<model>.store.ts`** — custom actions only for a toast, an optimistic update, or a multi-field write:
328
+ `await fetch.X` → `this.setX(...)` → toast, or mutate the client model, `void fetch.*`, then commit.
329
+ `this.pick(...)` when the value must exist, `this.get()` when it may not, `this.set({...})` to write; lists through
330
+ the collection API (`xList.set(x).save()`), not spread. **Another store is reached through `this`**:
331
+ `import type { RootStore } from "../st"` (type-only — `st.ts` imports every store), then
332
+ `(this as unknown as RootStore).logout()` or `.set({ … })` / `.get()`; every store is mixed into one root at
333
+ runtime, so the cast only names what `this` already is.
334
+ - **`<model>.dictionary.ts`** — empty stages still written:
335
+ `.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`;
336
+ every `.arg()` named, framework `skip` / `limit` / `sort` included; `modelDictionary` / `scalarDictionary` /
337
+ `serviceDictionary` by module kind.
338
+ - **`<module>.abstract.md`** — a title, one sentence naming what the module owns, a `## Rules` list of two to five
339
+ invariants the code cannot show, an optional workflow chain (`draft -> signed -> active`). Never restate fields or
340
+ types; update it when an invariant or workflow changes.
532
341
 
533
342
  ## Service And Signal Conventions (`*.{service,signal}.ts`, `server/**`)
534
343
 
535
- - Keep domain business operations in `.service.ts` classes built with `serve(...)`.
536
- - Keep execution contracts and triggers in `.signal.ts` classes built with `internal(...)`, `slice(...)`, and `endpoint(...)`.
537
- - Use `Internal` for internal triggers such as init, interval, cron, or queue jobs. A `process` job's
538
- `backoff: { type: "exponential", delay, maxDelay }` caps the wait between attempts, and the internal's
539
- `onFailed(job, error)` runs once a job has failed for good — its last attempt, or bullmq's stall limit — the dead
540
- letter, on either queue.
541
- - Use `Slice` for typed data views that feed client stores and zones; keep each slice focused on one purpose.
542
- - Use `Endpoint` for query and mutation contracts exposed to callers.
543
- - Connect external APIs or infrastructure through adapters, usually under `srvkit/`, and inject them into services instead of importing vendor clients directly into domain logic.
544
-
545
- ### Guards And Transports
546
-
547
- Full contract — credential handshake, room revalidation, socket cleanup scoping: `get_guideline` with
548
- `transportRule`, or `akan guideline show transportRule`.
549
-
550
- - Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` instead of
551
- branching on `getHttpContext()` / `getWebSocketContext()`, and keep them side-effect free and safe to re-run —
552
- a pubsub room's guards are re-run whenever the socket's credential changes, each time on a fresh context, so a
553
- guard may memoize per context without replaying the verdict it gave at subscribe.
554
- - **Arguments are parsed after the `"account"` guards, and `"resource"` guards run after the parse**, whatever
555
- order `guards: [...]` lists them in. A caller an account guard refuses never reaches an argument parser, so an
556
- expensive scalar parser is not a pre-auth attack surface. A guard or middleware that reads an argument earlier
557
- still gets it (parsed on first read). An unauthenticated call with a malformed argument answers 401/403, not 400.
558
- - Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is
559
- unguarded unless it declares its own `guards` in its signal option.
560
- - **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`** (`context.getClientIp()`
561
- inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
562
- *gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
563
- recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
564
- - **A device or another server proves itself with a machine token, never a staff session.**
565
- `MachineToken.issue({ kind, id, version })` (`akanjs/server`) signs one with `AKAN_MACHINE_PRIVATE_KEY` (made by
566
- `akan machine-keygen <file>`), with no expiry; a server holding that key or `AKAN_MACHINE_PUBLIC_KEY` sets
567
- `account.machine` from a verified one on HTTP and websocket alike, and only from one. Read it with `.with(Machine)`
568
- or in a guard, and revoke by bumping the version the guard compares to the record's own.
569
- - **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
570
- (`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
571
- bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
572
- **connection**, not a caller, so per-user state keys on the account.
573
- - **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
574
- — a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
575
- registers for both.
576
- - **A call's deadline is the endpoint's `{ timeout: <ms> }`, and there is nowhere else to put one.** It bounds
577
- both ends: the `Timeout` middleware — registered by default, and standing aside for every endpoint that
578
- declares none — rejects the call with `base.error.gatewayTimeout`, and the same value is serialized to the
579
- client as that call's request budget. Declared nowhere, a call takes the client's own default of **30
580
- seconds**, so an endpoint whose real work runs longer (provisioning, a firmware flow, an external
581
- orchestration) has to declare one or it dies mid-flight with a transport error that does not say whether the
582
- server ever received it. A caller overrides it per call with `fetch.x(…, { timeout })` — `false` waits as long
583
- as the runtime will — and an app moves its own default with `fetch.instance.setTimeout(ms)`. An upload is
584
- never bounded. **Losing the race does not cancel the work**: the handler runs to completion with nobody
585
- holding its result, so a deadline is an answer to the caller, not an undo.
586
- - **A cached answer is the endpoint's `{ cache: <ms> }`, and only a `query` taking no internal argument may have
587
- one.** It is a step of the call, not a middleware, and stands aside for every endpoint that declares nothing.
588
- Internal arguments are how a call learns who is asking (`.with(Self)`), so an endpoint that has them answers
589
- per caller and one shared entry would be one caller's answer handed to the next — that endpoint and every
590
- `mutation` are named in the log and left uncached. The lookup runs after the guards, so a hit reaches only a
591
- caller they admitted; what is cached is the handler's result, so `resolveReturn` still masks fields and resolves
592
- relations per call; a cache backend that is down is warned about and the call runs uncached. A call runs: the
593
- cross-site check and an `ip` rate limit, the body read, then the middleware chain `Logging → Timeout → <lib
594
- middlewares>`, then an `account` rate limit → account guards → argument parse → resource guards → internal
595
- arguments → cache → handler. There is no `Retry` middleware.
596
- - **A rate limit is the endpoint's `{ rateLimit: { calls, windowMs?, by? } }`** — `windowMs` 60 000 unless named,
597
- `by: "ip"` (counted before the body is read) or `"account"` (counted after the middlewares, before the guards;
598
- a caller with no account counts by address). It answers 429 `base.error.tooManyRequests` with `{ seconds }` and
599
- a `Retry-After` header. Counters share the cache across instances. An app tunes it in `option.ts` with
600
- `option.setRateLimit({ budget, endpoints, accountKey })` — `budget` covers every endpoint that declares none,
601
- `endpoints` overrides one by key (a lib's included, `false` exempts), `accountKey` names who `by: "account"`
602
- counts — and `rateLimit: false` exempts an endpoint from the app's budget. `AKAN_RATE_LIMIT=off` turns every
603
- budget off whatever the code says; budgets are off in a `local` environment unless `AKAN_RATE_LIMIT=on`. A
604
- business key (a phone number, an email) is counted in a service with `plug(RateLimit)` and
605
- `consume(key, { calls, windowMs })`, which no switch turns off. Callers whose address is unknown share one
606
- budget, and a page's server-side fetches carry the visitor's address, not the server's.
607
-
608
- ### Authorization Defaults
344
+ - `serve(...)` services hold business operations. Signals hold contracts: `internal(...)` for init, interval, cron
345
+ and queue jobs (retries, dead letter: `workspaceRecipes` Recipe 5), `slice(...)` for focused data views feeding
346
+ stores and zones, `endpoint(...)` for queries and mutations. External APIs and infrastructure go through
347
+ `srvkit/` adapters injected into services, never vendor clients in domain logic.
609
348
 
610
- - **Every `slice()` takes an explicit `{ guards: {…} }` second argument, and `root:` is always `Admin`.**
611
- - **A generated verb is taken off HTTP with `guards: { cru: false }`** (or `get`/`create`/`update`/`remove:
612
- false`), and a verb whose guards resolve to none is not mounted at all. `mcp: { cru: false }` does **not** do
613
- this: it keeps the verbs off the agent shelf while HTTP serves them to whoever passes their guards.
614
- - **Every endpoint that declares no guards is named in a boot `warn`**, since it answers anyone over HTTP and
615
- WebSocket — a named slice's `init()`, a root slice, a `pubsub`/`message`/`query`/`mutation`. Write
616
- `guards: [Public]` to keep one open on purpose.
617
- - **Every custom `mutation` / `query` / `message` names its own `guards: [...]` array.** Never rely on the slice default. `Public` belongs on a slice `get:`; on a mutation it is reserved for an act that must work signed out — signing up or in, a token refresh, an OAuth protocol route — and such a mutation also declares a `rateLimit`. MCP refuses a mutation whose only guard is `Public`, so it never reaches an agent.
618
- - **The guards are also the MCP exposure decision** — see MCP Exposure. An endpoint that names none is not published to agents at all, and a mutation whose only guard is `Public` is refused, so a missing `guards` array now costs visibility as well as authorization.
619
- - Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts` that `implements Guard` with an `async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn` then `false`. Admin bypass goes first.
620
- - Keep `static name = "User";` on guard classes. `fetch` serializes guard names and the API explorer filters on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
621
- - **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`. An `"account"` guard that throws at listing time reached for arguments it does not have: the entry is hidden from every caller and the guard is named once in the log as mismarked. A guard that admits no model at all — `Person` — adds `static agents = false`; the MCP catalogue then refuses every endpoint it guards outright, so the person-only act is absent from the document rather than hidden per caller.
622
- - The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
623
- - Guards ship with the library that owns the model and are imported by its own signals through the package path, so a mounting app inherits authorization and cannot forget it.
624
- - Services re-check ownership even when a guard already gated the call — two independent gates.
625
- - `srvkit/guards.ts` earns real comments: explain what would leak without each guard.
626
-
627
- ### Signal Body Types
628
-
629
- - `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
630
- - Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
631
- - `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a model field type. The other body args of such a mutation arrive typed as in a JSON body. A file's `type` on the server is what its name implies, not its bytes — check the bytes when the type matters.
632
- - Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference. A `Binary` return travels as base64 in an
633
- HTTP body, and a return declared `Any` that turns out to be bytes fails instead of reaching the caller as `{}`.
634
-
635
- ### Binary Pubsub And Mutation Verbs
636
-
637
- Full contract — backpressure modes, the `Any`-carrying-bytes fallback, multi-verb paths: `get_guideline` with
638
- `transportRule`.
639
-
640
- - **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON envelope and the base64 a
641
- JSON wire would need; the client's subscribe callback receives a `Uint8Array`. The optimization applies only
642
- when the **whole** return is `Binary`. A declared `Binary` room **coalesces under backpressure** — keeping only
643
- the newest frame — which is what telemetry and video want; declare `{ backpressure: "queue" }` when the frames
644
- are a sequence a subscriber must see in full. A `pubsub(Any)` carrying bytes is framed too and warns once;
645
- declare `Binary`.
646
- - **A `mutation` is `POST`.** `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several
647
- verbs; two endpoints claiming the same path **and** verb fail the boot. Reach for it only when a foreign wire
648
- protocol forces the verb — Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
649
- whatever is declared.
650
-
651
- ### Reserved Endpoint Names
652
-
653
- - Auto-generated CRUD endpoints (e.g. `create<Model>`, `update<Model>`, `remove<Model>`) already exist for every model. Do not declare an `Endpoint`/`Slice` with a name that collides with them.
654
- - The service layer surfaces such a collision as a typecheck error, but the signal layer can pass sync/typecheck/build and fail only at runtime — so treat name collisions as errors regardless of whether the build is green.
655
-
656
- ### Slices, Queries, and Hydration
657
-
658
- Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `labelOf`, and the `q.exists` /
659
- `q.missing` / `q.empty` split: `get_guideline` with `queryRule`, or `akan guideline show queryRule`.
660
-
661
- - **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
662
- [ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
663
- filter every model carries. A raw query descriptor could not survive the query string — `String(value)` sends
664
- `[object Object]`. It is an admin API: `root:` is always `Admin`.
665
- - A slice's `exec` returns a `QueryOf` (an opaque query descriptor); you **cannot** chain `.sort()`/`.limit()` on
666
- it. Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })`. For a
667
- chainable builder use the model facade's `find`/`findOne`.
668
- - **A projection is bare on the facade and nested under `select` on a filter accessor.** `pickById(id, { secret:
669
- true })` and `find`/`findOne`/`findById`/`pickOne` take the projection as their second argument; a generated
670
- `findBy<Filter>(...args, { select: { secret: true } })` takes it inside the option object. The shapes do not swap
671
- — `{ select: … }` handed to the facade projects a field named `select` and throws `Unknown document field path`.
672
- The facade's third argument is the scope (`{ withRemoved: true }`), never a field. This is the only way to read a
673
- `field.secret(...)` value.
674
- - **`limit: 0` means every row on the server, never on the client.** A server `list<Filter>(…, { limit: 0 })`
675
- reads without a ceiling, like an omitted limit, and `null` pages at 20. A client's `0` (`initX(…, { limit: 0 })`)
676
- is served `MAX_PAGE_SIZE` (500) with a one-time warn. Paginate (`loadMoreOf<Model>`) or name a page size.
677
- - **A slice list is either one window or an accumulated one.** `setPageOf<Model>` swaps the window;
678
- `loadMoreOf<Model>()` appends the rows after the ones loaded and takes **no page number** — it skips by
679
- `<model>List.length`, so the offset cannot drift from what is on screen when a live insertion moves the server's
680
- baseline, and `pageOf<Model>` stays 1, which is what keeps live placement (first page only) working past the
681
- first "more". `isCumulativeOf<Model>` is the mode.
682
- - **"Is there more" is `hasMoreOf<Model>`, never `count > page * limit`.** It comes from the length of the batch
683
- the server returned, so it survives `{ insight: false }` and cannot drift from a count a live event moved.
684
- - **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`),
685
- and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true**
686
- (`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape is what the `<ref>Obj` /
687
- `<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the
688
- value can cross the RSC boundary as a client prop.
689
- - Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
690
- `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
691
- are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
692
- them on a model that carries no removal side effect; otherwise remove documents one at a time.
693
- - **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
694
- "archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
695
- touches nothing.
696
- - `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
697
- most one of these", not for claiming the next item off a queue. A condition in the query is a compare-and-set in
698
- every mode: of two concurrent calls that both saw the row match, one applies, and `modifiedCount` says which.
699
- - **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
700
- single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
701
- - **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
702
- which a document written once and saved again no longer is. Reach for it only to find rows written before the
703
- field was declared.
704
-
705
- ### Text Search In A Filter — `q.search()`
706
-
707
- Full contract — relevance ordering, `columns` / `weights`: `get_guideline` with `queryRule`.
708
-
709
- - Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
710
- q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
711
- `insightBySearch` come for free — you do **not** need a slice to make search usable.
712
- - **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
713
- client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
714
- the table. Leave that decision to the mounting app.
715
- - `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
716
- `q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery`.
717
- - Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
718
- search endpoint into a full listing.
719
- - Order by relevance with the built-in `relevance` sort key. A slice endpoint never reaches the unspecified
720
- fallback — leaving `sort` off gets `latest`, so a client wanting score order has to name `relevance`.
721
-
722
- ### Service / Signal Injection
723
-
724
- - Injected dependencies resolve by field-name convention: a field named `<refName>Service` resolves to the service registered under `<refName>`, and `<refName>Signal` likewise (`pkgs/akanjs/service/injectInfo.ts`).
725
- - The `Service`/`Signal` suffix is required — the injector strips it to derive the registry lookup key. Name the field after the target refName plus the suffix, not arbitrarily.
726
- - Preference order inside a service: `service<srv.XService>()` for another module's service · `plug(AdapterClass)` or `plug(StorageAdaptorRole)` for an adapter · `use<T>()` only to reach an `option.ts`-registered legacy singleton · `env(...)` for config.
727
- - **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })` serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the same declaration. Never hand-encode JSON into a `String` memory. A memory is scoped to the service or adaptor that declares it; `ttl` (milliseconds) sets how long each write lives unless the `set` call names its own `expireAt`, and a `Map` entry expires on its own.
728
-
729
- ### Adapters — `adapt()` And `plug()`
730
-
731
- An injected singleton is an `adapt()` class in `srvkit/`. Write new adapters this way.
732
-
733
- ```ts
734
- export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, memory }) => ({
735
- discordApi: use<DiscordApi>(),
736
- workspaceRoot: env(() => `~/build/${getEnv().environment}`),
737
- masterHost: plug(MasterHost),
738
- tokenMap: memory(Map, { of: String }),
739
- })) {}
740
- ```
349
+ ### Authorization
741
350
 
742
- - Inject it with `plug(TheClass)` from a service or from another adapter. Destructure only the injectors you use, and write the registration key `as const`.
743
- - **Do not register an `adapt()` class in `lib/option.ts`.** It self-registers, and `plug(Class)` uses the class itself as the token. `option.ts` is now only for legacy constructor-style adapters and for widening the options type.
744
- - `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in `override async onInit()`.
745
- - **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site. If there is not exactly one per process that a service needs injected, it is a plain class.
746
- - **Legacy shape — recognise it, do not copy it.** Plain classes with `constructor(options: XOptions)`, registered in `lib/option.ts` and injected with `use<T>()`, still work. Migrate one to `adapt()` only when you are already changing it.
747
-
748
- Conventions that hold for both shapes:
749
-
750
- - Route every remote call through one private `#api<T>(path, init?)` with `signal: AbortSignal.timeout(20_000)`.
751
- - Paginate with `for (let page = 1; ; page += 1)` broken by `if (pageItems.length < 100) break;`.
752
- - Resolve secrets as `process.env.X ?? options.x ?? deterministicGenerator(...)` **inside a function**, never at module scope.
753
- - Extend a function by appending an optional trailing parameter with a default, never by changing arity.
754
- - Parameters: up to three required primitives positional; optional flags in a trailing `{ … } = {}`; four or more parameters, or any two same-typed strings, in one named destructured object.
755
- - Release locks in `finally`. Load heavy optional dependencies through a module-level memoized promise (`puppeteerLoad ??= import("puppeteer")`).
351
+ - **Every `slice()` takes an explicit `{ guards: {…} }` second argument, and `root:` is always `Admin`.**
352
+ `guards: { cru: false }` (or per verb) takes a generated verb off HTTP; `mcp: { cru: false }` only keeps it off the
353
+ agent shelf.
354
+ - **Every custom `mutation` / `query` / `message` names its own `guards: [...]`** — the slice map reaches only
355
+ generated endpoints, and a `pubsub` / `message` without its own is unguarded. `Public` belongs on a slice `get:`;
356
+ on a mutation only for an act that must work signed out (sign-up/in, token refresh, an OAuth route), which also
357
+ declares a `rateLimit`. A guardless endpoint is warned at boot and never published to MCP, which also refuses a
358
+ mutation guarded only by `Public`.
359
+ - Guards serve HTTP and websocket alike: read `context.get<T>("account")`, never `getHttpContext()` /
360
+ `getWebSocketContext()`; keep them side-effect free and re-runnable. The acting user comes from `.with(Self)` /
361
+ `.with(CurrentUserId)` / `.with(Me)` (never a client-supplied id) and the caller's address from `.with(Ip)`.
362
+ - Keep `static name = "User";` on guard classes — `fetch` serializes it and the API explorer filters on it. It looks
363
+ dead, so comment it.
364
+ - **Every guard declares `static scope: GuardScope`, no default**: `"account"` reads only the caller (MCP listings
365
+ evaluate it to hide entries), `"resource"` needs the call's arguments and fails closed without them. `SignedIn` /
366
+ `Admin` / roles are `"account"`, every `Can<Verb><Model>` is `"resource"`; `Person` adds `static agents = false`.
367
+ - `Can<Verb><Model>` guards in `srvkit/guards.ts` fail closed and ship with the model's library (`transportRule`).
368
+ Services re-check ownership behind a guard — two independent gates.
369
+
370
+ ### Calls, Queries, Agents (summaries)
371
+
372
+ - **Wire (`transportRule`)**: a deadline is the endpoint's `{ timeout: <ms> }` (undeclared, the client quits at
373
+ 30 s; the handler is not cancelled); `{ cache }` only on a `query` with no internal argument; `{ rateLimit }` per
374
+ endpoint, `option.setRateLimit` per app, `plug(RateLimit)` for a business key. `socketId` names a connection, not
375
+ a caller. A device proves itself with a `MachineToken`. Server to server, `fetch.clone({ origin })` opens no socket
376
+ without `connect: true`, retries through `retry`, and rethrows a remote `Err` as is — never wrapped or re-keyed.
377
+ - **Body types**: `.body()` / `.param()` take scalars, model refs, or `enumOf(...)`; `Int` / `Float`, never
378
+ `Number` (`pkgs/akanjs/signal/endpointInfo.ts`); `Upload` only in a `fileUpload: true` mutation; bytes are
379
+ `Binary`, never `Any`. A model or scalar argument is checked against the request schema on every transport: a
380
+ wrong JSON type, a value outside its enum or a missing required field answers 400 with `data.name` naming the
381
+ path (`data.items.2.price`); a defaulted field may be left out, an undeclared key is dropped, and a stored model's
382
+ input keeps an absent field absent.
383
+ - **Reserved names**: generated CRUD (`create<Model>`, `update<Model>`, `remove<Model>`, …) exists for every model.
384
+ The service layer reports a collision as a typecheck error, but a colliding signal name can pass sync, typecheck
385
+ and build and fail at runtime — treat it as an error.
386
+ - **Queries (`queryRule`)**: the root slice takes `queryKey` + `args`, never a raw query. A slice `exec` returns a
387
+ `query<Filter>(…)` descriptor (never a `list<Filter>` array) and is paged through `initX(…, { sort, page, limit })`.
388
+ A projection is bare on the facade (`pickById(id, { secret: true })`), under `select` on a filter accessor — the
389
+ only way to read a `field.secret`. More rows: `loadMoreOf<Model>()` / `hasMoreOf<Model>`, never
390
+ `count > page * limit`. A filter's `remove*` / `update*` are hookless (no cascade). No value is `q.empty`, never
391
+ `q.missing`.
392
+ - **Search**: `q.search(text, { prefix: true })` in a filter, at an AND position, over `text`-role fields; a search
393
+ *slice* only on data safe to enumerate; blank input matches nothing — never a passthrough.
394
+ - **`cascade` names a direction; the wrong one loses data**: `removeRef` on the owner's relation, `removeWith` on
395
+ the child's reference to its owner. A query-level removal runs no cascade.
396
+ - **MCP (`mcpRule`)**: `/mcp` publishes every guarded endpoint by default; `mcp: false` (or a `slice()` `mcp` map
397
+ mirroring `guards`) is curation, not authorization. `guards: [Every, Person]` reserves an act for a person;
398
+ `.with(AgentCall)` narrows an agent's call. Settings are `option.setMcp` in `lib/option.ts`; `JWT_SECRET` is
399
+ required outside `local`. Never make a refusal message more helpful. Every entry needs a `.desc()`; publish a
400
+ screen with `page().prompt(name, description)`.
401
+ - **In-page agent (`agentRule`)**: mount `<Agent.Chat />` once in a layout; it never executes tools and refuses
402
+ every call until `option.setAgentAccess(<guard>)`; the LLM is `option.setLlm({ apiKey, model, host })`, never env.
403
+ The surface is what components declare — `st.tool(…).exec(fn)` beside its control, `st.use` / `st.sel` /
404
+ `st.ref` keys — never a store class. `st.use.x({ agent: false })` hides a key; its model's `hidden` / `secret` /
405
+ `visual` fields are stripped at read. A tool value is capped at 20,000 characters. Route guidance is
406
+ `<Agent.Guide instructions="…" />`; model-facing text is English.
407
+
408
+ ### Injection And Adapters
409
+
410
+ - A field named `<refName>Service` / `<refName>Signal` resolves to that registration — the suffix is required
411
+ (`pkgs/akanjs/service/injectInfo.ts`). In a service: `service<srv.XService>()` · `plug(AdapterClass)` /
412
+ `plug(StorageAdaptorRole)` · `use<T>()` only for a legacy `option.ts` singleton · `env(...)` for config.
413
+ - `memory(...)` takes a scalar or model class (`memory(Map, { of: CallStateInput })`); never hand-encode JSON into a
414
+ `String` memory.
415
+ - **A new adapter is an `adapt()` singleton in `srvkit/`**, injected with `plug(TheClass)`, key `as const`. Never
416
+ register it in `lib/option.ts` (it self-registers); never construct a `Logger` in it (`this.logger` exists);
417
+ lifecycle work in `override async onInit()`. A per-use value object is a plain class. Legacy
418
+ `constructor(options)` adapters migrate only when already being changed. Example and adapter conventions (one
419
+ `#api<T>` with a timeout, pagination, secrets, parameter shapes): `workspaceRecipes` Recipe 2.
756
420
 
757
421
  ### Error Placement
758
422
 
759
- - State-machine preconditions throw in `document.ts`; cross-document rules throw in `service.ts`; request-level policy belongs in `signal.ts` guards.
760
- - Best-effort code returns a sentinel (`null`, `undefined`, `[0, 0]`, `{}`). There are no Result/Either wrappers.
761
- - `try/catch` is rare and always converts an exception into a decision, never swallows one. Guards catch → `logger.warn` → `return false`; adapters catch → `logger.error` → `return null`; UI uses `try/finally` to reset a spinner. A bodyless `catch {}` is acceptable only with a one-line reason.
762
- - Store actions do not `try/catch` — let the framework toast the `Err`. Client-side validation failure is `msg.error("<key>")` plus an early return, never a throw.
763
- - **A server-side `fetch.clone({ origin })` opens no websocket unless `connect: true`**, and neither does a clone of
764
- such a clone: it makes one-off calls to another server, and a socket it opened would reconnect forever with nobody
765
- to close it. It never carries the visitor's session either: a call to another origin sends only the `jwt` its
766
- caller gave the clone.
767
- - **Retry a call with `retry`, never with a loop of your own.** `fetch.clone({ origin, retry: {} })` (or a call's
768
- own `{ retry }`) tries again on an unreachable server, a 502/503/504 and a 429 after its Retry-After, backing off
769
- with jitter until the call's one deadline runs out; a mutation retries under one `Idempotency-Key` it mints, or the
770
- `idempotencyKey` you name, and the server runs a mutation once per key, caller and arguments, answering a repeat
771
- with the first response as it was sent; a signed-out call, one whose handler returns a `Response`, and a call
772
- through MCP run every time. A 4xx is never retried.
773
- - `String(err)` and `${err}` read `Err: <key> {…data}`, the data as one line of JSON with secret-named keys masked,
774
- so a ``logger.warn(`…: ${String(error)}`)`` keeps the exit code or status the `Err` carried.
775
- - **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })` restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a `new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to `Internal Server Error` on the way out.
776
-
777
- ### MCP Exposure
778
-
779
- Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
780
- follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An
781
- endpoint that declares a real guard is published; one that declares none is refused, and so is a mutation whose
782
- only guard is `Public`. The guards are already the authorization decision, so a second switch would say nothing
783
- they do not — while guaranteeing that every endpoint added later is invisible to agents until somebody remembers
784
- it.
785
-
786
- - **`mcp: false` keeps an endpoint off the shelf without touching its guards.** Guards answer "may an agent call
787
- this"; they cannot answer "does this belong on a shelf", and a step of a UI-driven state machine
788
- (`requestPhoneCodeForSignin`) is perfectly guarded and still a mistake for a model to reach. Write it in the
789
- signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`),
790
- or as an `mcp` map on `slice()` that **mirrors the `guards` map key for key** (`mcp: { cru: false }`) — root
791
- slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
792
- curation, not authorization: HTTP serves the endpoint exactly as before. To take generated CRUD off HTTP too,
793
- write `guards: { cru: false }`.
794
- - **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
795
- schema of every model it mentions and the listing is re-sent whole to every agent that connects. By default a
796
- response schema names a nested model instead of inlining it (`outputSchema: "shallow"`; `"full"` restores the
797
- closure, `"none"` drops the schema and keeps the text block) and a request schema asks for a relation's id, which
798
- is what the wire carries. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
799
- where the rest went; `mcp: { cru: false }` on a model is the next lever. `outputSchema` moves wire bytes and client
800
- memory only — the model receives names, descriptions and input schemas (measured: 219 tools ≈ 41k tokens either
801
- way) — so tool count and the input model behind `create*` / `update*` are what cost context. In one line:
802
- `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
803
- tokens, `outputSchema` for nothing that reaches the model.
804
- - Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
805
- allowedOrigins, pageSize, language, outputSchema, rateLimit, apps, modern, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
806
- `main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
807
- valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off, and an edge server
808
- (`AKAN_PUBLIC_OPERATION_MODE=edge`) starts with it off unless `AKAN_MCP=true` or `setMcp(true)`. Every call that executes an
809
- endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
810
- / `AKAN_MCP_RATE_LIMIT` tunes or disables it.
811
- - **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
812
- beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
813
- (RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
814
- names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
815
- `aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
816
- judge it unchanged. The tokens this built-in server mints carry no scope; `auth.scopes` / `AKAN_MCP_SCOPES` are for
817
- tokens an external issuer mints, and declared beside this server they refuse every one of its tokens with 403
818
- `insufficient_scope`. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
819
- `signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `accessTokenSeconds`,
820
- `clientIdMetadata`, `enabled`, plus `issuer` / `resource` — set those when a tunnel or an edge makes the derived
821
- origin wrong, because an MCP client compares the issuer byte for byte). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` (or
822
- `security.jwtSecret`) is mandatory whenever `operationMode` is not `local`, because the derived fallback is
823
- forgeable from three public names; `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly. A grant is revoked
824
- whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
825
- owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
826
- - **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
827
- neither, and `context.origin` is `"mcp"` for a call through the MCP endpoint. `guards: [Every, Person]` refuses a
828
- model and, because `Person` declares `static agents = false`, takes the entry out of the MCP catalogue document
829
- itself (named in the boot log like `mcp: false`); `.with(AgentCall)` hands an endpoint the same verdict as a
830
- boolean to narrow what the call sets in motion; `isAgentCall(context)` is the function behind both
831
- (`@libs/shared/srvkit`). Never sniff `aud` through a cast.
832
- - **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
833
- guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
834
- generated `light<Model>` read. An `enumOf` argument is checked against its values on every path. **Every refusal
835
- is named in the boot log**, along with every published entry missing a dictionary `.desc()` — an agent picks a
836
- tool by its description.
837
- - A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
838
- generalized. Never make either message more helpful — the difference is what enumerates your private surface.
839
- - **A `field.visual` field is stripped from every MCP result** and from the readable schema so the two agree.
840
- - **A structured result ships twice by default** — as `structuredContent` and as the same JSON in the text block.
841
- `option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
842
- - **A picture is an `ImageResult` return** (`akanjs/base`): `query(ImageResult, { guards })` returns `{ data,
843
- mimeType, caption? }` — padded base64 with no `data:` prefix, and `image/png` / `jpeg` / `webp` / `gif`. Over MCP it
844
- answers the caption as a text block and the picture as an image block, with no `structuredContent`; over HTTP it
845
- is that JSON object. Like `Binary` it is never a model field.
846
- - **A prompt is a screen: `page().prompt(name, description)`.** There is no `prompt()` on `endpoint()`.
847
- `prompts/get` runs the page's body in the RSC worker under the caller's token — nothing is rendered — and answers
848
- the description, one `resource` per `fetch.*` the page made (masked by the endpoint's return model, addressed by
849
- its `akan://` uri, one document once whatever shapes read it) and the published tools of the modules it fetched
850
- from, minus what is already attached; lists — only lists — are cut to `promptBudget`. A guard refusing a query
851
- inside the body reads as the screen refusing the account, the same way a redirect does. A
852
- required argument left out answers a pointer to the `<model>List…` tool, never a guess. The description is the
853
- only instruction, in English — `<Agent.Guide>` never reaches a prompt, and the in-page chat lists no page prompts.
854
- - **The same page is a tool too, and an MCP Apps host renders it.** A `.prompt()` page answers `tools/call` with
855
- that briefing plus its web URL, and a host that declares the `io.modelcontextprotocol/ui` extension (claude.ai,
856
- ChatGPT, VS Code) shows the screen itself in the chat — the CSR bundle in a sandboxed iframe whose every call the
857
- host relays under the agent's token. It shows the page alone: every layout above it still runs (guards, providers,
858
- `<Agent.Guide>`) and draws nothing, unless it draws no page, as a gate does. Nothing is written for the view. An
859
- endpoint kept off the shelf only by `mcp: false`, and the generated `light<Model>` read, stay callable by that view
860
- and are listed to no model; a `Person` act is still refused there. `option.setMcp({ apps: false })` keeps pages
861
- prompts only, as a read-only deployment does, and a page named like an endpoint stays one (boot warns).
862
-
863
- Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
864
- `McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
865
-
866
- ## In-Page Agent
867
-
868
- Every akan app can host a component-level agent that reads the rendered screen and drives it. **A component
869
- declaration is the surface, exactly**: `st.tool` publishes one action, and `st.use` / `st.sel` / `st.ref` make one
870
- store key readable while the reading component is mounted. Nothing is derived from a store class — a lever the
871
- screen does not offer the user is not one an agent may pull in their place. The React core is the `use-agentic`
872
- package; apps and libs never import it directly (`no-import-external-library`) — everything reaches them through
873
- `st.*` and `akanjs/ui`.
874
-
875
- Read the full contract before building an agent surface — the chat's own options, `st.tool` / `st.expose` /
876
- `st.useState` in full, zones and namespaces, attachments and speech, slash commands, transcript compaction, and
877
- the built-in tool semantics: `get_guideline` with `agentRule`, or `akan guideline show agentRule`. What follows is
878
- what every developer must know even when not building one.
879
-
880
- - **Mount `<Agent.Chat />` once in a layout.** The endpoint is a stateless relay that **never executes tools**:
881
- every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
882
- `AgentRelayAccess`, which refuses every call until the app names a guard of its own —
883
- `option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
884
- (`option.setLlm({ apiKey, model, host })`). Two adaptors ship, one per wire: `OpenaiLlm` is the default and
885
- speaks the chat-completions dialect to whatever `host` names — OpenAI, DeepSeek, Groq, OpenRouter, Ollama — and
886
- `AnthropicLlm` speaks the Messages API and reads a PDF as well as a picture. Both require `model`. A provider
887
- neither covers is an `adapt()` class in `srvkit/` implementing `LlmAdaptor`, applied with
888
- `option.applyAdaptor(LlmAdaptorRole, TheClass)`; whatever settings it needs beyond `LlmOption` ride `setLlm`
889
- too, read back with `use<MyLlmOption>()`.
890
- - **Declare the tool beside the control that already does it**, never as a separate surface:
891
- `st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
892
- person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
893
- tool without publishing it, and a `disabled` control publishes nothing.
894
- - **Forms publish themselves**: a `Field.*` / `Input.*` / `Select` / `Switch` handed `onChange={st.do.setXOnY}`
895
- **by reference** publishes that setter, and `st.use.yForm()` adds `fillYForm(patch)`. An inline arrow publishes
896
- nothing — normalize with the control's `transform` prop, and multi-write with a `_postSet<Field>` store method.
897
- This is the same rule `no-unpublished-form-setter.grit` enforces.
898
- - **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface; a key
899
- no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
900
- - **Return what answers the question, not the record.** A tool's value is capped at 20,000 characters before it
901
- enters the transcript, and once there it rides *every* later turn. A field that is bulky and useless to a model
902
- is fixed once at the model instead: `field.visual(String)`.
903
- - **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or page — the render
904
- tree is the cascade. It is a component, not a `pageConfig` field, and `*.abstract.md` is never served to agents.
905
- - **Model-facing text is English, always** — every `.desc()`, `instructions`, Guide text. The `l()` rule covers
906
- strings a *user* reads.
423
+ - State-machine preconditions throw in `document.ts`, cross-document rules in `service.ts`, request policy in
424
+ `signal.ts` guards. Best-effort code returns a sentinel (`null`, `undefined`, `[0, 0]`, `{}`) — no Result types.
425
+ - An endpoint names the errors it throws on purpose — `errors: { 409: ["ticket.error.locked"] }` in its signal
426
+ option, with keys its dictionary defines and never `base.error.*`. That is the status's OpenAPI response and a line
427
+ of the MCP tool's description; outside production a thrown key it leaves out warns once. A caller narrows with
428
+ `Err.is(error, "ticket.error.locked")`.
429
+ - `try/catch` turns an exception into a decision: guards → `logger.warn` → `false`; adapters → `logger.error` →
430
+ `null`; UI `try/finally` resets a spinner. A bodyless `catch {}` needs a one-line reason.
431
+ - Store actions do not `try/catch` (the framework toasts the `Err`); client validation is `msg.error("<key>")` plus
432
+ an early return, never a throw.
907
433
 
908
434
  ## Scalar Modeling (`**/*.constant.ts`)
909
435
 
910
- - Define Akan models in `.constant.ts` files with `via` from `akanjs/constant`.
911
- - Use `Int` for whole-number counts and quantities; use `Float` only for values that need decimals.
912
- - Use `ID` for document references and prefer explicit structured fields over `Any` unless the content is genuinely flexible.
913
- - For date defaults, prefer a function such as `default: () => dayjs()` so the value is created at runtime.
914
- - Follow the established model layering pattern in this order: `Input`, `Object`, `Light<Model>`, full `<Model>`, and `<Model>Insight`. Write all five, and write `<Model>Insight` even when it is empty.
915
- - Put display and predicate logic on the `Light<Model>` class rather than in a util module — see Module File Playbook.
916
- - Defaults are a literal for scalars and a thunk for anything constructed. Arrays are `field([T])`; optional is the postfix `.optional()`.
917
- - A document-path change to an `{ immutable: true }` field is refused with `base.error.immutableField` (400,
918
- `data.field`), which the client toasts in the user's language; query-level writes are not checked.
919
- - **`field.visual(T)` is a field the page renders and an agent never sees** — a blur placeholder, a rendered HTML body, a serialized geometry. It stays an ordinary stored `property` (persistence, search, forms and the page response untouched) and is stripped wherever a value is masked for an AI caller: every in-page-agent read and every MCP result, along with the MCP readable schema. Unlike `hidden`/`secret` it is cost, not secrecy — nothing is refused over one. Reach for it whenever a field is bulky and useless to a model; that is cheaper than every tool learning to avoid it.
920
- - **A `field.hidden` / `field.secret` value reads `null` on the client, and the type still declares it.** `SignalContext.resolveReturn` skips both field types while it builds an endpoint's response, and hydration writes `null` over the key rather than leaving it absent — the first branch of the loop in `constant/getDefault.ts`, ahead of the field's own default. So the value is a deliberate `null` behind a type promising a `string`, every use of it typechecks, and the failure lands wherever it is finally dereferenced instead of where it was read. **Guard with `??` or `== null`**: `=== undefined`, a destructuring default and an optional parameter default all catch only a missing key and sail straight past this one. A projection (`pickById(id, { secret: true })`) widens the *server's* read, never the response. If a screen needs the value, the field is neither hidden nor secret; if it only needs to be cheap for a model rather than unseen, that is `field.visual`.
921
-
922
- ### Scalar & Field Type Reference
923
-
924
- - **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`, `ImageResult`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible payloads.
925
- - **Use the JS globals directly (no import needed)**: `String`, `Boolean`, `Date`. They are monkey-patched to behave like scalars, so `field(String)` typechecks.
926
- - **`Number` is not a valid field/body type.** `NumberConstructor` is intentionally not augmented, so `field(Number)` / `.body("x", Number)` fails to typecheck. Use `Int` or `Float` instead.
927
- - Runtime resolution of every scalar (globals included) goes through `PrimitiveRegistry` by `refName` (`pkgs/akanjs/base/primitiveRegistry.ts`).
928
- - **`Binary` is raw bytes on the wire, and never a model field.** It is `Uint8Array` on both sides — a Node
929
- `Buffer` is one, so a server handler may pass one straight in — and it accepts base64 in either direction, so
930
- the same declaration serves a JSON body and a binary frame. It is **not storable**: the class build refuses
931
- `field(Binary)` naming the `File` model instead, because every non-base field lives in the `_doc` JSON column
932
- and bytes would sit there as base64 and ride every read of the row. Never send bytes as `Any` — `Any` passes a
933
- `Buffer` through untouched and `JSON.stringify` then spells it `{ type: "Buffer", data: number[] }`, which
934
- `JSON.parse` never restores; the result is 3.6x the wire and a shape that only breaks at the first byte-offset
935
- read. MCP refuses a `Binary` return for the same reason it refuses `Any`.
936
-
937
- ### Text Search Fields — the `text` role
938
-
939
- Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH_ENABLED`: `get_guideline` with
940
- `queryRule`.
941
-
942
- - A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and
943
- likewise `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model
944
- switch. Declaring roles is all the wiring there is.
945
- - Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
946
- `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
947
- scoping value (status, owner, role) that must be matchable but must never outrank a real title hit. `thumb` is
948
- mirrored for rendering a hit and is **not** indexed — never expect it to match.
949
- - `schema.index({ x: "text" })` (a Mongo text spec) builds nothing and warns at boot: full-text search is this role.
950
- - **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
951
- mirror is plaintext and an indexed secret would leak through search. The same refusal throws at runtime as a
952
- backstop, including for a `text` field *underneath* one of those. Do not work around either.
953
- - The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
954
- indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
955
- - Search runs in every database mode — fts5 on SQLite, a weighted `tsvector` (`pg_trgm` for `trigram`) on
956
- Postgres. The same text matches the same documents; only the order may differ on Postgres.
957
-
958
- ### Image & File Fields
959
-
960
- - **Do not declare `Upload` as a model field.** `Upload` is a signal-body-only primitive (see Service And Signal Conventions). Models reference the `File` model instead.
961
- - Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many.
962
- - The store then auto-generates an `upload<Field>On<Model>(fileList)` action that calls the framework upload mutation and polls file status until it leaves `"uploading"` (`pkgs/akanjs/store/action.ts`).
963
- - Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`, `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` module. Do not hand-roll data-URL fallbacks.
964
-
965
- ### Cascade Remove — the `cascade` option
966
-
967
- Full contract — bulk planning, the auto-created index, cycle cutting, boot-time validation: `get_guideline` with
968
- `queryRule`.
969
-
970
- **The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
971
- shape, so `cascade` never means "related" — it means one of exactly these:
972
-
973
- - `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
974
- `image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it.
975
- - `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
976
- its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
977
- forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
978
- `refPath`, and a field naming no owner each fail the class build.
979
- - **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time. The one
980
- exception is opt-in and priced: `cascade: "removeWithAny"` takes a free-form `String` type field holding the
981
- owner's refName, and sweeps for children on **every** model's removal. The sweep is one indexed probe, but a
982
- single wildcard edge turns every cascade in the app back to one document at a time; the boot log names the
983
- edges in one `info` line. The action carries its own `refPath` in the option type, so the widening cannot be
984
- declared apart from the direction it widens.
985
- - **A cascade goes through the target's service, never its model** — that path is what runs the target's
986
- `_postRemove`, where a module puts the side effect the removal has to carry.
987
- - **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
988
- `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
989
- - Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
990
- restorable, and reviving the owner does not revive what went with it.
991
- - **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
992
- generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
993
- at a time when it cascades.
436
+ - Models use `via` from `akanjs/constant`, all five layers (`Input`, `Object`, `Light<Model>`, `<Model>`,
437
+ `<Model>Insight`). `Int` for counts, `Float` only for decimals, `ID` for references; structured fields over `Any`.
438
+ - Import `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`, `ImageResult`, `enumOf`, `dayjs` from `akanjs/base`;
439
+ `String` / `Boolean` / `Date` are globals. No `JSON` scalar (use `Any`), no `Number`, and `Binary` / `Upload` are
440
+ never fields — a file is `field(File).optional()` / `field([File])`, uploaded by the generated
441
+ `upload<Field>On<Model>`.
442
+ - Defaults: a literal for scalars, a thunk for anything constructed (`default: () => dayjs()`). Arrays
443
+ `field([T])`; optional `.optional()`.
444
+ - **`field.hidden` / `field.secret` read `null` on the client while the type declares them** — guard with `??` or
445
+ `== null`, never `=== undefined` or a default. `field.visual(T)` is rendered but stripped from every agent and MCP
446
+ read. Full contract: `fieldRule`.
994
447
 
995
448
  ## Akan Page Routing (`apps/**/page/**`)
996
449
 
997
- - `apps/<app>/page` may contain route modules only. Do not add helper logic or component-only files there.
998
- - Route source files under `page/` must use `.tsx`. Do not add `logic.ts`, `.js`, or `.jsx` files under `page/`.
999
- - A route page is either `<routeName>.tsx` (serving `/routeName`) or a directory's `_index.tsx`; layouts use `_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
1000
- - Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add files like `_Component.tsx` or `_helper.tsx`.
1001
- - Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`, `common`, or `lib` instead.
1002
- - Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
1003
- - **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
1004
- `_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
1005
- is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` /
1006
- `.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` /
1007
- `.layoutStyle()` / `.viewport()` on the root layout; `.prompt()` on a page. A named export beside the chain, or
1008
- `page()` in a `_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function
1009
- of the route's args returning it, and one that throws renders no head (warned about) so a layout's sign-in redirect
1010
- still answers; there is no metadata object, and analytics is the app's own script, not a stage.
1011
- Heads merge root first: a deeper `.head()` replaces the `<title>`, a `<meta>` with the same `name` / `property`,
1012
- and a canonical / manifest / icon link, and keeps the rest — so a page states only what it changes. A canonical
1013
- link and `og:url` each name one URL, so a page carries them only from its own `.head()`, never from a layout's.
1014
- - **A CSR bundle (a native app, `?csr=true`) never shows a root layout's own markup.** Pages render into the
1015
- frame, and what the root layout draws around `{children}` stays in a hidden container, so its wrappers cannot
1016
- stack a second screen beside the frame. Its components still mount and their effects run (`<Auth.User />` works).
1017
- Anything a person must see from a root layout — an overlay, a gate, a banner — renders through
1018
- `createPortal(…, document.body)`, as `<Agent.Chat />` and the dialogs already do. A non-root `_layout.tsx` has no
1019
- such limit.
1020
- - **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
1021
- `.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
1022
- Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
1023
- search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
1024
- them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`. `lang` is
1025
- never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`. On a web
1026
- page's first render `st.use.searchParams()` is still the server's empty snapshot, synced right after hydration, so
1027
- an effect that needs the query on its first run takes the page's `.search()` value as a prop.
1028
- - `_overrides.tsx` is a logic-free UI-override manifest: imports plus a single `export default override({ Slot: AppComponent })` (from `akanjs/ui`), no `"use client"`. It re-skins framework `akanjs/ui` components for its route subtree; nested manifests merge over ancestors slot-by-slot (closest wins). See the UI Customization reference for the slot list.
1029
- - The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
1030
- loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
1031
- `page/` to the route chain"; new files are never written in it.
1032
- - `libs/<lib>/page` follows the same rules and ships routes to apps that opt in with `syncPageLibs` in `akan.config.ts`: `true` takes every lib dep that has a `page` folder, an array takes the libs listed, `false` (the default) syncs nothing.
1033
- - `akan sync` links those routes into `apps/<app>/page/(libs)/(<lib>)` — once per basePath when the app declares subRoutes. The folder is generated and gitignored; edit the lib source, never the link.
1034
- - Both path segments are route groups, so a lib route mounts at its own path (`libs/<lib>/page/login/_index.tsx` serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
1035
- - `.config({ devOnly: true })` keeps a route out of `akan build` while it keeps serving under `akan start` and keeps being typechecked. On a `_layout.tsx` it excludes every route under that directory too. Write it as a literal `true`/`false` — the build reads it off the source without evaluating the module.
1036
- - Before changing route behavior, check `pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
1037
-
1038
- ### Page Body Shape
450
+ Full contract — heads, CSR root layouts, typed params, lib pages, `devOnly`: `pageRule`.
451
+
452
+ - `page/` holds `.tsx` route modules only: `<routeName>.tsx` or `_index.tsx`, `_layout.tsx`, `_overrides.tsx` — no
453
+ other `_*.tsx`, helpers, `.ts` / `.js` files, or uppercase filenames. `[id]` is a dynamic segment, `(group)` a
454
+ route group.
455
+ - **A route file exports one chain** — `page()`, `layout()` in a `_layout.tsx`, `rootLayout()` in a root
456
+ `_layout.tsx` — and every setting is a stage (`.param`, `.search`, `.config`, `.head`, `.loading`; `.notFound` /
457
+ `.error` on layouts; `.fonts` / `.theme` / `.manifest` / … on the root; `.prompt` on a page). A named export
458
+ beside it fails the build.
459
+ - **Name every `[x]` segment with `.param("x", Type)`** and read query keys only via `.search("k", Type)`; values
460
+ arrive typed. `lang` is never declared — every stage receives it.
461
+ - `.head()` takes JSX or a function returning it — never a metadata object; deeper heads replace by tag identity,
462
+ and a canonical link / `og:url` comes only from the page's own `.head()`.
463
+ - In a CSR bundle a root layout's own markup is hidden: what a person must see from it renders through
464
+ `createPortal(…, document.body)`.
465
+ - `_overrides.tsx` is one `export default override({ Slot: AppComponent })` (`akanjs/ui`), no logic, no
466
+ `"use client"`. The legacy `export default async function Page` shape is never written anew.
467
+ - `libs/<lib>/page` reaches an app through `syncPageLibs`, linked into the generated `page/(libs)/(<lib>)`; edit the
468
+ lib source, never the link. `.config({ devOnly: true })` (a literal) keeps a route out of `akan build`.
1039
469
 
1040
470
  ```tsx
1041
471
  export default page()
@@ -1049,149 +479,85 @@ export default page()
1049
479
  });
1050
480
  ```
1051
481
 
1052
- - There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
1053
- `.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
1054
- - Body order inside `.render()`: `usePage()`, auth, fetch, return.
1055
- - Hand each `fetch.init*` / `view*` / `edit*` field to its section unawaited. Await only what the shell itself
1056
- reads — a title, a redirect, an id for a link — and run those awaits through one `Promise.all`.
1057
- - Gate auth at `_layout.tsx`; repeating `getSelf({ unauthorize: "/signin" })` in the page is fine and common.
1058
- - `async` on the render callback only when the body awaits; the callback is not a React component, so it takes no
1059
- name and no return type.
1060
- - Publish a screen to agents with `.prompt(name, description)` — see MCP Exposure.
1061
- - No `useState`, no `useEffect`, and no comments in page files.
482
+ - `.render()` gets the declared args flat and typed (no `PageProps`, no `loader=` prop). Order: `usePage()`, auth,
483
+ fetch, return. Hand `fetch.init*` / `view*` / `edit*` fields to sections unawaited; await only what the shell reads
484
+ (title, redirect, link id) in one `Promise.all`.
485
+ - Gate auth at `_layout.tsx` (repeating `getSelf` in the page is fine). `async` only when the body awaits; the
486
+ callback is not a React component, so it takes no name and no return type. No `useState`, `useEffect`, or
487
+ comments in page files.
1062
488
 
1063
489
  ## Akan Sync Conventions (`apps/**`, `libs/**`)
1064
490
 
1065
- - `apps/<appName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `client.ts`, `main.ts`, `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`.
1066
- - `apps/<appName>` root may only contain these folders: `.akan`, `common`, `env`, `lib`, `native`, `page`, `plugin`, `private`, `public`, `script`, `secrets`, `srvkit`, `ui`, `webkit`.
1067
- - `libs/<libName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `README.md`, `akan.config.ts`, `akan.lib.json`, `client.ts`, `index.ts`, `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.spec.json`, `tsconfig.tsbuildinfo`.
1068
- - `libs/<libName>` root may only contain these folders: `common`, `env`, `lib`, `native`, `page`, `plugin`, `private`, `public`, `srvkit`, `ui`, `webkit`. A library is never booted or packaged as an app, so the run and mobile entries an app carries (`main.ts`, `.akan`, `script`, `secrets`) are rejected there. A Capacitor-era `ios` / `android` / `mobile` folder or `capacitor.config.*` is refused in an app root too, named as a leftover: the native runtime generates its projects under `dist/native/<app>/<target>`.
1069
- - Both allowlists have one source — `pkgs/@akanjs/devkit/workspaceLayout.ts`. `akan sync` (error), `akan doctor`
1070
- (diagnostic), and `akan quality scan` (warning) all read it, so add a new root entry there and mirror it into this
1071
- list, never into one of the three call sites.
1072
- - `akan sync` maintains a scoped agent guide per app/lib: `apps/<app>/AGENTS.md` / `libs/<lib>/AGENTS.md`. The
1073
- section between the `akan:agent` markers (the `## Recipes In Scope` index) is generated — do not hand-edit it;
1074
- content outside the markers is yours. `akan lint` fails when the generated section is stale.
1075
- - The `plugin/` facet holds Akan plugin declarations; files use the `<name>.plugin.ts` convention (e.g. `pushNotification.plugin.ts`) and are re-exported from the generated `plugin/index.ts` barrel.
1076
- - Do not add `apps/*/base` or `libs/*/base`; place shared utilities under that app or lib's own `common/`.
1077
- - `apps/*/lib` and `libs/*/lib` root files are limited to generated/support files: `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`, `useServer.ts`. Nothing hand-written sits beside them, tests included: a test goes in the module folder it covers (`lib/<model>/<model>.signal.test.ts`), or in `srvkit/` when it drives several modules.
1078
- - Domain module folders are `lib/<model>` for database modules, `lib/_<service>` for service modules, and `lib/__scalar/<scalar>` for scalar modules.
1079
- - Database module UI files are limited to `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.Util.tsx`, `<Model>.View.tsx`, and `<Model>.Zone.tsx`.
1080
- - Service module UI files are limited to `<Service>.Util.tsx` and `<Service>.Zone.tsx`.
1081
- - Scalar module UI files are limited to `<Scalar>.Template.tsx` and `<Scalar>.Unit.tsx`.
1082
- - A module test is named after the one module file it covers — `<model>.<role>.test.ts` / `.spec.ts`, `<Model>.<Role>.test.tsx` — so `<model>.<topic>.signal.test.ts` is refused, just like `<model>.<topic>.signal.ts`.
1083
- - `ui/index.ts`, `webkit/index.ts`, `srvkit/index.ts`, `common/index.ts`, `plugin/index.ts`, and module `lib/**/index.ts` files are generated by scanSync; do not hand-edit or track them.
1084
- - Generated facet indexes export only 1-depth files/folders with `export * from "./name";`. A facet folder with
1085
- nothing to export syncs to `export {};` (a missing folder gets no barrel), so a stale barrel heals on the next
1086
- sync; `akan create-library` creates `common/`, `srvkit/`, `webkit/` and `ui/` holding only that empty barrel.
1087
- - **Never hand-write `index.tsx` (or `index.jsx` / `index.mts`) at the root of `ui/`, `webkit/`, `srvkit/`,
1088
- `common/` or `plugin/`.** Bun resolves it ahead of the generated `index.ts` while TypeScript resolves `index.ts`
1089
- first, so an export the generated barrel adds typechecks and is `undefined` at runtime. `akan sync` refuses it and
1090
- `akan doctor` reports `facet-index-shadowed`; a hand-written namespace belongs in `ui/<Folder>/index.tsx`.
1091
- - Sync writes generated barrels, module indexes and `akan.*.json` exactly as Biome prints them, so
1092
- `akan lint --no-fix` passes right after a sync. A lint diff in one of them is a generator bug: fix the generator.
1093
- - **`akan sync` owns the `dependencies` of an app's or lib's package.json.** It adds what the code imports at the
1094
- root's version, realigns every root-declared entry to the root's version, and removes a root-declared entry
1095
- nothing imports (logged). An entry the root does not declare is kept, with a warning. A package no TS file imports
1096
- (a CSS `@plugin`, a runtime-only package) is kept by listing it in `package.json` under
1097
- `akan.keepDependencies`; `externalLibs` and `trustedDependencies` are always kept. devDependencies are realigned,
1098
- never pruned. `akan install-library` lets the root win: it adds only what the root lacks and warns when the lib
1099
- wants a newer version.
1100
- - `libs/<libName>` may hold a `page` folder of route modules; scanSync links it into every app that opts in with `syncPageLibs`, so `apps/*/page/**/(libs)` is generated and gitignored like `public/libs`.
491
+ - App root files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `client.ts`, `main.ts`,
492
+ `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`; folders: `.akan`, `common`, `env`, `lib`,
493
+ `native`, `page`, `plugin`, `private`, `public`, `script`, `secrets`, `srvkit`, `ui`, `webkit`. A Capacitor-era
494
+ `ios` / `android` / `mobile` folder or `capacitor.config.*` in an app root is refused as a leftover (native
495
+ projects live under `dist/native/<app>/<target>`).
496
+ - Lib root files: `AGENTS.md`, `CLAUDE.md`, `README.md`, `akan.config.ts`, `akan.lib.json`, `client.ts`, `index.ts`,
497
+ `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.spec.json`, `tsconfig.tsbuildinfo`; folders: the app's
498
+ minus `.akan`, `script`, `secrets` (no `main.ts` either — a lib is never booted).
499
+ - The allowlists' one source is `pkgs/@akanjs/devkit/workspaceLayout.ts`, read by `akan sync` (error), `akan doctor`
500
+ and `akan quality scan` — add an entry there and mirror it here.
501
+ - `apps/<app>/AGENTS.md` / `libs/<lib>/AGENTS.md` carry a generated `## Recipes In Scope` block between the
502
+ `akan:agent` markers — never hand-edit it (outside the markers is yours); `akan lint` fails when it is stale.
503
+ - `plugin/<name>.plugin.ts` declares an Akan plugin. No `apps/*/base` or `libs/*/base` — use `common/`.
504
+ - `lib/` root holds only `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`,
505
+ `useServer.ts` — no hand-written file or test.
506
+ - Modules are `lib/<model>`, `lib/_<service>`, `lib/__scalar/<scalar>`; their UI files are
507
+ `<Model>.Template|Unit|Util|View|Zone.tsx`, `<Service>.Util|Zone.tsx`, `<Scalar>.Template|Unit.tsx`. A test is
508
+ named after the one file it covers, so `<model>.<topic>.signal.test.ts` is refused like `<model>.<topic>.signal.ts`.
509
+ - `ui/`, `webkit/`, `srvkit/`, `common/`, `plugin/` and module `index.ts` barrels are generated
510
+ (`export * from "./name";` per 1-depth entry, `export {};` when empty, so a stale one heals on sync;
511
+ `akan create-library` starts `common/`, `srvkit/`, `webkit/`, `ui/` with that empty barrel) — never hand-edit or
512
+ track them. **Never hand-write an
513
+ `index.tsx` / `.jsx` / `.mts` at those roots**: Bun resolves it before the generated `index.ts`, so new exports are
514
+ `undefined` at runtime (`akan sync` refuses it, `akan doctor` reports `facet-index-shadowed`); a namespace belongs
515
+ in `ui/<Folder>/index.tsx`.
516
+ - Sync writes barrels, module indexes and `akan.*.json` exactly as Biome prints them (`akan lint --no-fix` passes
517
+ right after a sync), so a lint diff in one is a generator bug — fix the generator.
518
+ - **`akan sync` owns package.json `dependencies`**: it adds imports at the root's version, realigns root-declared
519
+ entries, and drops unimported ones (logged); undeclared-at-root entries are kept with a warning. Keep a package no
520
+ TS file imports (a CSS `@plugin`, a runtime-only package) with `akan.keepDependencies`; `externalLibs` and
521
+ `trustedDependencies` always stay; devDependencies are realigned, never pruned. `akan install-library` lets the
522
+ root win — it adds only what the root lacks and warns when the lib wants a newer version.
1101
523
 
1102
524
  ## Layer Placement (`common/`, `webkit/`, `srvkit/`, `ui/`)
1103
525
 
1104
526
  | Folder | Admission test | Naming |
1105
527
  |---|---|---|
1106
- | `common/` | pure, isomorphic, zero-dependency; may import only sibling `common/*` and `akanjs/base`. Cannot import `Err`, so keep throwing code out of it. | camelCase file, filename equals the single export |
1107
- | `webkit/` | touches `window` / `navigator` / the native bridge (`akanjs/client/native`, or `akanjs/client/native/<plugin id>` for any builtin plugin), or is a React hook | `use<Thing>.tsx` — `.tsx` even with no JSX |
528
+ | `common/` | pure, isomorphic, zero-dependency; imports only sibling `common/*` and `akanjs/base` (so no `Err`, and no throwing code) | camelCase file named after its single export |
529
+ | `webkit/` | touches `window` / `navigator` / the native bridge (`akanjs/client/native[/<plugin id>]`), or is a React hook | `use<Thing>.tsx`, `.tsx` even without JSX |
1108
530
  | `srvkit/` | touches `node:*`, `Bun`, `process.env`, a secret, or a server SDK | camelCase file, PascalCase class |
1109
- | `ui/` | renders JSX, or defines a look (a recipe in `Recipe/`, a lib's `tokens.css`), and is not bound to one model | PascalCase component, camelCase sidecar (`swipeCard.util.ts`), `Recipe/<name>.ts` |
1110
- | `plugin/` | build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
1111
- | `native/` | a native plugin the app or lib owns: `native-plugin.json` beside its page API (`src/index.ts`, `definePlugin` from `akanjs/client/native`), its desktop part (`src/desktop.ts`, `defineDesktopPlugin` from `akanjs/native/desktop`), Kotlin and Swift. Every mobile and desktop target ships it; a lib's reach only the apps that depend on it, and the app's own wins an id | `native/<id>/`, the folder named after the plugin id |
531
+ | `ui/` | renders JSX or defines a look (`Recipe/`, a lib's `tokens.css`), not bound to one model | PascalCase component, camelCase sidecar, `Recipe/<name>.ts` |
532
+ | `plugin/` | a build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
533
+ | `native/` | a native plugin the app or lib owns — its page API reached only through a `webkit/` hook (`runtimeRule`) | `native/<id>/` |
1112
534
 
1113
535
  - Hooks return a named object of async closures, never a tuple.
1114
- - A `native/` plugin's page API is imported by a `webkit/` hook (`../native/<id>/src`) and nothing else; pages and
1115
- components call the hook, the same way they reach `akanjs/client/native`.
1116
- - `ui/` is the presentation layer — markup and the looks it is built from — so a recipe lives there although it is
1117
- neither a component nor a hook. Not `webkit/`: its hooks are `"use client"`, the opposite signal for a function
1118
- server components call. Not `common/`: it cannot import `akanjs/ui`, where `recipe` / `tv` come from.
1119
- - `libs/<lib>/ui/tokens.css` is the one CSS file a lib owns: plain `:root` custom properties for colors that must **not** follow the theme (a vendor brand color, a fixed surface). Every app whose pages reach that lib compiles it automatically, ahead of the app's own stylesheets, so nothing is imported by hand and no app can forget it. Reference them as `bg-[var(--kakao)]`; `@theme` extensions stay in the app stylesheet, because the color vocabulary is closed per stylesheet. Theme-following colors are the app's, not the lib's.
1120
- - A layer-root `index.ts` is generated, but a `ui/<Folder>/index.tsx` that builds a namespace is hand-written source. The distinguishing test is that a generated barrel contains nothing but `export * from "./X";` lines.
1121
- - `ui/<Folder>/index_.tsx` (trailing underscore) is the `"use client"` + `lazy()` boundary, with a server-safe `index.tsx` beside it. Collapsing the pair into one file breaks RSC.
536
+ - Recipes live in `ui/`: `webkit/` hooks are `"use client"`, and `common/` cannot import `akanjs/ui`.
537
+ - A layer-root `index.ts` is generated; a `ui/<Folder>/index.tsx` building a namespace is hand-written (a generated
538
+ barrel holds only `export *` lines). `ui/<Folder>/index_.tsx` is the `"use client"` + `lazy()` boundary beside a
539
+ server-safe `index.tsx` — merging the pair breaks RSC.
1122
540
 
1123
541
  ## Present In The Code — Do Not Imitate
1124
542
 
1125
- Older files contain these. They are warts, not conventions: do not copy them forward, and prefer the newer neighbour
1126
- when two shapes disagree.
1127
-
1128
- - `children: any` — newer files use `children: ReactNode`.
1129
- - Hard-coded API keys or secrets in source; they belong in `option.ts` or env.
1130
- - Large blocks of commented-out code left in place.
1131
- - Stale `// TODO: Implement …` comments above implemented methods.
1132
- - `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
1133
- - Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
1134
- - Raw palette grays such as `text-gray-400` instead of the semantic tokens (`text-foreground/70`).
1135
-
1136
- A store calling `(this as unknown as RootStore).<action>()` or `.set({ … })` over
1137
- `import type { RootStore } from "../st"` is **not** one of these — it is the sanctioned way to reach another store's
1138
- actions and state (see `<model>.store.ts` above).
543
+ Warts in older files — never copy them; when two shapes disagree, follow the newer neighbour: `children: any`
544
+ (use `ReactNode`); hard-coded API keys or secrets (they belong in `option.ts` or env); large commented-out blocks;
545
+ stale `// TODO: Implement …` above implemented methods; `{cond && <X/>}`, hard-coded Korean bypassing `l()`,
546
+ `window.alert(...)`; bare `/* eslint-disable */` blocks; raw palette grays like `text-gray-400`. A store reaching
547
+ another through `(this as unknown as RootStore)` is **not** a wart — it is the sanctioned way.
1139
548
 
1140
549
  ## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
1141
550
 
1142
- - Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env` contents.
1143
- - If env keys are needed for documentation, list only key names and example placeholders, not live values.
1144
- - Preserve the existing env/secret flow through root scripts such as `bun run downloadEnv`, `bun run uploadEnv`, `bun run downloadSecret`, and `bun run uploadSecret`.
1145
- - When editing infra env or secret scripts, keep Jenkins and deployment assumptions intact unless the task explicitly asks to change them.
551
+ - Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env`
552
+ contents; document env keys by name with placeholders only.
553
+ - Preserve the env/secret flow through `bun run downloadEnv`, `uploadEnv`, `downloadSecret`, `uploadSecret`, and
554
+ keep Jenkins and deployment assumptions intact unless the task asks otherwise.
1146
555
  - Treat generated env/secret artifacts as sensitive even when they are not named `.env`.
1147
556
 
1148
557
  ## Application Test Commands
1149
558
 
1150
- - After changing application source code, test the app with `bun run akan start <appName>`.
1151
- - **`akan start` takes several apps** — `akan start a,b`, `akan start a b`, `akan start all`, or no argument at
1152
- all for a checkbox that remembers the last pick.
1153
- - **It opens a full-screen view by default, at any app count.** A rail of apps with the processes inside each
1154
- one — `host` for the dev host, gateway and RSC worker, plus a row per replica the gateway forwards — and the
1155
- selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each
1156
- app's digit), `↑↓` scroll (shift for a page, `G` back to following),
1157
- `/` greps, `e` shows stderr only, `c` clears, `y` copies, `Y` copies the log path, `o` opens the browser,
1158
- `r` restarts that app, `q` quits. Under `--share`, `s` copies the public URL — the selected app's, or every
1159
- app's from the merged row — and the header carries it ahead of the local one, because the line `--share`
1160
- printed before the view took the terminal is gone by its first repaint.
1161
- **`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized terminal
1162
- downgrades to that on its own.
1163
- - **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
1164
- Ink repaints one frame in place, so the full-screen view leaves a bordered screenshot in the scrollback and
1165
- takes the rest of the session with it — a drag-selection is cleared by the next repaint, and what survives
1166
- carries borders and lines truncated to the pane. The file is the same output with no ANSI, nothing truncated,
1167
- and every process of the app in arrival order, including the dev host's own build output, which reaches no
1168
- other file. The previous session is kept beside it as `dev.prev.log`. Hand somebody — or an agent — the
1169
- path rather than a paste: it costs one line instead of a transcript, and it can be re-read after a fix.
1170
- In the view, `y` copies the lines the filters have already narrowed and `Y` copies the path.
1171
- - **How many apps boot at once is the machine's answer, not a constant.** A cold boot is an app's RSS peak
1172
- (~1.8GB per app, most of it the build worker — the base build, then the SSR registry's), so the default wave
1173
- is what memory and cores allow — half the memory budget divided by that peak, and one app per four cores —
1174
- which boots a laptop's apps together and still staggers them inside a small container. The session says
1175
- which in one line. The next wave starts once an app serves and its boot builds have settled, or 30 seconds
1176
- after it serves, while the app shows as ready — and `--open` opens it — as soon as it serves.
1177
- `--concurrency <n>` overrides the wave size, and `AKAN_MEMORY_LIMIT` lowers the budget it derives from.
1178
- - `--kill` frees the dev ports first — it resolves each port's listener, walks up to the top of that akan dev
1179
- tree and signals it, so another checkout's server or a stale orphan on the same port is reclaimed. A holder
1180
- that is not recognisably an akan process is reported and left alone.
1181
- - **A dev server watches its own app directory and the libs it actually depends on**, transitively, resolved
1182
- once at boot from the synced manifests — so a save under a lib the app never imports rebuilds and reloads
1183
- nothing, and neither does a save in a sibling app. An import that makes a new lib a dependency needs a
1184
- fresh `akan start`, because it needs a `sync` first anyway. Before the first `sync` the manifests do not
1185
- exist yet and the whole `libs/` container is watched, which is the safe direction.
1186
- - Test production build generation with `bun run akan build <appName>`.
1187
- - To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
1188
- - A server a test boots in-process takes `start({ listen: false, schedules: false })` to run no internal (init,
1189
- interval, cron, queue worker); `listen: false` alone still runs them, as a batch replica does. A server process a
1190
- test spawns from source resolves the workspace from its cwd and gets the root `.env` from Bun, so spawn it inside
1191
- the repo with `bun --no-env-file`, or spawn the built `dist/apps/<app>` output, which needs neither.
1192
-
1193
- ```bash
1194
- cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
1195
- AKAN_PUBLIC_APP_NAME=<appName> AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation \
1196
- AKAN_PUBLIC_BASE_PATHS=<basePaths> bun main.js
1197
- ```
559
+ - After changing application code, run `bun run akan start <appName>` (`a,b`, `a b`, or `all` for several); check
560
+ the production build with `bun run akan build <appName>`.
561
+ - A supervised session writes `local/apps/<app>/runtime/dev.log` (previous: `dev.prev.log`) — hand over that path,
562
+ not a paste of the repainting full-screen view. Keys, `--plain`, boot waves, `--kill`, watch scope, running a
563
+ built artifact, booting a server in a test: `runtimeRule`.