@akanjs/cli 3.0.0-alpha.70 → 3.0.0-alpha.71

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 (55) hide show
  1. package/.build-stamp +1 -1
  2. package/{agent.command-964bf8qg.js → agent.command-xdvw642m.js} +5 -5
  3. package/{application.command-n45n18sc.js → application.command-g3f7xj25.js} +27 -4
  4. package/buildBatch.proc.js +1 -1
  5. package/{capacitorApp-vnvnvdhf.js → capacitorApp-b0ppke3m.js} +2 -2
  6. package/{cloud.command-kx1bh5yq.js → cloud.command-0hmsv44q.js} +7 -7
  7. package/commandManifest.json +1 -1
  8. package/{context.command-g1a65x6j.js → context.command-x92afrnf.js} +12 -12
  9. package/{guideline.command-xphyhvwh.js → guideline.command-hpq08q66.js} +2 -2
  10. package/guidelines/conventions/conventions.instruction.md +255 -436
  11. package/guidelines/framework/framework.generate.json +3 -2
  12. package/guidelines/framework/framework.instruction.md +0 -9
  13. package/guidelines/mcpRule/mcpRule.instruction.md +5 -8
  14. package/guidelines/queryRule/queryRule.generate.json +31 -0
  15. package/guidelines/queryRule/queryRule.instruction.md +143 -0
  16. package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
  17. package/guidelines/runtimeRule/runtimeRule.instruction.md +176 -0
  18. package/guidelines/transportRule/transportRule.generate.json +31 -0
  19. package/guidelines/transportRule/transportRule.instruction.md +60 -0
  20. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +17 -392
  21. package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
  22. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +402 -0
  23. package/incrementalBuilder.proc.js +1 -1
  24. package/{index-nkwmnqwn.js → index-5f4gnt4c.js} +1 -0
  25. package/{index-va15r8h5.js → index-6d5r8q6k.js} +2 -2
  26. package/{index-4rw4qhe6.js → index-737aj4fm.js} +3 -3
  27. package/{index-ytaqtc04.js → index-8besc14m.js} +1 -1
  28. package/{index-hee7egtm.js → index-cbfp5wyp.js} +1 -1
  29. package/{index-mkn5qagt.js → index-cp00yns2.js} +70 -4
  30. package/{index-nj3w0r34.js → index-ety6takb.js} +3 -3
  31. package/{index-e31ghfp5.js → index-gvq17wqn.js} +1 -1
  32. package/{index-8gybxemv.js → index-hs70d8ed.js} +5 -5
  33. package/{index-0ngvvgry.js → index-jybbmwed.js} +2 -2
  34. package/{index-bqqts262.js → index-m5gfc86a.js} +1 -1
  35. package/{index-qt4nfr0n.js → index-nq3avsf6.js} +10 -10
  36. package/{index-yg3hxk2k.js → index-p27h7535.js} +4 -4
  37. package/{index-fahkq20v.js → index-pehn44hr.js} +3 -3
  38. package/{index-v5fdxdre.js → index-r691v756.js} +2 -2
  39. package/{index-p681w089.js → index-sqsfv8wg.js} +2 -2
  40. package/{index-rbnpe8cp.js → index-wbbswwqt.js} +17 -21
  41. package/{index-rb65xnw9.js → index-wybbcgxn.js} +4 -4
  42. package/index.js +18 -18
  43. package/{library.command-rfpd27yv.js → library.command-8vgrdtwy.js} +3 -3
  44. package/{localRegistry.command-pyxfe0fs.js → localRegistry.command-0gtqj0r0.js} +6 -6
  45. package/{module.command-qhaxad0p.js → module.command-rama4zya.js} +5 -5
  46. package/{package.command-2k22q9nt.js → package.command-6jxapdsg.js} +3 -3
  47. package/package.json +2 -2
  48. package/{page.command-ymk9dqh4.js → page.command-hxy7x525.js} +3 -3
  49. package/{primitive.command-9k45zpc1.js → primitive.command-4bf6xcc3.js} +6 -6
  50. package/{quality.command-bf6vkxe5.js → quality.command-h5edwegz.js} +2 -2
  51. package/{repair.command-egvvm6xf.js → repair.command-rgqf6px6.js} +5 -5
  52. package/{scalar.command-bt1facyj.js → scalar.command-6krkz6tw.js} +4 -4
  53. package/templates/workspaceRoot/AGENTS.md.template +4 -0
  54. package/{workflow.command-v1n3wg7v.js → workflow.command-dwhwrqhd.js} +9 -9
  55. package/{workspace.command-yvvqvhzz.js → workspace.command-f5h11gjn.js} +18 -18
@@ -19,6 +19,25 @@
19
19
  - Prefer established nearby patterns over introducing a new abstraction.
20
20
  - Add new abstractions only when they remove real duplication or match an existing project pattern.
21
21
 
22
+ ## Deep-Dive Guidelines
23
+
24
+ This file carries the rules. The reasoning, the measurements, and the per-feature edge cases live in guidelines
25
+ you fetch on demand — `get_guideline` with the name, or `akan guideline show <name>`. Fetch the relevant one
26
+ **before** a deep pass on that area; a section below that points at one is a summary, not the whole contract.
27
+
28
+ | name | covers |
29
+ |---|---|
30
+ | `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, shipped assets |
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 |
40
+
22
41
  ## Lint-Enforced Rules (These Break The Build)
23
42
 
24
43
  Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — that file scopes every grit plugin in
@@ -26,56 +45,54 @@ Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — tha
26
45
  back.
27
46
 
28
47
  - **Never hand-order Tailwind classes.** `nursery/useSortedClasses` is an error and also sorts the string
29
- arguments to `cn()`. Sorter output such as `font-bold text-2xl text-foreground` or
30
- `border-foreground/5 border-t` is correct. Write the classes in any order, run the formatter, leave the result.
31
- - **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as
32
- no CSS and fail lint (`no-raw-palette-class.grit`, `no-arbitrary-color.grit`, `no-daisyui-legacy-class.grit`, `no-inline-color.grit`,
33
- `no-interpolated-arbitrary-class.grit`): raw palette classes (`bg-blue-500`), arbitrary color values
34
- (`bg-[#3b82f6]`), an arbitrary class built by interpolation, daisyUI legacy classes (`btn-primary`, `card-body`)
35
- and its dropped color slots (`bg-base-100`, `text-base-content`, `text-primary-content`, `bg-error` — use
36
- `background`/`muted`/`border`, `foreground`, `<color>-foreground`, `destructive`), and color literals in
37
- `style={{...}}`. Use semantic tokens (`bg-primary`, `text-foreground/70`). A legitimate
38
- fixed color (OS-chrome mockups, data-viz) takes a `// biome-ignore lint/plugin: <reason>` with the reason
39
- spelled out.
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`)
40
58
  - **Never `throw new Error`.** Throw `new Err("<module>.error.<key>")` and register the key as `[en, ko]` in that
41
59
  module's dictionary `.error({})`. Import `Err` from `"../dict"` on the server and from `"@libs/<lib>/client"` or
42
- `"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts tests, `*.constant.ts`, `common/**`, and
43
- `env/**` — `common/` and `env/` have no legal `Err` import path, so keep throwing code out of them.
60
+ `"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts tests, `*.constant.ts`, `common/**`, and `env/**`
61
+ — the last two have no legal `Err` import path, so keep throwing code out of them.
44
62
  - **Never import a third-party package** from `page/**`, from any barrel, or from any
45
63
  `*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
46
64
  (`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
47
- `common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft. Do not delete
48
- them.
65
+ `common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft.
49
66
  - **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
50
67
  `*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
51
68
  `#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
52
69
  - **No `console.log` / `console.debug`.** Only `assert`, `error`, `info`, and `warn` are allowed. Server code uses
53
70
  the injected `this.logger.*` or `new Logger("ClassName")`.
71
+ - **Never call `logger.log()` / `Logger.log()`** (`no-deprecated-log-level.grit`). The ladder is `trace verbose
72
+ debug info warn error`; `log` was a seventh tier *below* `info` that `AKAN_PUBLIC_LOG_LEVEL=info` silently
73
+ dropped. The method is kept and emits at `info`, so a call reads like a distinct level and is not one — write
74
+ `.info()`.
54
75
  - **Never write a `//!` marker in browser-reachable code** — `ui/`, `webkit/`, `common/`, `page/**/*.tsx`,
55
76
  `*.constant.ts`, `*.store.ts`, and the five module component suffixes (`no-bang-comment-in-client.grit`). Bun
56
77
  classifies `//!` and `/*!` as legal comments and keeps them through minification, so the note ships to every
57
78
  visitor. Use `// FIXME:` there; `//!` stays legal in server, `srvkit/`, and CLI files.
58
79
  - **Never return a value from a store action** (`no-return-in-store-action.grit`). Every method of a `store(...)`
59
- class dispatches through `st.do.<action>()`, which is typed `void` / `Promise<void>`, so the value is
60
- unreachable — write it into state with `this.set({ ... })`. A bare `return;` guard, a `return` inside a nested
61
- callback, a getter, and a `static` helper are all still fine.
80
+ class dispatches through `st.do.<action>()`, typed `void` / `Promise<void>`, so the value is unreachable — write
81
+ it into state with `this.set({ ... })`. A bare `return;` guard, a `return` inside a nested callback, a getter,
82
+ and a `static` helper are all still fine.
62
83
  - **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
63
84
  - **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
64
- roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server has
65
- to hand across the boundary; take `bannerId: string` and read the model from the store instead. **Only prop
66
- positions are read** — a `*Props` interface or type alias, and the inline object type on the component's own
67
- parameter — and only shapes that are actually an instance are flagged. Exempt: an indexed enum access
68
- (`cnst.<Enum>["value"]`, a string union), a `ClientInit` / `ClientView` / `ClientEdit` type argument (plain
69
- `GetStateObject<…>` data), a `ModelsProps<…>` type argument, a function-typed prop, and any `cnst` type that
70
- never leaves the file. `ModelProps<"setting", cnst.LightSetting>` and any other indexed access
71
- (`cnst.Banner["image"]` is a `File`) stay flagged.
85
+ roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server
86
+ has to hand across the boundary; take `bannerId: string` and read the model from the store instead. Only prop
87
+ positions are read, and only shapes that are actually an instance. Exempt: an indexed enum access
88
+ (`cnst.<Enum>["value"]`), a `ClientInit` / `ClientView` / `ClientEdit` or `ModelsProps<…>` type argument, a
89
+ function-typed prop, and any `cnst` type that never leaves the file. Other indexed accesses
90
+ (`ModelProps<"setting", cnst.LightSetting>`, `cnst.Banner["image"]`) stay flagged.
72
91
  - **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
73
92
  `onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}`, but
74
93
  the arrow is a fresh anonymous closure, so the control emits no `data-akan-action` and publishes no agent tool
75
94
  for the field — a silent failure in two lines that read the same. A wrapper that transforms the value, adds a
76
95
  statement, or writes a nested path with `writeOnX` stays legal; publish that one with an explicit `st.tool`.
77
- Scoped to `{apps,libs}/**/*.tsx`, and a typed parameter is not matched, so it under-reports rather than
78
- misfiring.
79
96
  - **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
80
97
  `../map/map.constant` are the sanctioned exception.
81
98
  - **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
@@ -85,41 +102,33 @@ back.
85
102
  `*.store.ts`, a module component, `ui/`, `webkit/`, a package `client` entrypoint, or the `st` / `store` /
86
103
  `useClient` barrels (`no-import-client-in-server.grit`). Shared files — `common/` and `*.constant.ts` — are held
87
104
  to **both**, so they reach neither side. `import type` is erased before bundling and stays legal in every
88
- direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs/**`: `pkgs/akanjs/**`
89
- implements the boundary and is where the two graphs legitimately meet.
105
+ direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs/**`.
90
106
  - **Server-component discipline** is enforced on `page/**`, `*.Unit.tsx`, and `*.View.tsx`
91
107
  (`no-import-client-functions.grit`, `no-use-client-in-server.grit`, `non-scalar-props-restricted.grit`).
92
108
  - **Never write an async component outside `page/`** (`no-async-component-in-ui.grit`, scoped to
93
109
  `{apps,libs}/**/ui/**/*.tsx`). React has no async client component, so a `ui/` component that awaits breaks as
94
110
  soon as a client parent renders it, and the load drops below the route, which could have started it before the
95
111
  first byte. Await in the page — or hand an unawaited `fetch.*` to a `Zone` as an `init` / `view` prop — and take
96
- the resolved data as a prop. A component is a PascalCase binding whose own initializer is `async`, so an async
97
- handler declared inside a synchronous component, an inline `onClick={async () => …}`, and
98
- `lazy(async () => import(…))` are all untouched.
112
+ the resolved data as a prop. Only a PascalCase binding whose own initializer is `async` is matched, so an async
113
+ handler inside a synchronous component and `lazy(async () => import(…))` are untouched.
99
114
  - **Never call `fetch.init*` from a client file** (`no-init-fetch-in-client.grit`). `fetch.init<Model><Suffix>` and
100
115
  `fetch.get<Model>Init<Suffix>` compose the slice's list and insight queries into the hydration snapshot that
101
- `Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration it
102
- is two extra round-trips for a shell the browser already painted, landing in a value nothing reads. Load it in the
103
- page and pass the result down as an `init` prop — or hand the unawaited promise across — and reload from the client
104
- through the generated `st.do.init<Model><Suffix>()`. The gate is the file: the real `"use client"` directive (the
105
- same text as a string literal or inside sample code is not one) plus `*.store.ts`, which is client-only by role and
106
- carries no directive. The name is matched by shape, since a lint rule cannot know which slices exist: the
107
- generated one always carries two capital-led segments (`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so
108
- a hand-written `initPayment` is out and `initializeSomething` was never in. `view` / `edit` hydrate alike but are
109
- left unmatched — `edit<X>` is a plausible custom endpoint name.
116
+ `Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration
117
+ it is two extra round-trips for a shell the browser already painted. Load it in the page and pass the result
118
+ down as an `init` prop — or hand the unawaited promise across — and reload from the client through the generated
119
+ `st.do.init<Model><Suffix>()`. Gated on the real `"use client"` directive plus `*.store.ts`, and matched by shape
120
+ (`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so a hand-written `initPayment` is out.
110
121
  - `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose: `key={idx}` for embedded scalars and
111
122
  short dependency arrays are intentional, not oversights.
112
123
  - **A grit plugin diagnostic is suppressed as `lint/plugin`, not `plugin`** — `// biome-ignore lint/plugin: <reason>`
113
- for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form Biome's
114
- own category name suggests does nothing. Suppress a plugin only where the rule is genuinely wrong for the file, and
115
- say why: the module-convention plugins (`no-import-external-library`, `no-deep-internal-import`, the store/signal
116
- ones) apply to `apps/**` and `libs/**` only, so a plain package under `pkgs/` never needs the escape hatch.
124
+ for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
125
+ Biome's own category name suggests does nothing. The module-convention plugins apply to `apps/**` and `libs/**`
126
+ only, so a plain package under `pkgs/` never needs the escape hatch.
117
127
  - **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome does not report the parse
118
128
  error; it falls back to discovery and aborts on whatever nested config the walk finds. Rename the file to
119
- `biome.jsonc` to document a disabled rule; `akan lint` pins the config path either way, so it reports the parse
120
- error on the offending line.
121
- - **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` for no limit). Biome's own default is 20
122
- with no count, which reads as progress when the mix of findings merely changed.
129
+ `biome.jsonc` to document a disabled rule.
130
+ - **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` for no limit). Biome's own default is
131
+ 20 with no count, which reads as progress when the mix of findings merely changed.
123
132
 
124
133
  ## Coding Style (`**/*.{ts,tsx}`)
125
134
 
@@ -247,158 +256,69 @@ are, because those are the ones the server could have done.
247
256
  1. **Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
248
257
  `children` untouched, so the markup inside never reaches the bundle.
249
258
  2. **Split compound components so panels stay on the server.** `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in
250
- `akanjs/ui` is the shape: only the provider and menu hold state, and `<Tab.Panel>` renders children as-is.
251
- Never one `"use client"` file with a mode `useState` and every panel body inlined.
252
- 3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes
253
- the result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it.
254
- Never a `useEffect(…, [])` that fetches on mount.
259
+ `akanjs/ui` is the shape: only the provider and menu hold state. Never one `"use client"` file with a mode
260
+ `useState` and every panel body inlined.
261
+ 3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes the
262
+ result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it. Never a
263
+ `useEffect(…, [])` that fetches on mount.
255
264
  4. **Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and
256
265
  delegate to a server `View`.
257
266
  5. **Hand the promise across, not the awaited value.** `ClientInit` / `ClientView` are `PromiseOrObject<T>`, so a
258
267
  route may pass an unawaited `fetch.initX(...)` and `Load.*` resolves it behind a skeleton.
259
- 6. **Use named `ReactNode` slots, not just `children`.** `Layout.Navbar` takes `title`, `back`, `left`, `right`,
268
+ 6. **Use named `ReactNode` slots, not just `children`** — `Layout.Navbar` takes `title`, `back`, `left`, `right`,
260
269
  and `children`, so a client shell composes server content in five places instead of absorbing it.
261
270
  7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
262
271
  lookups belong in a module-scope `as const` map.
263
- 8. **Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any HTML
264
- is sent.
272
+ 8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any HTML is sent.
265
273
  9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
266
274
  `<details>`/`<summary>`, keeps both branches server-rendered.
267
- 10. **Keep the heavy island out of the first load.** A large client-only widget goes behind the
268
- `ui/<Folder>/index_.tsx` + `lazy()` pair. `usePage()` and `l()` work in server components, so translation
269
- never forces a boundary.
270
-
271
- Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`, or
272
- `akan guideline show ssrRule`.
273
-
274
- ## Web Surfaces — Building And Serving Without SSR/CSR
275
-
276
- An app serves three things: the API, the SSR/RSC web renderer, and the CSR single-file bundle. The API is always
277
- on; the other two are declared in `akan.config.ts` as **`web: true | false | { csr: boolean }`** and can be
278
- narrowed again per deployment.
279
-
280
- ```ts
281
- const config: AppConfig = { web: { csr: false } }; // web without the mobile bundle
282
- const config: AppConfig = { web: false }; // api only
283
- ```
284
-
285
- - **`web: { csr: false }`** drops the CSR build phase and the `/__csr` + `?csr=true` routes. The CSR bundle is
286
- what the Capacitor mobile build ships, so a web-only deployment never needs it — and an app that declares a
287
- `mobile` section is refused, because `akan build-ios` copies `dist/apps/<app>/csr/<target>.html` into the
288
- native project.
289
- - **`web: false`** is an API-only build: no base artifact, no pages or client bundles, no RSC worker
290
- entrypoint, and no `public/` in the image (the web router's catch-all is its only reader). Nothing under
291
- `page/` is served, including routes a lib contributed through `syncPageLibs`.
292
- - **There is no CSR-without-SSR option, by type.** The CSR bundle inlines the stylesheet the SSR base artifact
293
- compiles, so it would ship an unstyled app — the object form therefore carries only `csr`, and SSR goes off
294
- only through the whole-surface `false`.
295
- - **At runtime, `AKAN_SSR` and `AKAN_CSR` narrow further and never widen** — `false` or `0` turns one off, and
296
- `AKAN_SSR=false` takes CSR with it for the same reason the option has no such pair. A surface the build left
297
- out cannot be switched back on, and the boot log names what the process ended up serving. The generated
298
- Dockerfile writes the build's own answer as the image default.
299
- - **A build with no web artifact boots the API instead of crashing.** `WebRouter.create` returns `null` when
300
- `.akan/artifact/base-artifact.json` is absent — an api-only build, or a workspace with no `page/` at all.
301
- - `akan start` ignores `web` and keeps the whole dev surface: the incremental builder is also the file watcher,
302
- so switching it off would take server-code HMR with it. It warns once when the config and the dev server
303
- disagree.
304
- - The saving is mostly the RSC worker, which is a **separate process per web-serving replica**. Measured on
305
- `apps/akan` at boot plus one render: 350MB across 3 processes with SSR on, 120MB across 2 with
306
- `AKAN_SSR=false`, and the built image goes 86MB → 6.2MB when the artifacts are left out of it too.
307
- - **The generated image installs `ca-certificates` and `tzdata` and nothing else.** It used to carry the whole
308
- Chromium runtime, ffmpeg, `build-essential`, `python3` and `redis` in every app's image whether or not the app
309
- reached for any of them. An app that needs one declares it in `docker.preRuns` / `docker.postRuns`, which are
310
- emitted around the `bun install` — that is the migration for a `puppeteer` or `ffmpeg` app.
311
-
312
- ## The Process Model — Gateway And Solo
313
-
314
- A container runs one process per replica, a gateway in front of them when there is more than one, and one RSC
315
- worker per web-serving replica.
316
-
317
- - **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default, and what
318
- every environment in `infra/app/values` sets — means there is nothing to balance and nothing to fan pubsub out
319
- to, so `AkanApp` starts that replica in-process rather than spawning it. Measured on `apps/akan`: 28MB less RSS
320
- and twice the requests per second, because every request used to cross a unix-socket proxy hop. Declare two or
321
- more and the gateway is back, spawning and proxying them.
322
- - **`AKAN_SOLO=false` forces the gateway** for a single replica. Like `AKAN_SSR`, the env only narrows — it
323
- cannot fold a real gateway's replicas into one process. Passing `replica` to `new AkanApp(...)` also keeps the
324
- gateway: code that states a topology is asking for the thing that serves it.
325
- - **`akan start` always runs the gateway**, whatever the replica count. It is also the dev host's builder relay,
326
- its crash page, and what holds the port across a child restart.
327
- - **A batch-only replica (`0,1,0`) keeps the gateway too**, because a batch server never listens and the gateway
328
- is then the only thing bound to answer `/_akan/app/health`.
329
- - **The RSC worker is never folded in.** It runs under `--conditions react-server`, which resolves the same
330
- module graph differently, so it cannot share a process with the server that renders client components.
331
- - **A solo process answers `/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` itself**, in the
332
- gateway's own shape — a `children` array with one entry — so a probe reads one contract either way. It owns the
333
- rotating log file the gateway would otherwise write, in the same `runtime/logs` directory.
334
- - **Nothing supervises a solo process but the orchestrator**, since the gateway's crash-restart-with-backoff went
335
- with it. `infra/app/templates/app.yaml` carries the liveness, readiness and startup probes that replace it.
336
- - **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel.** The barrel re-exports
337
- `AkanServer`, whose graph the gateway never runs; through it the process evaluated 35MB of SSR renderer and
338
- SQLite driver to spawn children and relay bytes. Keep entrypoint imports at the leaf.
339
-
340
- ## The Generated Image — `docker` In `akan.config.ts`
341
-
342
- **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
343
- assembles one from. There is no `content` field; the string *is* the content.
344
-
345
- ```ts
346
- const config: AppConfig = {
347
- docker: { preRuns: ["apt-get update && apt-get install -y --no-install-recommends ffmpeg"] },
348
- };
349
- const config: AppConfig = { docker: "FROM oven/bun:1-slim\n…" }; // verbatim, nothing merged in
350
- ```
351
-
352
- - `image` and each run entry take `string | { amd64?, arm64? }`; the object form compiles to a
353
- `RUN if [ "$TARGETARCH" = "<arch>" ]` guard, so a multi-arch build runs it on one leg only. `preRuns` land
354
- before `bun install --production` (where a native dependency's build tools have to be), `postRuns` after it
355
- and before the app files are copied.
356
- - **A lib declares the steps its own runtime needs**, and every app that mounts it inherits them:
357
- `libs/<lib>/akan.config.ts` takes `docker: { preRuns, postRuns }` and nothing else — the base image and the
358
- command belong to the app. Lib steps are emitted before the app's own, and an identical step declared twice
359
- becomes one layer.
360
- - **The string form takes no contributions.** A Dockerfile handed over whole is used exactly as written, so a
361
- lib's `preRuns` are dropped rather than spliced into a file Akan does not own. An app that needs both writes
362
- the parts instead.
363
- - Like `externalLibs`, lib steps are collected from **every lib in the workspace**, not just this app's
364
- dependency closure — narrowing that set needs the dependency scan, and this config is re-read on every file
365
- change in dev. Keep a lib's steps to what its runtime genuinely requires.
366
- - `AkanAppConfig.docker` is the resolved declaration; `AkanAppConfig.dockerfile` is the text `akan build` writes
367
- to `dist/apps/<app>/Dockerfile`.
368
-
369
- ## Shipped Assets — `assets` In `akan.config.ts`
370
-
371
- `akan build` copies the whole `public/` tree into `dist`, lib assets dereferenced, and that copy is the image.
372
- **`assets: { pruneFonts, keepFonts }`** trims the fonts out of it that nothing reads. **Source trees are never
373
- touched** — an app's and a lib's own `public/` keep every file, because one shared font folder is picked over
374
- differently by every app that mounts it and by other repos.
375
-
376
- ```ts
377
- const config: LibConfig = { assets: { keepFonts: ["fonts/Assistant-*.woff2"] } }; // libs/<lib>/akan.config.ts
378
- const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve, not the normal escape hatch
379
- ```
380
-
381
- - **A font with `optimize` on is a build input, not a runtime asset.** `FontOptimizer` subsets it into
382
- `.akan/artifact/fonts` and the emitted CSS points at `/_akan/fonts/*`, which `WebRouter` serves from the
383
- artifact — so the source in `public/` is never opened again. That is the bulk of what this drops, and it
384
- applies to the fonts the app *does* use, not only the ones it does not.
385
- - **The keep-set is derived per app, per build; there is no list of fonts to drop.** An exclude list would rot
386
- the moment another app or repo started using one of them, and nobody would know to update it. A font survives
387
- because ① its font declares `optimize: false`, so `FontCss.getRuntimeCss` emits the raw src; ② something
388
- references its filename — anything under `public/`, the compiled stylesheet, a client/server bundle, the CSR
389
- shell; or ③ a `keepFonts` glob names it.
390
- - **`keepFonts` belongs to the `akan.config.ts` that owns the font**, written against that lib's or app's own
391
- `public/` (`"fonts/X.woff2"`, resolved to `libs/<lib>/fonts/X.woff2`). A lib shared across repos carries the
392
- reason its own CSS needs a font, instead of every mounting app rediscovering it in production.
393
- - **A reference from a build bundle is ignored for an already-subset font.** Every route file's `fonts`
394
- declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared source is in
395
- all three whether or not anything loads it. `public/` and the compiled CSS are read as authoritative.
396
- - **A generated manifest that lists every public asset keeps every font it names** — a service-worker precache
397
- file is the usual one. That is a real reference, not a false positive, so the build reports it at `info`:
398
- read the `[font-prune] kept …` line before concluding the prune does nothing.
399
- - Matching is by filename, including the percent-encoded spelling, so a `url()` survives however it is written.
400
- - The phase is `assets`, between `csr` and `compress`. An api-only build (`web: false`) copies no `public/` and
401
- the phase finds nothing to do.
275
+ 10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
276
+ `usePage()` and `l()` work in server components, so translation never forces a boundary.
277
+
278
+ Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`.
279
+
280
+ ## Runtime & Build Configuration
281
+
282
+ What an app serves, how many processes it runs, where its logs go, and what ships in its image. All of it is
283
+ declared in `akan.config.ts` and narrowed — never widened — by an env at boot. Domain code reaches none of it.
284
+ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runtimeRule`.
285
+
286
+ - **Web surfaces are `web: true | false | { csr: boolean }`.** `{ csr: false }` drops the mobile/CSR bundle,
287
+ `false` is an API-only build that serves nothing under `page/`. There is no CSR-without-SSR option, by type —
288
+ the CSR bundle inlines the stylesheet the SSR base artifact compiles. `AKAN_SSR` / `AKAN_CSR` narrow further at
289
+ runtime and can never switch a surface the build left out back on. `akan start` ignores `web` and keeps the
290
+ whole dev surface.
291
+ - **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default everywhere —
292
+ has nothing to balance, so there is no gateway and no unix-socket proxy hop. Two or more replicas, a batch-only
293
+ replica, `AKAN_SOLO=false`, passing `replica` to `new AkanApp(...)`, or `akan start` all bring the gateway back.
294
+ The RSC worker is always its own process (`--conditions react-server`). A solo process answers
295
+ `/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` in the gateway's own shape, and owns the
296
+ rotating log file; nothing supervises it but the orchestrator's probes.
297
+ - **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel** — the barrel re-exports
298
+ `AkanServer`, whose graph the gateway never runs. Keep entrypoint imports at the leaf.
299
+ - **Every `Logger` call builds a `LogRecord`** carrying `traceId`, `endpoint` and `origin` alongside the message,
300
+ so one request's lines share a trace. Request context is on by default, production included
301
+ (`AKAN_LOG_CONTEXT=0` opts out). **Give every sink a floor** — `Logger.addSink(sink, { minLevel })`; a sink with
302
+ none makes every `verbose` call render. **Never log per delivered record in anything that delivers records.**
303
+ Tail a running server with `akan logs <app>` (`--level --grep --endpoint --trace --child --role --origin
304
+ --since`, `--json`) or `.tail` / `.trace <id>` inside `akan console`.
305
+ - **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
306
+ solo replica) its only writer** — every other server process turns its console off and forwards from
307
+ `AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
308
+ `raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
309
+ `AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs` (SSE, `Last-Event-ID` resume, explicit `gap` events); unset,
310
+ the route does not exist. `AKAN_LOG_CANONICAL=1|slow` writes one `ok|error <endpoint> ms= status=` line per call;
311
+ `AKAN_LOG_FLIGHT=1` keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it
312
+ failed or ran past `AKAN_LOG_FLIGHT_MS`; `x-akan-debug` lowers one request to `trace` — in `local`, or with the
313
+ `AKAN_LOG_DEBUG_HEADER` secret. Structured values go in `LogRecord.attrs` via `Logger.emit(...)`; a key naming a
314
+ secret is `[redacted]` before the record exists.
315
+ - **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
316
+ assembles one from; the string form takes no contributions. The generated image installs `ca-certificates` and
317
+ `tzdata` and nothing else, so an app needing `ffmpeg` or Chromium declares it in `preRuns` / `postRuns`. A lib
318
+ declares the steps its own runtime needs and every mounting app inherits them.
319
+ - **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
320
+ trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
321
+ the `akan.config.ts` that owns the font, written against that scope's own `public/`.
402
322
 
403
323
  ## React Components And Styling (`**/*.tsx`)
404
324
 
@@ -440,9 +360,14 @@ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve,
440
360
  **`<model>.constant.ts`** — five classes in order with one blank line between them and `enumOf("camelName", [...] as const)`
441
361
  classes above: `XInput → XObject → LightX → X → XInsight`. Write `XInsight` even when it is empty. Put display and
442
362
  predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`) — the Light class is the one both server
443
- and client hold, so shared logic belongs there instead of in a util module. This is the most commonly missed rule in
444
- the codebase. Collection-level helpers go `static` on the full model. Give any field whose business meaning is not
445
- obvious a short trailing comment.
363
+ and client hold, so shared logic belongs there instead of in a util module. **This is the most commonly missed rule
364
+ in the codebase.** Collection-level helpers go `static` on the full model. Give any field whose business meaning is
365
+ not obvious a short trailing comment.
366
+
367
+ **A model instance's `Date` fields are prototype accessors, not own properties.** `user.createdAt` is still a `Dayjs`,
368
+ built on first read from a native `Date` the instance holds under a symbol. So `Object.keys(user)` and `{ ...user }`
369
+ do **not** include them, while `"createdAt" in user`, `for...in`, `JSON.stringify(user)`, `plainFieldsOf(user)`
370
+ (`akanjs/common`), `immerify`, and `deepObjectify` all do. To copy a model, `new cnst.X().set(user)`, never a spread.
446
371
 
447
372
  **`<model>.document.ts`** — fixed order: `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
448
373
  with `sort: {}` always present. Chain methods validate → mutate → `return this`, and never `save()`; the caller saves,
@@ -450,9 +375,8 @@ so chains compose (`org.removeUser(id).removeInvite(id).save()`). Put a one-line
450
375
  transition. Atomic counters live on the Model class with the updater-callback form, returning `!!modifiedCount`.
451
376
  Indexes and derived totals go in `static override _onSchema`, not in the service. **Removal is always soft** — the
452
377
  framework has no hard delete for a model table, and `delete` is deliberately left unused so it can mean one later.
453
- The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`), because a
454
- bare `update`/`remove` would read like the document-path `update(id)` / `doc.remove()` while hitting every match; only
455
- the count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
378
+ The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only the
379
+ count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
456
380
  `removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
457
381
  so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
458
382
 
@@ -470,8 +394,8 @@ stores need none, because state and CRUD actions are generated. The body is thre
470
394
  `this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
471
395
  `this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
472
396
  through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
473
- nothing** (`no-return-in-store-action.grit`) — hand the result to `this.set({...})`; a bare `return;` guard stays
474
- fine. **Never `import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
397
+ nothing** (`no-return-in-store-action.grit`). **Never `import type { RootStore } from "../st"`** — it crashes
398
+ `akan build` with a Bun SSR segfault.
475
399
 
476
400
  **`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
477
401
  `.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
@@ -494,27 +418,25 @@ workflow changes.
494
418
 
495
419
  ### Guards And Transports
496
420
 
497
- - Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` (`pkgs/akanjs/signal/signalContext.ts`) instead of branching on `getHttpContext()` / `getWebSocketContext()`.
498
- - Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is unguarded unless it declares its own `guards` in its signal option.
499
- - A pubsub room is authorized once, at subscribe. When a socket's credential changes the framework re-runs each room's guards and unsubscribes the ones that now fail (`SignalResolver.revalidateWsRooms`), so guards must stay side-effect free and safe to re-run.
500
- - A websocket carries its credential in the handshake snapshot on `ws.data` (`AppWsData`); clients that hold the token in memory send it with `fetch.setJwt(...)`, which forwards an auth frame over the socket.
501
- - **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`.** Whenever a federation
502
- gateway is in front, `ws.remoteAddress` and the child's own peer are the *gateway* (`127.0.0.1`) for every
503
- caller and for the whole life of every socket. `Ip` reads what a proxy recorded (`x-real-ip`, then the first
504
- `x-forwarded-for` entry) and falls back to the peer only when nothing proxied the call — which is the answer
505
- for a solo process behind an ingress that sets neither; `context.getClientIp()` is the same answer inside a
506
- guard or middleware. It arrives unwrapped from
507
- its `::ffff:` IPv4-mapped form, so it can address a `udp4` socket as well as identify a caller, and it is
508
- `null` rather than a placeholder when genuinely unknown — a loopback-looking address for an unknown caller
509
- is the failure this replaced. The gateway also forwards the client port (`x-forwarded-port`, read with
510
- `context.getClientPort()`) along with host and protocol.
511
- - **Every socket carries a `socketId`, and only the framework mints one.** `AppWsData` assigns it at the handshake, and a `message` / `pubsub` handler reads it off the `Ws` internal arg — `.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`. Never mint your own from `ws.data`: the room bookkeeping keys on the framework's id, and a second one fails to match it silently. It identifies a **connection**, not a caller — a reconnect gets a new id and the federation gateway's own socket is a different one — so per-user state keys on the account, never on this.
512
-
513
- - **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it.**
514
- From a `pubsub` subscribe it belongs to that room — `unsubscribe` runs when the client leaves it or a credential
515
- change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
516
- neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
517
- the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
421
+ Full contract — credential handshake, room revalidation, socket cleanup scoping: `get_guideline` with
422
+ `transportRule`, or `akan guideline show transportRule`.
423
+
424
+ - Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` instead of
425
+ branching on `getHttpContext()` / `getWebSocketContext()`, and keep them side-effect free and safe to re-run —
426
+ a pubsub room's guards are re-run whenever the socket's credential changes.
427
+ - Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is
428
+ unguarded unless it declares its own `guards` in its signal option.
429
+ - **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`** (`context.getClientIp()`
430
+ inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
431
+ *gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
432
+ recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
433
+ - **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
434
+ (`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
435
+ bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
436
+ **connection**, not a caller, so per-user state keys on the account.
437
+ - **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
438
+ — a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
439
+ registers for both.
518
440
 
519
441
  ### Authorization Defaults
520
442
 
@@ -536,35 +458,21 @@ workflow changes.
536
458
  - `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.
537
459
  - Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference.
538
460
 
539
- ### Binary Pubsub
540
-
541
- - **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON `{ type: "pub" }`
542
- envelope and the base64 a JSON wire would need. The client's subscribe callback receives a `Uint8Array`.
543
- Nothing else is declared: the return type is the whole switch, and text and binary frames coexist on one
544
- socket, so every JSON endpoint is untouched. The optimization applies only when the **whole** return is
545
- `Binary` — `[Binary]`, or a `Binary` inside a model, falls back to base64.
546
- - **A declared `Binary` room coalesces under backpressure**: while a socket cannot keep up the room keeps only
547
- its newest frame and drops the rest, which is what a telemetry or video stream wants. Declare
548
- `pubsub(Binary, { backpressure: "queue" })` when the frames are a sequence a subscriber must see in full, such
549
- as deltas against a base it already holds; the send buffer then grows with the slowest subscriber. Coalescing
550
- is keyed by the endpoint, not carried with the frame, so this process, an IPC deliver and a Redis fan-out all
551
- reach the same answer. Coalesced frames are counted in `pubsubCoalesceCount` on the replica's metrics report.
552
- - **A `pubsub(Any)` that happens to carry bytes is framed too, and warns once.** `Any` passes a `Buffer` through
553
- untouched, so the transport sees bytes and sends them whole rather than as a number array — an undeclared
554
- publisher is fixed rather than left corrupt. It is still not the contract: it queues rather than coalescing
555
- (nobody declared lossy delivery for that room), and a Redis fan-out still JSON-stringifies it. Declare
556
- `Binary`.
557
- - The federation gateway relays a binary frame unchanged, and Bun IPC carries bytes as bytes, so the only hop
558
- that needed teaching was the socket itself. A cross-server (Redis) fan-out encodes it as protobuf `bytes`.
559
-
560
- ### Mutation HTTP Verb
561
-
562
- - A `mutation` is `POST`. `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several verbs
563
- — a `query` GET and a `mutation` POST on the same custom `path` are mounted side by side. Two endpoints claiming
564
- the same path **and** verb fail the boot rather than silently shadowing one another.
565
- - Reach for it only when a foreign wire protocol forces the verb (a client you cannot change that sends
566
- `PATCH /rest/v1/<table>`). Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
567
- whatever is declared, so nothing needs restating per caller.
461
+ ### Binary Pubsub And Mutation Verbs
462
+
463
+ Full contract — backpressure modes, the `Any`-carrying-bytes fallback, multi-verb paths: `get_guideline` with
464
+ `transportRule`.
465
+
466
+ - **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON envelope and the base64 a
467
+ JSON wire would need; the client's subscribe callback receives a `Uint8Array`. The optimization applies only
468
+ when the **whole** return is `Binary`. A declared `Binary` room **coalesces under backpressure** — keeping only
469
+ the newest frame — which is what telemetry and video want; declare `{ backpressure: "queue" }` when the frames
470
+ are a sequence a subscriber must see in full. A `pubsub(Any)` carrying bytes is framed too and warns once;
471
+ declare `Binary`.
472
+ - **A `mutation` is `POST`.** `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several
473
+ verbs; two endpoints claiming the same path **and** verb fail the boot. Reach for it only when a foreign wire
474
+ protocol forces the verb — Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
475
+ whatever is declared.
568
476
 
569
477
  ### Reserved Endpoint Names
570
478
 
@@ -573,74 +481,49 @@ workflow changes.
573
481
 
574
482
  ### Slices, Queries, and Hydration
575
483
 
484
+ Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `labelOf`, and the `q.exists` /
485
+ `q.missing` / `q.empty` split: `get_guideline` with `queryRule`, or `akan guideline show queryRule`.
486
+
576
487
  - **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
577
488
  [ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
578
- filter every model carries. A raw query descriptor let a caller compose a query the model never declared, and
579
- could not survive the query string at all — `String(value)` sends `[object Object]`. Args are parsed by the type
580
- the filter declared, a missing required one is refused, and args past the declared ones are dropped
581
- (`resolveFilterQuery`, `pkgs/akanjs/document/filterMeta.ts`). It is an admin API: `root:` is always `Admin`.
582
- - **Give an id filter arg a `ref` and the admin panel stops asking for hex.** `filter().arg("ownerId", ID, { ref:
583
- "user" })` names the model the id points at; the arg then renders a picker that runs the same query maker against
584
- that model's own filters, so a ref of a ref keeps nesting. The picker holds its rows in local state rather than the
585
- ref model's store — that store is a singleton, and loading into it would overwrite whatever listing of the same
586
- model is already on screen. `ref` is the only UI hint a filter arg carries; there is no render callback, because a
587
- function cannot cross to the client.
588
- - **A summary counter names the listing it counts, on the field itself.** `field(Int).meta(getQueryMeta("user")
589
- .query("byStatuses").args([["prepare"]]))` is what makes the dashboard tile clickable: clicking it applies that
590
- filter to the listing in place — one request, no navigation — and fills the arg controls under the toolbar, so
591
- the filter is visible and editable rather than opaque. `args` may be a thunk (`() => [dayjs().subtract(1,
592
- "hour")]`), read at the click so a relative time is current. A meta naming a different model counts a different
593
- listing, so its tile stays inert here. `AdminPanel`'s `queryMap` still overrides per column, and is now only for
594
- a column whose field cannot declare one.
595
- - **A picker row is labelled by `labelOf`, which reads `Light<Model>.label()` first.** Then the field carrying
596
- `text: "title"`, then `title` / `name`, with the id as the floor. Write the method on the Light class — that is
597
- where display logic belongs, and the same label is what `Load.Units` and the in-page agent's screen scope show.
598
- A model with none renders ids and says so in the picker.
599
- - A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
600
- - Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
601
- - Generated list accessors like `listBy(...)` return `Promise<Doc[]>`. For a chainable builder (`.sort().skip().limit().select()`) use the model facade's `findMany`/`findOne` (`FindManyChain`, `pkgs/akanjs/document/into.ts`).
602
- - **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`); client fetch results are raw `GetStateObject` plain data (functions stripped, `pkgs/akanjs/base/types.ts`).
489
+ filter every model carries. A raw query descriptor could not survive the query string — `String(value)` sends
490
+ `[object Object]`. It is an admin API: `root:` is always `Admin`.
491
+ - A slice's `exec` returns a `QueryOf` (an opaque query descriptor); you **cannot** chain `.sort()`/`.limit()` on
492
+ it. Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })`. For a
493
+ chainable builder use the model facade's `findMany`/`findOne`.
494
+ - **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`);
495
+ client fetch results are raw `GetStateObject` plain data, functions stripped.
603
496
  - Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
604
- `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
605
- query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use them on a
606
- model that carries no removal side effect; otherwise remove documents one at a time.
497
+ `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
498
+ are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
499
+ them on a model that carries no removal side effect; otherwise remove documents one at a time.
607
500
  - **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
608
- "archived" })`. The patch cannot trail the filter args — a filter's own args may be optional and no tuple type
609
- puts a required element after those — so it lands on a terminal `.set()`, mirroring the `UPDATE … SET …` it
610
- compiles to. Building the chain touches nothing; only `.set()` runs a query.
611
- - `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
612
- and there is no way to change that. They also report only counts, never which row they touched, so they are for
613
- "there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
614
- - **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
615
- model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
616
- throws at boot instead (`assertFilterFitsCrud`).
617
- - **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
618
- key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
619
- or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
620
- out, while a document read back and saved carries the explicit `null` that the read materialized. So `q.missing`
621
- matches a freshly inserted row and silently stops matching that same row after its first round-trip save. Reach
622
- for it only to find rows written before the field was declared.
501
+ "archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
502
+ touches nothing.
503
+ - `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
504
+ most one of these", not for claiming the next item off a queue.
505
+ - **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
506
+ single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
507
+ - **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
508
+ which a document written once and saved again no longer is. Reach for it only to find rows written before the
509
+ field was declared.
623
510
 
624
511
  ### Text Search In A Filter — `q.search()`
625
512
 
513
+ Full contract — relevance ordering, `columns` / `weights`: `get_guideline` with `queryRule`.
514
+
626
515
  - Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
627
516
  q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
628
517
  `insightBySearch` come for free — you do **not** need a slice to make search usable.
629
518
  - **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
630
- client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk the
631
- table. Leave that decision to the mounting app.
519
+ client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
520
+ the table. Leave that decision to the mounting app.
632
521
  - `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
633
- `q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery` — a query-level write
634
- takes no join, so ignoring the node would silently widen the write to every other matching row.
522
+ `q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery`.
635
523
  - Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
636
524
  search endpoint into a full listing.
637
- - Order by relevance with the built-in `relevance` sort key. It is an empty sort map, which the store reads as
638
- "unspecified": score order when a search join is present, `createdAt` descending otherwise. That fallback is the
639
- compiler's own, not a model-defined default — redefining `latest` on the model does not change it.
640
- - **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
641
- client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
642
- - Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
643
- finite numbers positional over `["title", "desc", "tag", "filter"]`.
525
+ - Order by relevance with the built-in `relevance` sort key. A slice endpoint never reaches the unspecified
526
+ fallback — leaving `sort` off gets `latest`, so a client wanting score order has to name `relevance`.
644
527
 
645
528
  ### Service / Signal Injection
646
529
 
@@ -664,8 +547,8 @@ export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, me
664
547
  - 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`.
665
548
  - **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.
666
549
  - `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in `override async onInit()`.
667
- - **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site. Ask whether there is exactly one per process that a service needs injected; if not, it is a plain class.
668
- - **Legacy shape — recognise it, do not copy it.** Plain classes with `constructor(options: XOptions)`, registered in `lib/option.ts` as `options.x ? new XApi(options.x) : null` and injected with `use<T>()`, still work. Migrate one to `adapt()` only when you are already changing it.
550
+ - **`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.
551
+ - **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.
669
552
 
670
553
  Conventions that hold for both shapes:
671
554
 
@@ -686,54 +569,41 @@ Conventions that hold for both shapes:
686
569
  ### MCP Exposure
687
570
 
688
571
  Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
689
- follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
690
- that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
691
- `Public`. The guards are already the authorization decision, so a second switch would say nothing they do not —
692
- while guaranteeing that every endpoint added later is invisible to agents until somebody remembers it.
572
+ follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An
573
+ endpoint that declares a real guard is published; one that declares none is refused, and so is a mutation whose
574
+ only guard is `Public`. The guards are already the authorization decision, so a second switch would say nothing
575
+ they do not — while guaranteeing that every endpoint added later is invisible to agents until somebody remembers
576
+ it.
693
577
 
694
578
  - **`mcp: false` keeps an endpoint off the shelf without touching its guards.** Guards answer "may an agent call
695
579
  this"; they cannot answer "does this belong on a shelf", and a step of a UI-driven state machine
696
580
  (`requestPhoneCodeForSignin`) is perfectly guarded and still a mistake for a model to reach. Write it in the
697
- signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`
698
- — which covers that slice's list and insight together), or as an `mcp` map on `slice()` that **mirrors the
699
- `guards` map key for key** (`mcp: { cru: false }`), with the same `cru` fallbacks and the same scope: the root
700
- slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, so everything
701
- added later still publishes. **It is curation, not authorization — HTTP serves the endpoint exactly as before.**
702
- - **Narrow by cost, and read the boot log first.** MCP has no shared component section and forbids a `$ref` across
703
- entries, so every entry inlines the full schema of every model it mentions, and the listing is re-sent whole to
704
- every agent that connects. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
705
- where it went. A plain 21-field model costs 12.6KB over eight entries; `mcp: { cru: false }` takes it to 4.7KB,
706
- because the generated writes carry the input *and* the full model.
581
+ signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`),
582
+ or as an `mcp` map on `slice()` that **mirrors the `guards` map key for key** (`mcp: { cru: false }`) — root
583
+ slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
584
+ curation, not authorization: HTTP serves the endpoint exactly as before.
585
+ - **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
586
+ full schema of every model it mentions and the listing is re-sent whole to every agent that connects. A plain
587
+ 21-field model costs 12.6KB over eight entries; `mcp: { cru: false }` takes it to 4.7KB. `MCP catalogue: … ·
588
+ listing 214KB` plus a per-signal `MCP catalogue cost:` line says where it went.
707
589
  - Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
708
- allowedOrigins, pageSize, language, auth })`, **not `main.ts`**. Every lib's option is read in mount order with
709
- the app's last, and each field has an `AKAN_MCP_*` env spelling the option overrides.
710
- - **RFC 9728 metadata is served only once an authorization server is named.** Advertising `bearer_methods_supported`
711
- without one makes Cursor (and similar clients) force OAuth. Set `AKAN_MCP_AUTH_SERVERS` when the surface is an
712
- OAuth resource; otherwise the well-known document is absent.
713
- - **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real guard (`[Public]` is
714
- having none), `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and
715
- the generated `light<Model>` read — it is `<Model>` in a smaller shape, so one read never costs two entries of
716
- every listing. A `prompt` also refuses a list argument and any `Any` argument.
717
- - **Every refusal is named in the boot log**, with a `MCP catalogue: tools=… prompts=…` count. Read that line
718
- first when a tool you expected is missing — with no opt-in to notice, it is the only place the answer exists.
719
- The boot log also names every published entry with no dictionary `.desc()`: an agent picks a tool by its
720
- description, so write the model `.desc()` the generated entries borrow.
721
- - A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
722
- generalized to `You are not permitted to perform this action.` Never make either message more helpful — the
723
- difference is what enumerates your private surface.
724
- - **A `field.visual` field is stripped from every MCP result**, and from the readable schema so the two agree — the
725
- model-level answer to a field the page needs and an agent does not. `resolveReturn` leaves it alone, so the
726
- browser still receives it.
727
- - **A structured result ships twice by default** — once as `structuredContent`, once as the same JSON in the text
728
- block, which is what the spec asks for clients predating the structured field and is also a flat doubling of what
729
- every model-returning tool costs. `option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
730
- - `AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch; `AKAN_MCP=false` takes the
590
+ allowedOrigins, pageSize, language, auth })`, **not `main.ts`**. Each field has an `AKAN_MCP_*` env spelling.
591
+ `AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch; `AKAN_MCP=false` takes the
731
592
  whole surface off.
593
+ - **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
594
+ guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
595
+ generated `light<Model>` read. A `prompt` also refuses a list argument and any `Any` argument. **Every refusal
596
+ is named in the boot log**, along with every published entry missing a dictionary `.desc()` — an agent picks a
597
+ tool by its description.
598
+ - A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
599
+ generalized. Never make either message more helpful — the difference is what enumerates your private surface.
600
+ - **A `field.visual` field is stripped from every MCP result** and from the readable schema so the two agree.
601
+ - **A structured result ships twice by default** — as `structuredContent` and as the same JSON in the text block.
602
+ `option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
732
603
  - **`prompt()`** is invoked by the *user* as a slash command, never chosen by the model. `exec` returns
733
604
  `PromptMessage[]` built with `Msg.user` / `Msg.assistant` / `Msg.resource` / `Msg.image`; an embedded payload is
734
- masked by the model you name (`Msg.resource(uri, task, { model: cnst.LightTask })`), and one whose
735
- `hidden`/`secret` fields are populated with no model named is refused. It is also mounted as a plain HTTP `GET`
736
- whether or not MCP is enabled, so guard it like any other read.
605
+ masked by the model you name, and one whose `hidden`/`secret` fields are populated with no model named is
606
+ refused. It is also mounted as a plain HTTP `GET` whether or not MCP is enabled, so guard it like any other read.
737
607
 
738
608
  Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
739
609
  `McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
@@ -747,64 +617,34 @@ screen does not offer the user is not one an agent may pull in their place. The
747
617
  package; apps and libs never import it directly (`no-import-external-library`) — everything reaches them through
748
618
  `st.*` and `akanjs/ui`.
749
619
 
620
+ Read the full contract before building an agent surface — the chat's own options, `st.tool` / `st.expose` /
621
+ `st.useState` in full, zones and namespaces, attachments and speech, slash commands, transcript compaction, and
622
+ the built-in tool semantics: `get_guideline` with `agentRule`, or `akan guideline show agentRule`. What follows is
623
+ what every developer must know even when not building one.
624
+
750
625
  - **Mount `<Agent.Chat />` once in a layout.** The endpoint is a stateless relay that **never executes tools**:
751
626
  every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
752
627
  `AgentRelayAccess`, which refuses every call until the app names a guard of its own —
753
- `option.setAgentAccess(SignedIn)`.
754
- - **The LLM is configured in `option.ts`, never through the environment.** `option.setLlm({ apiKey, model, host })`
755
- fills whichever adaptor holds `LlmAdaptorRole`; swap providers with `option.applyAdaptor(LlmAdaptorRole, X)`.
756
- - **Declare the tool beside the control that already does it.**
757
- `st.tool("x").desc("…").arg("id", ID).opt("force", Boolean).exec(fn)` publishes one action and returns the
758
- callable to hand to `onClick` — one handler for the person and the agent. `.desc()` is required and comes first,
759
- `.arg()` is an argument the caller must pass and `.opt()` one it may, and `.exec()` is the only hook. Reach a
760
- store action from the body (`.exec((id) => st.do.removeX(id))`); `st.do` on its own reaches nobody.
761
- - **Publish a tool only where the screen already renders the control.** A falsy name declares the tool without
762
- publishing it, which is how a conditional surface stays legal — `.exec()` is a hook, so withhold the name, not
763
- the call. A `disabled` control publishes nothing.
764
- - **A component that renders once per row takes the row id as an argument**, never a closure over it — fifty
765
- registrations of one name leave forty-nine shadowed. The description is what makes the repeats one declaration:
766
- same name and same `.desc()` is the same tool registered fifty times, and only a second description under that
767
- name warns.
768
- - **`{ settle: false }` is the read that returns what is already there.** Every other tool is waited out before
769
- what it did to the screen is reported, because a write may still be landing when `exec` resolves. `{ confirm }`
770
- and `{ guard }` ride the same object; a `remove*` name confirms by default.
771
- - **A component that can render twice on one screen takes a `namespace` prop** (`Tab`, `Dialog`, `Dropdown`,
772
- `ScreenNavigator`) and publishes nothing without one.
628
+ `option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
629
+ (`option.setLlm({ apiKey, model, host })`).
630
+ - **Declare the tool beside the control that already does it**, never as a separate surface:
631
+ `st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
632
+ person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
633
+ tool without publishing it, and a `disabled` control publishes nothing.
773
634
  - **Forms publish themselves**: a `Field.*` / `Input.*` / `Select` / `Switch` handed `onChange={st.do.setXOnY}`
774
635
  **by reference** publishes that setter, and `st.use.yForm()` adds `fillYForm(patch)`. An inline arrow publishes
775
636
  nothing — normalize with the control's `transform` prop, and multi-write with a `_postSet<Field>` store method.
776
- - **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface;
777
- a key no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
778
- - **A component's own value is published by its declared type.** `st.expose("taskId", ID).desc("…").value(v)` for
779
- a derived value and `st.useState("tab", String, { set: true }).desc("…").init("all")` for local state — the type
780
- typechecks what is handed over and decides how it is read, so a model class masks by that model. `Any` is the
781
- escape hatch: no typecheck, and the value passes untouched. `.value()` also takes a thunk, read when the agent
782
- reads.
637
+ This is the same rule `no-unpublished-form-setter.grit` enforces.
638
+ - **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface; a key
639
+ no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
783
640
  - **Return what answers the question, not the record.** A tool's value is capped at 20,000 characters before it
784
- enters the transcript and clipped with a note the model reads (`ToolOutput.limit`), because a store key is sized
785
- for a screen and not for a model's window: one `readState` of a list whose rows carry inlined bytes is megabytes,
786
- and from there it rides *every* later turn. Each tool row in the chat carries its own estimated token cost and
787
- opens onto the value the model was handed — that row is where a conversation that filled up in four messages
788
- explains itself. A field that is bulky and useless to a model is fixed once at the model instead:
789
- `field.visual(String)` keeps it on the page and out of every agent-facing read and MCP result.
790
- - **`<Agent.Skip label="site footer">` leaves a region out of the default `readScreen`** — a footer, a cookie
791
- banner, a nav that repeats on every route. `[skipped: site footer]` stands in its place rather than nothing, since
792
- a deleted region reads as an absent one, and the marker's name is a `section` that reads the region on request.
793
- Put `data-agent-skip="<name>"` on the element the page already renders where a wrapper div would move the layout.
794
- It hides **text, not behaviour**: an `st.tool` inside is published as before. Narrowing from the other side —
795
- `Agent.Zone`, `readScreen({ section })` — is the better move on a screen that is mostly chrome.
641
+ enters the transcript, and once there it rides *every* later turn. A field that is bulky and useless to a model
642
+ is fixed once at the model instead: `field.visual(String)`.
796
643
  - **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or page — the render
797
644
  tree is the cascade. It is a component, not a `pageConfig` field, and `*.abstract.md` is never served to agents.
798
- - The framework publishes five built-ins on every store surface — `navigate`, `goBack`, `readScreen`,
799
- `readState(key)`, `highlight(target)` — plus `askUser`, which the session owns. **There is no general-purpose
800
- wait**: publish an `st.tool` beside the control that starts the work and let it await the work.
801
645
  - **Model-facing text is English, always** — every `.desc()`, `instructions`, Guide text. The `l()` rule covers
802
646
  strings a *user* reads.
803
647
 
804
- Full contract — the chat's own options, attachments and speech, zones, what `akanjs/ui` publishes for you, slash
805
- commands, transcript compaction, and the built-in tool semantics: `get_guideline` with `agentRule`, or
806
- `akan guideline show agentRule`.
807
-
808
648
  ## Scalar Modeling (`**/*.constant.ts`)
809
649
 
810
650
  - Define Akan models in `.constant.ts` files with `via` from `akanjs/constant`.
@@ -833,34 +673,22 @@ commands, transcript compaction, and the built-in tool semantics: `get_guideline
833
673
 
834
674
  ### Text Search Fields — the `text` role
835
675
 
836
- - A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
837
- `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
676
+ Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH_ENABLED`: `get_guideline` with
677
+ `queryRule`.
678
+
679
+ - A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and
680
+ likewise `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model
681
+ switch. Declaring roles is all the wiring there is.
838
682
  - Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
839
683
  `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
840
- scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
841
- - `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
684
+ scoping value (status, owner, role) that must be matchable but must never outrank a real title hit. `thumb` is
685
+ mirrored for rendering a hit and is **not** indexed — never expect it to match.
842
686
  - **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
843
- mirror is plaintext and an indexed secret would leak through search. The class build throws the same refusal as
844
- a backstop, for an option object the excess-property check cannot see through, and names the way out: drop the
845
- role, or leave the field unmasked. Do not work around either. The throw also covers a `text` field *underneath*
846
- one of those — a scalar's own field is reachable through its parent, so `f.secret(Noti)` where `Noti.label`
847
- carries a role is rejected at the parent, not silently indexed; that one is only reachable at runtime, since the
848
- pairing spans two files.
849
- - The role works on a relation too (`image: field(File, { text: "thumb" })`) and on an array (`playing: field([String],
850
- { text: "tag" })`); an array of objects indexes by leaf key, including an array leaf (`works[*].tags`). A field
851
- inside a `Map` indexes nothing: there is no fixed path to extract it from.
852
- - Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
853
- because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
854
- path.
687
+ mirror is plaintext and an indexed secret would leak through search. The same refusal throws at runtime as a
688
+ backstop, including for a `text` field *underneath* one of those. Do not work around either.
689
+ - The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
690
+ indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
855
691
  - Search runs on sqlite/libsql only. `q.search()` against Postgres throws, loudly, rather than returning every row.
856
- - `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
857
- re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
858
- it does not mount, so a mixed fleet leaves stale triggers behind.
859
- - The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
860
- `unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
861
- tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
862
- rebuild takes no cross-process claim, so a fleet restarted at once repeats it in every process; stagger the
863
- restart when the mirror is large.
864
692
 
865
693
  ### Image & File Fields
866
694
 
@@ -871,37 +699,28 @@ commands, transcript compaction, and the built-in tool semantics: `get_guideline
871
699
 
872
700
  ### Cascade Remove — the `cascade` option
873
701
 
702
+ Full contract — bulk planning, the auto-created index, cycle cutting, boot-time validation: `get_guideline` with
703
+ `queryRule`.
704
+
874
705
  **The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
875
706
  shape, so `cascade` never means "related" — it means one of exactly these:
876
707
 
877
708
  - `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
878
- `image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it; a primitive, a bare
879
- `ID`, and a scalar each fail the class build.
709
+ `image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it.
880
710
  - `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
881
- its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three forms:
882
- a relation (`field(AgentSession, { cascade: "removeWith" })`), an id with `ref`, or a polymorphic id with
883
- `refPath`. An array, a Map, `ref` together with `refPath`, and a field naming no owner each fail the class build.
884
- - **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time, so every
885
- model's removal would have to sweep the polymorphic table on the chance it is the owner.
711
+ its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
712
+ forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
713
+ `refPath`, and a field naming no owner each fail the class build.
714
+ - **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time.
886
715
  - **A cascade goes through the target's service, never its model** — that path is what runs the target's
887
716
  `_postRemove`, where a module puts the side effect the removal has to carry.
888
- - **Bulk is decided at boot, per target model, for both directions.** A target with no `remove` schema hook, no
889
- `_pre`/`_postRemove`, no cascade of its own, and no children takes one `removeManyByQuery`. Adding a
890
- `_postRemove` silently flips it back to one document at a time — the boot log is the only place that shows.
891
- - **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` counts,
892
- and an unmounted `removeRef` target or `removeWith` owner fails the boot rather than the first removal. An
893
- unmounted `refPath` candidate only warns, since that list spans optional modules by design.
894
- - **Nothing checks whether another document still references the same target.** `File` in particular is deduped
895
- by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
717
+ - **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
718
+ `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
896
719
  - Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
897
720
  restorable, and reviving the owner does not revive what went with it.
898
- - A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
899
- polymorphic); every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table.
900
721
  - **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
901
722
  generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
902
723
  at a time when it cascades.
903
- - Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write. Cycles are cut by a
904
- visited set carried down the whole chain, with a depth cap of 16.
905
724
 
906
725
  ## Akan Page Routing (`apps/**/page/**`)
907
726