@akanjs/cli 3.0.0-alpha.4 → 3.0.0-alpha.41

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 (108) hide show
  1. package/.build-stamp +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/{abstractCompactor-rk6twzvj.js → abstractCompactor-20e0cwbs.js} +3 -3
  5. package/{agent.command-jfz8yfwy.js → agent.command-e835fjem.js} +9 -8
  6. package/{application.command-mxyqtd6p.js → application.command-sjtbk6tr.js} +7 -6
  7. package/{applicationBuildRunner-cwk52sb7.js → applicationBuildRunner-7gnfz7ww.js} +7 -8
  8. package/{applicationReleasePackager-yrrhbkv3.js → applicationReleasePackager-93nzdfsy.js} +2 -2
  9. package/buildBatch.proc.js +6 -5
  10. package/{capacitorApp-j357ak1g.js → capacitorApp-qv7g3dp2.js} +29 -28
  11. package/{cloud.command-f87dwe52.js → cloud.command-y0e4eyqz.js} +11 -10
  12. package/{context.command-z5bf8bn7.js → context.command-vn87jb08.js} +17 -16
  13. package/{dependencyScanner-f6grrep3.js → dependencyScanner-rbs2407b.js} +1 -1
  14. package/getDirname-ka11hm7e.js +8 -0
  15. package/{guideline.command-1m1ctar3.js → guideline.command-s4hn2bsy.js} +7 -6
  16. package/guidelines/conventions/conventions.generate.json +25 -0
  17. package/guidelines/conventions/conventions.instruction.md +1193 -0
  18. package/guidelines/cssRule/cssRule.instruction.md +7 -0
  19. package/guidelines/framework/framework.instruction.md +1 -0
  20. package/guidelines/ssrRule/ssrRule.generate.json +31 -0
  21. package/guidelines/ssrRule/ssrRule.instruction.md +108 -0
  22. package/guidelines/workspaceOnboarding/workspaceOnboarding.generate.json +24 -0
  23. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +972 -0
  24. package/incrementalBuilder.proc.js +7 -6
  25. package/{index-1s71fwrq.js → index-0h212yqe.js} +13 -13
  26. package/index-1577bej2.js +7 -0
  27. package/{index-e3qskyt2.js → index-33zce8gt.js} +8 -4
  28. package/{index-csqk9z5j.js → index-3dp7x6rc.js} +3 -3
  29. package/{index-cn24dcqn.js → index-73rnbwgg.js} +15 -7
  30. package/{index-tq1y56tp.js → index-8d2sv6pw.js} +39 -16
  31. package/{index-jtm86byd.js → index-8vkkm7v0.js} +125 -66
  32. package/{index-r4w8cry5.js → index-9jsnb4vw.js} +4 -4
  33. package/{index-ybybkec0.js → index-becx1t0k.js} +6 -6
  34. package/{index-4yp06pn1.js → index-g9e2fhw2.js} +49 -32
  35. package/{index-cap2k2ch.js → index-hg0qvhmx.js} +152 -241
  36. package/{index-xwgm94zr.js → index-j37qq1f2.js} +1 -1
  37. package/{index-n0z3f2xg.js → index-k6fvbcj1.js} +37 -6
  38. package/{index-6pz1j0zj.js → index-mxvakhsm.js} +6 -2
  39. package/{index-vhv3xnjz.js → index-mykmf6wd.js} +8 -8
  40. package/{index-y0k42k04.js → index-mz5jtaq7.js} +1 -1
  41. package/{index-jk9q6pse.js → index-n706txcv.js} +5 -3
  42. package/{index-hjzr8ja1.js → index-qc0tnhs2.js} +1 -1
  43. package/{index-rh16j3c7.js → index-qnv0aqmm.js} +12 -8
  44. package/{index-3pgtw35d.js → index-sh01r3pm.js} +2 -2
  45. package/{index-xj8bks3p.js → index-t71zvmt9.js} +9 -11
  46. package/{index-4zgs8g1w.js → index-tsmjxph2.js} +1 -1
  47. package/{index-fmgbwsr5.js → index-x33gnap3.js} +4 -4
  48. package/{index-b978vs2e.js → index-xyrb7pk5.js} +3 -3
  49. package/index.js +21 -20
  50. package/{library.command-pwfx8dx2.js → library.command-v5cyaw9t.js} +6 -5
  51. package/{localRegistry.command-r4zjx5py.js → localRegistry.command-53t758pt.js} +10 -9
  52. package/{module.command-0t3ewata.js → module.command-pq6w84xg.js} +10 -9
  53. package/{package.command-7hmkxvpa.js → package.command-qkcs60ts.js} +6 -5
  54. package/package.json +3 -4
  55. package/{page.command-st6r4s0q.js → page.command-qj5bfnh9.js} +6 -5
  56. package/{primitive.command-1qkxpzsn.js → primitive.command-bw0nes6x.js} +11 -10
  57. package/{quality.command-hy5vt1bh.js → quality.command-g1s2wt8b.js} +532 -87
  58. package/{recipeScanner-tghkzrrt.js → recipeScanner-zrzev25x.js} +3 -3
  59. package/{repair.command-nakkq2zp.js → repair.command-mzwtejpk.js} +8 -7
  60. package/{routeSourceValidator-cncd92fv.js → routeSourceValidator-7fycdqyv.js} +3 -39
  61. package/{scalar.command-fjq272gs.js → scalar.command-rm6k3c65.js} +9 -8
  62. package/templates/app/env/env.server.debug.ts.template +1 -5
  63. package/templates/app/env/env.server.develop.ts.template +1 -5
  64. package/templates/app/env/env.server.local.ts.template +1 -5
  65. package/templates/app/env/env.server.main.ts.template +1 -5
  66. package/templates/app/page/styles.css.template +3 -3
  67. package/templates/appSample/srvkit/AuthGuard.ts +9 -5
  68. package/templates/appSample/srvkit/SessionInternalArg.ts +4 -3
  69. package/templates/crudPages/[__model__Id]/edit/page.tsx +1 -1
  70. package/templates/crudPages/new/page.tsx +1 -1
  71. package/templates/crudSinglePage/page.tsx +1 -1
  72. package/templates/env/_env.server.type.ts +7 -5
  73. package/templates/lib/db.ts +4 -2
  74. package/templates/lib/dict.ts +1 -1
  75. package/templates/lib/sig.ts +1 -1
  76. package/templates/lib/srv.ts +6 -2
  77. package/templates/lib/useClient.ts +9 -3
  78. package/templates/module/__Model__.Util.tsx +1 -1
  79. package/templates/module/__Model__.Zone.tsx +3 -5
  80. package/templates/workspaceRoot/AGENTS.md.template +0 -831
  81. package/templates/workspaceRoot/CLAUDE.md.template +19 -0
  82. package/templates/workspaceRoot/biome.json.template +4 -217
  83. package/{typeChecker-kravn7ns.js → typeChecker-g6bh0xfv.js} +1 -1
  84. package/typecheck.proc.js +1 -1
  85. package/{workflow.command-jswbgk90.js → workflow.command-g2cgcgxn.js} +16 -15
  86. package/{workspace.command-h9wwcjpt.js → workspace.command-f7jerqb2.js} +70 -52
  87. package/templates/libRoot/base/baseLogic.ts +0 -14
  88. package/templates/libRoot/base/index.ts +0 -8
  89. package/templates/workspaceRoot/.cursor/rules/akan-page-routing.mdc.template +0 -43
  90. package/templates/workspaceRoot/.cursor/rules/akan-sync-conventions.mdc.template +0 -33
  91. package/templates/workspaceRoot/.cursor/rules/application-test-commands.mdc.template +0 -16
  92. package/templates/workspaceRoot/.cursor/rules/change-scope.mdc.template +0 -12
  93. package/templates/workspaceRoot/.cursor/rules/client-server-boundaries.mdc.template +0 -17
  94. package/templates/workspaceRoot/.cursor/rules/coding-comments.mdc.template +0 -25
  95. package/templates/workspaceRoot/.cursor/rules/coding-style.mdc.template +0 -44
  96. package/templates/workspaceRoot/.cursor/rules/domain-module-conventions.mdc.template +0 -17
  97. package/templates/workspaceRoot/.cursor/rules/format-lint.mdc.template +0 -12
  98. package/templates/workspaceRoot/.cursor/rules/lint-enforced-rules.mdc.template +0 -18
  99. package/templates/workspaceRoot/.cursor/rules/module-file-playbook.mdc.template +0 -61
  100. package/templates/workspaceRoot/.cursor/rules/naming-conventions.mdc.template +0 -15
  101. package/templates/workspaceRoot/.cursor/rules/react-component-style.mdc.template +0 -21
  102. package/templates/workspaceRoot/.cursor/rules/repo-overview.mdc.template +0 -17
  103. package/templates/workspaceRoot/.cursor/rules/scalar-modeling.mdc.template +0 -58
  104. package/templates/workspaceRoot/.cursor/rules/secrets-and-env.mdc.template +0 -13
  105. package/templates/workspaceRoot/.cursor/rules/service-signal-conventions.mdc.template +0 -83
  106. package/templates/workspaceRoot/.cursor/rules/srvkit-adapters-and-guards.mdc.template +0 -50
  107. package/templates/workspaceRoot/.cursor/rules/typescript-imports.mdc.template +0 -16
  108. /package/{index-r24hmh0q.js → index-yb2wsc7f.js} +0 -0
@@ -0,0 +1,1193 @@
1
+ ## Repo Overview
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.
13
+
14
+ ## Change Scope
15
+
16
+ - Keep edits scoped to the requested task and the directly related files.
17
+ - Do not reformat unrelated files or rewrite nearby code just for style.
18
+ - Do not revert or overwrite unrelated user changes in a dirty working tree.
19
+ - Prefer established nearby patterns over introducing a new abstraction.
20
+ - Add new abstractions only when they remove real duplication or match an existing project pattern.
21
+
22
+ ## Lint-Enforced Rules (These Break The Build)
23
+
24
+ Enforced by `biome.json` and the grit plugins in `pkgs/@akanjs/devkit/lint/`. Several of them produce output
25
+ that looks wrong; do not "fix" it back.
26
+
27
+ - **Never hand-order Tailwind classes.** `nursery/useSortedClasses` is an error and also sorts the string
28
+ arguments to `cn()`. Sorter output such as `font-bold text-2xl text-foreground` or
29
+ `border-foreground/5 border-t` is correct. Write the classes in any order, run the formatter, leave the result.
30
+ - **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as
31
+ no CSS and fail lint (`no-raw-palette-class.grit`, `no-arbitrary-color.grit`, `no-daisyui-legacy-class.grit`,
32
+ `no-inline-color.grit`): raw palette classes (`bg-blue-500`), arbitrary color values (`bg-[#3b82f6]`), daisyUI
33
+ legacy classes (`btn-primary`, `card-body`), and color literals in `style={{...}}`. Use semantic tokens
34
+ (`bg-primary`, `text-foreground/70`). A legitimate fixed color (OS-chrome mockups, data-viz) takes a
35
+ `// biome-ignore lint/plugin: <reason>` with the reason spelled out. `apps/akan/page/v1/**` is excluded.
36
+ - **Never `throw new Error`.** Throw `new Err("<module>.error.<key>")` and register the key as `[en, ko]` in that
37
+ module's dictionary `.error({})`. Import `Err` from `"../dict"` on the server and from `"@libs/<lib>/client"` or
38
+ `"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts `*.test.ts`, `*.spec.ts`, `*.constant.ts`,
39
+ `common/**`, and `apps/akan/env/**` — `common/` and `env/` have no legal `Err` import path, so keep throwing code
40
+ out of them.
41
+ - **Never import a third-party package** from `page/**`, from any barrel, or from any
42
+ `*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
43
+ (`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims such as
44
+ `libs/<lib>/base/<pkg>.ts` and `libs/<lib>/webkit/<hook>.ts` exist for exactly this reason — they are
45
+ load-bearing, not cruft. Do not delete them.
46
+ - **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
47
+ `*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
48
+ `#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
49
+ - **No `console.log` / `console.debug`.** Only `assert`, `error`, `info`, and `warn` are allowed. Server code uses
50
+ the injected `this.logger.*` or `new Logger("ClassName")`.
51
+ - **Never write a `//!` marker in browser-reachable code** — `ui/`, `webkit/`, `common/`, `page/**/*.tsx`,
52
+ `*.constant.ts`, `*.store.ts`, and the five module component suffixes (`no-bang-comment-in-client.grit`). Bun
53
+ classifies `//!` and `/*!` as legal comments and keeps them through minification, so the note ships to every
54
+ visitor. Use `// FIXME:` there; `//!` stays legal in server, `srvkit/`, and CLI files.
55
+ - **Never return a value from a store action** (`no-return-in-store-action.grit`). Every method of a `store(...)`
56
+ class dispatches through `st.do.<action>()`, which is typed `void` / `Promise<void>`, so the value is
57
+ unreachable — write it into state with `this.set({ ... })`. A bare `return;` guard, a `return` inside a nested
58
+ callback, a getter, and a `static` helper are all still fine.
59
+ - **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
60
+ - **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
61
+ roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server has
62
+ to hand across the boundary; take `bannerId: string` and read the model from the store instead. Three shapes are
63
+ exempt because none of them is an instance: `cnst.<Enum>["value"]`, whose indexed access resolves to a string
64
+ union; a `ClientInit` / `ClientView` / `ClientEdit` type argument, which the framework maps to `GetStateObject<…>`
65
+ plain data; and a `ModelsProps<cnst.Setting>` type argument, whose only use of the model is the `onClickItem`
66
+ callback, so nothing model-shaped ever lands on a prop. `ModelProps<"setting", cnst.LightSetting>` is *not* exempt
67
+ — that one spreads the model onto the props themselves, and `Unit` / `View`, which take it, are server components
68
+ this rule does not cover. Any *other* indexed access is still flagged — `cnst.Banner["image"]` is a `File`.
69
+ **Only prop positions are read** — a `*Props` interface or type alias, and the inline object type on the
70
+ component's own parameter. A `cnst` type that never leaves the file is not a boundary crossing and stays legal: a
71
+ local annotation, a callback parameter the framework itself types with the model
72
+ (`renderItem={(ticket: cnst.LightTicket) => …}`), a module-scope helper, a non-`Props` local shape, and the props
73
+ of a component nested inside another one. A function-typed prop (`onPick?: (t: cnst.LightTicket) => void`) is
74
+ exempt for the same reason — a closure cannot cross the RSC boundary at all, so whoever passes it is a client
75
+ component already holding the value.
76
+ - **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
77
+ `onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}` —
78
+ generated field setters take exactly one value — but the arrow is a fresh anonymous closure, so the control
79
+ emits no `data-akan-action` and publishes no agent tool for the field. The failure is silent and the two lines
80
+ read the same, which is why it is a lint error. A wrapper that transforms the value
81
+ (`st.do.setNameOnX(toCamelCase(name))`), adds a statement, or writes a nested path with `writeOnX` is doing
82
+ something a reference cannot and stays legal — publish that one with an explicit `st.tool`. Scoped to
83
+ `{apps,libs}/**/*.tsx`; a typed parameter is not matched, so it under-reports rather than misfiring.
84
+ - **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
85
+ `../map/map.constant` are the sanctioned exception.
86
+ - **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
87
+ `.tsx`) may not import a `*.document.ts` / `*.dictionary.ts` / `*.service.ts` / `*.signal.ts`, `srvkit/`, a
88
+ package `server` entrypoint, or the `db` / `srv` / `sig` / `dict` / `option` / `useServer` barrels
89
+ (`no-import-server-in-client.grit`). Server files (those four suffixes plus `srvkit/`) may not import a
90
+ `*.store.ts`, a module component, `ui/`, `webkit/`, a package `client` entrypoint, or the `st` / `store` /
91
+ `useClient` barrels (`no-import-client-in-server.grit`). Shared files — `common/` and `*.constant.ts` — are held
92
+ to **both**, so they reach neither side. `import type` is erased before bundling and stays legal in every
93
+ direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs/**`: `pkgs/akanjs/**`
94
+ implements the boundary and is where the two graphs legitimately meet.
95
+ - **Server-component discipline** is enforced on `page/**`, `*.Unit.tsx`, and `*.View.tsx`
96
+ (`no-import-client-functions.grit`, `no-use-client-in-server.grit`, `non-scalar-props-restricted.grit`).
97
+ - `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose: `key={idx}` for embedded scalars and
98
+ short dependency arrays are intentional, not oversights.
99
+ - **A grit plugin diagnostic is suppressed as `lint/plugin`, not `plugin`** — `// biome-ignore lint/plugin: <reason>`
100
+ for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form Biome's
101
+ own category name suggests does nothing. Suppress a plugin only where the rule is genuinely wrong for the file, and
102
+ say why: the module-convention plugins (`no-import-external-library`, `no-deep-internal-import`, the store/signal
103
+ ones) apply to `apps/**` and `libs/**` only, so a plain package under `pkgs/` never needs the escape hatch.
104
+ - **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome 2.5.8 does not report the parse
105
+ error; it falls back to discovery and aborts on whatever nested config the walk finds, typically inside a directory
106
+ `files.includes` excludes. Rename the file to `biome.jsonc` to document a disabled rule; `akan lint` pins the config
107
+ path either way, so it reports the parse error on the offending line.
108
+ - **`akan lint` prints up to 200 diagnostics** (`--maxDiagnostics <n>`, `0` for no limit). Biome's own default is 20
109
+ with no count, which reads as progress when the mix of findings merely changed.
110
+
111
+ ## Coding Style (`**/*.{ts,tsx}`)
112
+
113
+ - For large units of work, prefer declaring a class and running the flow through an instance instead of scattering many standalone functions.
114
+ - Prefer class methods or `static` methods over unrelated top-level helper functions when the logic belongs to a class-level workflow.
115
+ - 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.
116
+ - In files that declare a class, avoid top-level functions or variables when they can reasonably live inside the class.
117
+ - Prefer `const` function expressions over `function` declarations unless hoisting, overloads, generators, or framework conventions make `function` the better fit.
118
+ - Prefer declaring only one class per file; split the file when two or more class declarations are needed.
119
+ - 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`.
120
+ - Avoid keeping exported functions that only instantiate a class and immediately call one method. Prefer migrating callers to instantiate the class directly.
121
+ - Except for React component files or convention files, TypeScript filenames should use camelCase.
122
+ - 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.
123
+
124
+ ### File Size And Duplication
125
+
126
+ - Keep files small. The house median is well under 50 lines; split a component before it reaches ~150 lines rather than adding section comments.
127
+ - 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.
128
+ - Never add a sibling helper file inside `lib/<model>/`. Helpers go to `common/`, `webkit/`, `srvkit/`, or `ui/`.
129
+ - 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.
130
+
131
+ ### TypeScript Shape
132
+
133
+ - Use `interface` for object shapes and `type` only for unions and aliases.
134
+ - 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.
135
+ - Never use a non-null assertion. Narrow with `?.`, an early return, or a type predicate such as `.filter((id): id is string => !!id)`.
136
+ - Escape with `as unknown as T`. Never `as any`.
137
+ - Never annotate a component's return type. Annotate a helper only when the return is a union, a tuple, or a type predicate.
138
+ - 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.
139
+ - Async functions carry no `Async` suffix.
140
+
141
+ ### Test Code
142
+
143
+ - Write TypeScript tests with Bun's test runner and import `describe`, `expect`, and `test` from `bun:test`.
144
+ - Keep tests colocated with the source they cover using `*.test.ts` or `*.spec.ts`, following the existing nearby pattern.
145
+ - Prefer focused behavior tests for public contracts and edge cases over implementation-detail assertions.
146
+ - 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.
147
+ - 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()`.
148
+ - `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 shared lib directly.
149
+ - A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
150
+
151
+ ## Comments
152
+
153
+ Do not narrate code. Do document the thing the code cannot say. Both halves are the rule.
154
+
155
+ - Never add a comment that restates the identifier, the signature, or the control flow.
156
+ - Prefer clear names and structure so ordinary logic needs no explanation.
157
+ - Do not add JSDoc, section banners, or "why/how" comments for ordinary logic.
158
+ - 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.
159
+ - 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.
160
+ - In-code markers:
161
+ 1. `TODO` — unfinished work that must be tracked in-code
162
+ 2. `FIXME` — known broken or incorrect behavior that must be fixed
163
+ 3. `XXX` — dangerous / surprising hazard that a reader must not miss
164
+ 4. `//!` — disabled or must-fix code. **Server, `srvkit/`, and CLI files only.** Bun's bundler treats `//!`
165
+ (and `/*!`) as a legal comment and keeps it through minification, so in browser-reachable code the note
166
+ ships verbatim to every visitor. Use `// FIXME:` there instead; `no-bang-comment-in-client.grit` enforces it.
167
+ 5. `//?` — an explanatory aside
168
+ 6. `//*` — a design note
169
+ 7. Deletion caution — warn why removing a line or block would break something non-obvious
170
+ - Keep allowed comments one short line when possible.
171
+ - Every suppression carries a reason: `// biome-ignore lint/<rule>: <why>`. Never a bare disable block.
172
+ - Match nearby file style: if the surrounding code has few comments, keep it that way.
173
+
174
+ ## TypeScript And Imports (`**/*.{ts,tsx}`)
175
+
176
+ - Use Bun and ESM assumptions from the root `tsconfig.json`.
177
+ - Prefer path aliases over deep relative imports when crossing package boundaries.
178
+ - Use `akanjs/*` for framework facets, `@apps/*` for apps, `@libs/*` for shared libs, and `@contract/*` for contract code.
179
+ - Respect existing client/server entrypoints such as `@libs/shared/client`, `@libs/shared/server`, `@apps/akasys/client`, and `@apps/akasys/server`.
180
+ - Let Biome organize imports instead of manually reshuffling unrelated imports.
181
+ - 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.
182
+ - 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.
183
+
184
+ ## Formatting And Linting
185
+
186
+ - Use Biome as the formatter and linter.
187
+ - Format with `bun run akan lint <appName>` from the repo root.
188
+ - Keep formatting consistent with `biome.json`: 2-space indentation, 120 line width, and double quotes for JS/TS.
189
+ - Avoid adding new `console` usage except accepted methods such as `console.error`, `console.info`, and `console.warn`.
190
+ - Do not make broad formatting-only changes in unrelated files.
191
+
192
+ ## Client / Server Boundaries (`apps/**`, `libs/**`, `pkgs/akanjs/**`)
193
+
194
+ - Use `"use client";` at the top of client component files.
195
+ - Be careful when importing client-only code from page or layout modules.
196
+ - Keep page props serializable unless the existing route pattern clearly allows otherwise.
197
+ - 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.
198
+ - Preserve established domain file roles such as `.document.ts`, `.service.ts`, `.store.ts`, `.constant.ts`, and `.client.ts`.
199
+ - When unsure, inspect nearby files in the same app or package before introducing a new boundary pattern.
200
+
201
+ ## SSR First — Server Rendering Is The Default
202
+
203
+ Akan is SSR-first. Every JSX element that renders on the server ships as HTML and costs nothing to hydrate;
204
+ every element behind `"use client"` ships twice — as markup and as bundled JS that must re-run in the browser.
205
+ The boundary is not about which file *may* be client, it is about **how little** ends up on the client side.
206
+
207
+ **The default is server. `"use client"` is a cost you justify per component, not a habit.** A component earns the
208
+ directive only by using a client-only capability: a React hook, a JSX event handler, the store (`st.use.*` /
209
+ `st.do.*`), a browser global, or a client-only third-party package. Rendering markup, reading a param, calling
210
+ `l()`, and mapping over data are all server work.
211
+
212
+ Measure before and after with `akan quality ssr` (`--format json` for tooling). It prints the server render share
213
+ per app and lib — server-rendered JSX elements over total — and the SSR warnings below. Treat **50% server share
214
+ as the floor** for an app or lib and a **falling share as a regression**: if a change moves markup to the client,
215
+ say why in the PR or move it back.
216
+
217
+ ### What `akan quality ssr` Flags
218
+
219
+ | Rule | Means |
220
+ |---|---|
221
+ | `akan.ssr.unnecessary-use-client` | The directive is there but nothing in the file needs it. Delete it. |
222
+ | `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. |
223
+ | `akan.ssr.client-static-markup` | A large subtree wraps one or two interactive touches. Split it: interaction stays client, markup goes server. |
224
+ | `akan.ssr.client-mount-load` | A `useEffect(…, [])` loads server data. The route can fetch it before the first byte. |
225
+ | `akan.ssr.module-missing-server-view` | A module renders only from `Template`/`Zone`/`Util` and has no `Unit`/`View` at all. |
226
+ | `akan.ssr.template-client-state` | A `Template` holds form state in `useState` instead of the store. |
227
+
228
+ A third-party client package or an `index_.tsx` `lazy()` boundary is a legitimate reason for the directive and is
229
+ not flagged. Interaction-driven `fetch.*` (a lookup inside `onClick`) is not flagged either — only mount-time loads
230
+ are, because those are the ones the server could have done.
231
+
232
+ ### Server-Side Implementation Playbook
233
+
234
+ **① Wrap the interaction, not the UI.** The smallest useful client component is a shell that adds one behaviour and
235
+ renders `children` untouched. The children stay server components, so the markup inside them never reaches the
236
+ bundle. `libs/shared/ui/Only/User.tsx` is the shape: it reads auth state on the client and returns `{children}`.
237
+
238
+ ```tsx
239
+ "use client";
240
+ export const ClickWrapper = ({ children, onPick }: ClickWrapperProps) => (
241
+ <div onClick={onPick}>{children}</div>
242
+ );
243
+ ```
244
+
245
+ **② Split compound components so panels stay on the server.** A tab, accordion, or disclosure needs client state
246
+ only for *which* part is visible — never for what the parts contain. Split into a context provider plus menu and
247
+ panel pieces, and take panel content as `children`. `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in `akanjs/ui`
248
+ is exactly this: only the provider and the menu hold state, and `<Tab.Panel>` renders its children as-is, so a
249
+ server `Unit`/`View` passed in stays server-rendered. Never reach for one `"use client"` file with a mode
250
+ `useState` and every panel body inlined.
251
+
252
+ **③ Sync state instead of fetching it.** A server component cannot hold state, so render the initial data on the
253
+ server and hand it across the boundary as a serializable object. That is what `init` / `view` props are: the route
254
+ calls `fetch.initXInY(...)` / `fetch.viewX(...)`, passes the result into a `Zone`, and `Load.Units` / `Load.View`
255
+ hydrate the store from it. Never replace that with a `useEffect(…, [])` that fetches on mount — it renders an empty
256
+ shell, hydrates, then round-trips for data the server already had.
257
+
258
+ **④ Push the boundary down to the leaf that needs it.** When a `Zone` reads the store, it should hold *zero*
259
+ markup and delegate to a server `View`. `User.Zone.Self` is one line — `st.use.self()` into
260
+ `<User.View.General user={self} />` — so the whole detail surface renders server-side wherever a route uses the
261
+ `View` directly.
262
+
263
+ **⑤ Hand the promise across, not the awaited value.** `ClientInit` / `ClientView` are `PromiseOrObject<T>`, so a
264
+ route may pass an unawaited `fetch.initX(...)`; `Load.*` renders a skeleton and resolves it. `await` in the route
265
+ blocks the shell for data the page needs immediately; passing the promise streams the rest. Independent fetches
266
+ still go through one `Promise.all`.
267
+
268
+ **⑥ Use named `ReactNode` slots, not just `children`.** A client shell can take several server-rendered subtrees:
269
+ `Layout.Navbar` accepts `title`, `back`, `left`, `right`, and `children`, so a client navbar composes server
270
+ content in five places instead of absorbing it.
271
+
272
+ **⑦ Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>` (`isNew()`,
273
+ `canWrite(user?)`, `formatTimes()`), and enum→class lookups belong in a module-scope `as const` map. Both sides
274
+ call the same method, so the server can render the result — a client component that exists only to compute a label
275
+ is markup in the wrong place.
276
+
277
+ **⑧ Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any HTML is
278
+ sent. A client-side auth check costs a hydration round-trip and flashes the wrong UI first.
279
+
280
+ **⑨ Prefer CSS over client state for pure visibility.** Toggling with a `data-*` attribute plus `group-data-[…]`
281
+ variants (see `libs/util/ui/Grid/*`) or with `<details>`/`<summary>` keeps both branches server-rendered. Reach for
282
+ `useState` when the state is real, not when a variant would do.
283
+
284
+ **⑩ Keep the heavy island out of the first load.** A large client-only widget goes behind the
285
+ `ui/<Folder>/index_.tsx` + `lazy()` pair so the server renders the page around it. `usePage()` and `l()` work in
286
+ server components, so translation never forces a boundary.
287
+
288
+ Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`, or
289
+ `akan guideline show ssrRule`.
290
+
291
+ ## React Components And Styling (`**/*.tsx`)
292
+
293
+ - Components are `export const X = ({ … }: XProps) => { return (…); };` — arrow const with a block body. `export default` is reserved for pages, layouts, and `lazy()` targets.
294
+ - Never `React.FC`, never `defaultProps`, never `PropsWithChildren`. Defaults go in the destructuring (`prefix = ""`); children are typed `children: ReactNode`.
295
+ - `"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.
296
+ - 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.
297
+ - 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.
298
+ - 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`.
299
+ - 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.
300
+ - Read with `st.use.*` and write with `st.do.*`. Client components do not call `fetch.*`.
301
+ - 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"`).
302
+ - Multi-slot components take extra named props (`wrapperClassName`, `bodyClassName`), never a `classNames` object.
303
+ - Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`. Escalate the map to `webkit/` when a second module needs it.
304
+ - Use `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
305
+
306
+ ## Naming And Language
307
+
308
+ - 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`).
309
+ - 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`.
310
+ - Slice and filter names are prepositional: `inOrg`, `inProject`, `inPeriod`, `byStatuses`, `ofPortfolio`. Never `getXInY`, never `listX`.
311
+ - Handlers are `onX` props taking inline arrows. Do not extract a `handleX`.
312
+ - 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`.
313
+ - 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.
314
+ - 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`.
315
+ - 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 `다.`.
316
+
317
+ ## Domain Module Conventions (`apps/**/lib/**`, `libs/**/lib/**`)
318
+
319
+ - Organize business concepts as domain folders under `lib/`; keep related schema, service, signal, store, and UI files together.
320
+ - 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`.
321
+ - Use PascalCase React component files such as `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.View.tsx`, `<Model>.Zone.tsx`, and `<Model>.Util.tsx`.
322
+ - Treat `constant.ts`, `dictionary.ts`, and `signal.ts` as shared contract files that should avoid platform-specific dependencies.
323
+ - Keep backend persistence/query logic in `.document.ts` and domain business orchestration in `.service.ts`.
324
+ - 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.
325
+
326
+ ### Module File Playbook
327
+
328
+ **`<model>.constant.ts`** — five classes in order with one blank line between them and `enumOf("camelName", [...] as const)`
329
+ classes above: `XInput → XObject → LightX → X → XInsight`. Write `XInsight` even when it is empty. Put display and
330
+ predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`, `isCancellable()`) — the Light class is the
331
+ one both server and client hold, so shared logic belongs there instead of in a util module. This is the most commonly
332
+ missed rule in the codebase. Collection-level helpers go `static` on the full model. Give any field whose business
333
+ meaning is not obvious a short trailing comment.
334
+
335
+ **`<model>.document.ts`** — fixed order: `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
336
+ with `sort: {}` always present. Chain methods validate → mutate → `return this`, and never `save()`; the caller saves,
337
+ so chains compose (`org.removeUser(id).removeInvite(id).save()`). Put a one-line comment above each stating the
338
+ transition. Atomic counters live on the Model class with the updater-callback form, returning `!!modifiedCount`.
339
+ Indexes and derived totals go in `static override _onSchema`, not in the service. **Removal is always soft** — the
340
+ model facade's `removeMany(query)` and the store's `removeManyByQuery` stamp `removedAt` like `remove(id)` does; the
341
+ framework has no hard delete for a model table, and `delete` is deliberately left unused so it can mean one later.
342
+ The facade keeps `Many`/`One` spelled out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`):
343
+ a bare `update`/`remove` would read like the document-path `update(id)` / `doc.remove()` while hitting every match.
344
+ Only the count was shortened — `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)`
345
+ and `removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no
346
+ hooks either, so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
347
+
348
+ **`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
349
+ explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
350
+ `_postCreate`, not inline. Fire-and-forget is explicitly `void`-ed. Order deliberately: load every referenced document,
351
+ then save, then notify. Return `null` / `false` for "not allowed" or "not found" and let the signal decide whether that
352
+ is an error.
353
+
354
+ **`<model>.signal.ts`** — `XInternal` → `XSlice` → `XEndpoint`, all three declared even when empty. `exec` is a one-liner
355
+ delegating to the service.
356
+
357
+ **`<model>.store.ts`** — write a custom action only for a toast, an optimistic update, or a multi-field write; most
358
+ stores need none, because state and CRUD actions are generated. The body is three lines: `await fetch.X` →
359
+ `this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
360
+ `this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
361
+ through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
362
+ nothing** — `st.do.<action>()` is typed `void` / `Promise<void>`, so hand the result to `this.set({...})` rather
363
+ than returning it (`no-return-in-store-action.grit`); a bare `return;` guard stays fine. **Never
364
+ `import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
365
+
366
+ **`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
367
+ `.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
368
+ Name every argument in `.arg()`, including framework-supplied `skip` / `limit` / `sort`. Use `modelDictionary`,
369
+ `scalarDictionary`, or `serviceDictionary` to match the module kind.
370
+
371
+ **`<module>.abstract.md`** — a title line, one declarative sentence naming what the module owns, a `## Rules` list of
372
+ two to five invariants the code cannot show, and an optional workflow arrow chain
373
+ (`draft -> signed -> active -> completed`). Never restate field lists or types. Update it whenever an invariant or
374
+ workflow changes.
375
+
376
+ ## Service And Signal Conventions (`*.{service,signal}.ts`, `server/**`)
377
+
378
+ - Keep domain business operations in `.service.ts` classes built with `serve(...)`.
379
+ - Keep execution contracts and triggers in `.signal.ts` classes built with `internal(...)`, `slice(...)`, and `endpoint(...)`.
380
+ - Use `Internal` for internal triggers such as init, interval, cron, or queue jobs.
381
+ - Use `Slice` for typed data views that feed client stores and zones; keep each slice focused on one purpose.
382
+ - Use `Endpoint` for query and mutation contracts exposed to callers.
383
+ - Connect external APIs or infrastructure through adapters, usually under `srvkit/`, and inject them into services instead of importing vendor clients directly into domain logic.
384
+
385
+ ### Guards And Transports
386
+
387
+ - 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()`.
388
+ - 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.
389
+ - 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.
390
+ - 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.
391
+
392
+ ### Authorization Defaults
393
+
394
+ - **Every `slice()` takes an explicit `{ guards: {…} }` second argument, and `root:` is always `Admin`.**
395
+ - **Every custom `mutation` / `query` / `message` names its own `guards: [...]` array.** Never rely on the slice default. `Public` belongs on a slice `get:`, never on a mutation.
396
+ - **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.
397
+ - 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.
398
+ - 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.
399
+ - **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"`.
400
+ - The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
401
+ - 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.
402
+ - Services re-check ownership even when a guard already gated the call — two independent gates.
403
+ - `srvkit/guards.ts` earns real comments: explain what would leak without each guard.
404
+
405
+ ### Signal Body Types
406
+
407
+ - `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
408
+ - Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
409
+ - `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])` (see `libs/shared/lib/file/file.signal.ts`). It is not a model field type.
410
+
411
+ ### Mutation HTTP Verb
412
+
413
+ - A `mutation` is `POST`. `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several verbs
414
+ — a `query` GET and a `mutation` POST on the same custom `path` are mounted side by side. Two endpoints claiming
415
+ the same path **and** verb fail the boot rather than silently shadowing one another.
416
+ - Reach for it only when a foreign wire protocol forces the verb (a client you cannot change that sends
417
+ `PATCH /rest/v1/<table>`). Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
418
+ whatever is declared, so nothing needs restating per caller.
419
+
420
+ ### Reserved Endpoint Names
421
+
422
+ - 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.
423
+ - 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.
424
+
425
+ ### Slices, Queries, and Hydration
426
+
427
+ - A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
428
+ - Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
429
+ - 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`).
430
+ - **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`).
431
+ - Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
432
+ `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
433
+ query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use them on a
434
+ model that carries no removal side effect; otherwise remove documents one at a time.
435
+ - **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
436
+ "archived" })`. The patch cannot trail the filter args — a filter's own args may be optional and no tuple type
437
+ puts a required element after those — so it lands on a terminal `.set()`, mirroring the `UPDATE … SET …` it
438
+ compiles to. Building the chain touches nothing; only `.set()` runs a query.
439
+ - `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
440
+ and there is no way to change that. They also report only counts, never which row they touched, so they are for
441
+ "there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
442
+ - **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
443
+ model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
444
+ throws at boot instead (`assertFilterFitsCrud`).
445
+
446
+ ### Text Search In A Filter — `q.search()`
447
+
448
+ - Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
449
+ q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
450
+ `insightBySearch` come for free — you do **not** need a slice to make search usable.
451
+ - **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
452
+ client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk the
453
+ table. Leave that decision to the mounting app.
454
+ - `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
455
+ `q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery` — a query-level write
456
+ takes no join, so ignoring the node would silently widen the write to every other matching row.
457
+ - Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
458
+ search endpoint into a full listing.
459
+ - Order by relevance with the built-in `relevance` sort key. It is an empty sort map, which the store reads as
460
+ "unspecified": score order when a search join is present, `createdAt` descending otherwise. That fallback is the
461
+ compiler's own, not a model-defined default — redefining `latest` on the model does not change it.
462
+ - **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
463
+ client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
464
+ - Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
465
+ finite numbers positional over `["title", "desc", "tag", "filter"]`.
466
+
467
+ ### Service / Signal Injection
468
+
469
+ - 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`).
470
+ - 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.
471
+ - 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.
472
+
473
+ ### Adapters — `adapt()` And `plug()`
474
+
475
+ An injected singleton is an `adapt()` class in `srvkit/`. Write new adapters this way.
476
+
477
+ ```ts
478
+ export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, memory }) => ({
479
+ discordApi: use<DiscordApi>(),
480
+ workspaceRoot: env(() => `~/build/${getEnv().environment}`),
481
+ masterHost: plug(MasterHost),
482
+ tokenMap: memory(Map, { of: String }),
483
+ })) {}
484
+ ```
485
+
486
+ - 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`.
487
+ - **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.
488
+ - `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in `override async onInit()`.
489
+ - **`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.
490
+ - **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.
491
+
492
+ Conventions that hold for both shapes:
493
+
494
+ - Route every remote call through one private `#api<T>(path, init?)` with `signal: AbortSignal.timeout(20_000)`.
495
+ - Paginate with `for (let page = 1; ; page += 1)` broken by `if (pageItems.length < 100) break;`.
496
+ - Resolve secrets as `process.env.X ?? options.x ?? deterministicGenerator(...)` **inside a function**, never at module scope.
497
+ - Extend a function by appending an optional trailing parameter with a default, never by changing arity.
498
+ - 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.
499
+ - Release locks in `finally`. Load heavy optional dependencies through a module-level memoized promise (`puppeteerLoad ??= import("puppeteer")`).
500
+
501
+ ### Error Placement
502
+
503
+ - State-machine preconditions throw in `document.ts`; cross-document rules throw in `service.ts`; request-level policy belongs in `signal.ts` guards.
504
+ - Best-effort code returns a sentinel (`null`, `undefined`, `[0, 0]`, `{}`). There are no Result/Either wrappers.
505
+ - `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.
506
+ - 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.
507
+
508
+ ### MCP Exposure
509
+
510
+ Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
511
+ follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
512
+ that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
513
+ `Public`. `AKAN_MCP=false` takes the whole surface off. The reasoning is that the guards are already the
514
+ authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
515
+ switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
516
+ until somebody remembers it.
517
+
518
+ Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
519
+ `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, and `auth`. **Not `main.ts`**: the gateway
520
+ there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
521
+ mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
522
+ declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
523
+ `AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
524
+ `AKAN_MCP_LANGUAGE`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that
525
+ configures what the source does not, which the option overrides.
526
+ The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
527
+ has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
528
+ `AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
529
+ concatenation.
530
+
531
+ ```typescript
532
+ // apps/<app>/lib/option.ts
533
+ export const option = new AkanOption<ModulesOptions>().setMcp({
534
+ instructions: "Domain tools for the akan app. Start from taskInTodo.",
535
+ language: "en",
536
+ });
537
+ ```
538
+
539
+ ```typescript
540
+ // <model>.signal.ts — every one of these is an MCP tool or prompt, with no `mcp:` option anywhere
541
+ export class TaskSlice extends slice(
542
+ srv.task,
543
+ { guards: { root: Admin, get: SignedIn, cru: SignedIn } },
544
+ (init) => ({
545
+ // its own guards: the map above reaches base CRUD and the root slice, never a named slice — so a named slice
546
+ // that names none is refused rather than published, which is the one shape to watch for.
547
+ inTodo: init({ guards: [SignedIn] }).exec(function () {
548
+ return this.taskService.queryByStatuses(["todo"]);
549
+ }),
550
+ }),
551
+ ) {}
552
+
553
+ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => ({
554
+ startTask: mutation(cnst.Task, { guards: [SignedIn] })
555
+ .param("taskId", ID)
556
+ .exec(async function (taskId) {
557
+ return await this.taskService.startTask(taskId);
558
+ }),
559
+ reviewTask: prompt({ guards: [SignedIn] })
560
+ .param("taskId", ID)
561
+ .exec(async function (taskId) {
562
+ const task = await this.taskService.getTask(taskId);
563
+ return [Msg.user(`Review this task and suggest next steps.`), Msg.resource(`akan://task/${taskId}`, task)];
564
+ }),
565
+ })) {}
566
+ ```
567
+
568
+ - **The refusals are fail-closed**: **an endpoint that declares no `guards` at all** (nobody decided who may reach
569
+ it), **a mutation with no real `guards`** (`[Public]` is having none, spelled out — it answers true
570
+ unconditionally), `pubsub` and `message` (their internal args read a socket an MCP request does not have), an
571
+ `Any` or `Upload` return, a file upload, and **an argument typed `Any` that must be filled**.
572
+ A `prompt` refuses two more, because its `arguments` is one string per name with no schema beside it: a **list
573
+ argument**, which could never carry a second value, and **any `Any` argument** — a tool leaves that out of its
574
+ schema, and a prompt has no schema to leave it out of.
575
+ - **Every refusal is named in the boot log**: one `warn` per endpoint plus a `MCP catalogue: tools=… prompts=…`
576
+ count. Read that line first when a tool you expected is missing — and it is the *only* place the answer exists,
577
+ because there is no absent opt-in to notice. The API explorer badges the same rule per endpoint (`MCP` /
578
+ `MCP refused`), from the same shared implementation the catalogue runs.
579
+ - **An `Any` argument is left out of the published schema** rather than described as `{}` — it tells a model
580
+ nothing — and a value sent for one is refused by name, so the endpoint reads it as omitted. That is what happens
581
+ to the root list's raw `query` descriptor: read as sent, it would be an arbitrary filter over every model you
582
+ publish. Declare a named filter slice when an agent should narrow a list.
583
+ - **A nullable model return publishes no `outputSchema`**, and its empty answer ships as the text `null` with no
584
+ `structuredContent`. That field is an object by definition, so `null` cannot ride in it any more than an array
585
+ can — a list is wrapped as `{ items: … }` for the same reason — and a declared schema obliges every result to
586
+ match it, so a client SDK throws on the first call that finds nothing. A nullable *list* keeps its schema, and a
587
+ scalar return has no structured half at all: it ships as the value itself, not as JSON.
588
+ - **An `outputSchema` names no `hidden` or `secret` field.** Every response has both stripped, so publishing them
589
+ promises a property no answer can carry — and on a model like `user` the names are the leak. Your *input* schema
590
+ keeps them: they are legal to send, and the same model describes a request body.
591
+ - A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
592
+ message more helpful — the difference is what enumerates your private surface. A guard's refusal is generalized
593
+ the same way: the caller reads `You are not permitted to perform this action.`, never `Access denied by guard:
594
+ Admin`, which names your authorization structure to the one caller barred from it. A domain `Err` resolves
595
+ through the dictionary first and keeps its own words.
596
+ - The `readOnly` / `destructive` / `idempotent` hints a client renders are derived from the endpoint type and key
597
+ and are not configurable. Clients are told to distrust hints; they are never a gate.
598
+ - **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
599
+ whatever it declared, and reports each one in the boot log like any other refusal.
600
+ - OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
601
+ path, the spelling most clients try first). `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, and `AKAN_MCP_RESOURCE`
602
+ configure it; `insufficient_scope` is enforced only once `AKAN_MCP_SCOPES` is set. A token carrying no `aud` at
603
+ all is refused once `AKAN_MCP_AUTH_SERVERS` names an issuer — that issuer mints tokens for its other resources
604
+ too — and accepted while none is named, because a first-party Akan token is bound by app and environment.
605
+ - **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
606
+ description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
607
+ the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
608
+ their generated `Get X`. Write that model `.desc()` — it is the only text those entries can carry. There is no
609
+ `akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
610
+ with exposure derived from the guards the resolved catalogue is the only place that can answer.
611
+ - A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
612
+ Every other MCP client sends no `Origin` at all, and the one that does is matched against the forwarded host so
613
+ a proxy does not turn each call into a 403 — which is only as trustworthy as an edge that *overwrites* that
614
+ header. `AKAN_MCP_RESOURCE` pins the resource identifier where you cannot guarantee it.
615
+ - **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
616
+ - **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
617
+ unparseable or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
618
+ published schema and nothing on the wire enforces it — and so does a document that is not there, as
619
+ `No <model> found for the arguments given.` A `prompt`, having no `isError` to carry a refusal, answers `-32602`.
620
+ Only a real failure logs a stack; an agent can drive the rest at will.
621
+ - **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
622
+ wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
623
+ disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
624
+ method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
625
+ gone".
626
+ - **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
627
+ body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
628
+ rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
629
+ are derived from the catalogue, so a server with no prompts does not advertise `prompts`.
630
+ - **An expired or wrongly-audienced bearer token is refused up front**, so an agent is told to authenticate rather
631
+ than that the tool does not exist. Its **signature is not checked** — that needs your app's own secret — so a
632
+ token signed wrong, like an opaque one, still degrades to an anonymous caller.
633
+ - **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/light/{id}`, `akan://<model>/list` for the model's own
634
+ list, and `akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any
635
+ token there is one a slice could also be named. **Those four are the whole set**, so only the generated reads are
636
+ addressable: a custom endpoint keeps its tool and gets no resource template.
637
+ - **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
638
+ clients, so there is no `Accept-Language` negotiation.
639
+
640
+ **`prompt()`** is invoked by the *user* — a client renders it as a slash command — not chosen by the model. `exec`
641
+ returns `PromptMessage[]`, or a bare string that is wrapped into one user message; build them with `Msg.user` /
642
+ `Msg.assistant` / `Msg.link` / `Msg.resource` / `Msg.image` / `Msg.imageOf`. It takes `.param()` and `.search()`
643
+ only, because `prompts/get` sends a flat string map. **An embedded payload is masked by the model you name** —
644
+ `Msg.resource(uri, task, { model: cnst.LightTask })`, or `Msg.mask(cnst.LightTask, task)` for one piece of an
645
+ assembly. Taking the model as an argument is what makes a `{ ...doc }` spread maskable, since that and `toJSON()`
646
+ arrive with the class already gone; a value with no model named whose `hidden`/`secret` fields are populated is
647
+ **refused**, one level into a plain object too. **A `prompt` is also mounted as a
648
+ plain HTTP `GET` whether or not you enabled MCP**, because that route is what lets a web UI preview one — and it
649
+ is in your OpenAPI document like any other `GET`, answering the one fixed `PromptMessage[]` shape. MCP exposure
650
+ gates the catalogue, not the surface, so guard it
651
+ like any other read — and a prompt declaring no
652
+ `guards` at all is named in the boot log, while an explicit `[Public]` is a decision and stays quiet. Every `Msg` builder takes
653
+ optional `annotations` last (`audience`, `priority` 0..1, `lastModified`) — give the instruction a high `priority`
654
+ and its attachments a low one, or a client with a full window drops blocks by position and keeps the attachment
655
+ over the ask.
656
+
657
+ **`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
658
+ frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
659
+ websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
660
+ costs something to assemble.
661
+
662
+ ## In-Page Agent
663
+
664
+ Every akan app can host a component-level agent that reads the rendered screen and drives it. **A component
665
+ declaration is the surface, exactly**: `st.tool` publishes one action, and `st.use` / `st.sel` / `st.ref` make one
666
+ store key readable while the reading component is mounted. Nothing is derived from a store class — declaring a
667
+ method on a `store(...)` gives an agent nothing at all, because a lever the screen does not offer the user is not
668
+ one an agent may pull in their place, and a module's whole vocabulary published at once was noise the model paid
669
+ for. `Load` scopes, the route, and the live keys complete the context. The React core is the `use-agentic` package;
670
+ apps and libs never import it directly (`no-import-external-library`) — everything reaches them through `st.*` and
671
+ `akanjs/ui`.
672
+
673
+ - **Mount `<Agent.Chat />` once in a layout.** That is the floating chat, the approval card, and the client-side
674
+ loop. The default runner drives `runAgentTurn`, which the **framework serves on every app** — no lib to mount,
675
+ `AKAN_AGENT=false` takes it off — and negotiates streaming via `accept`, so assistant text arrives as it is
676
+ generated with zero app code. The endpoint is a stateless relay and **never executes tools**: every tool runs in
677
+ the caller's own browser session, gated by guards and the approval card. Its guard is `AgentRelayAccess`, which
678
+ **refuses every call until the app names a guard of its own** — the same answer `None` gives, with no boot
679
+ warning. A product with accounts names it in its `option.ts`, `option.setAgentAccess(SignedIn)`, taking the same
680
+ guard classes every endpoint takes (an array is ANDed, `null` clears what a library set); without one the chat
681
+ cannot spend the LLM key.
682
+ `persist` keeps the transcript across reloads (sessionStorage; `{ storage: "local" }` to outlive the tab),
683
+ default off. Re-skin through the `AgentChat` slot in `_overrides.tsx`.
684
+ - **The LLM is configured in `option.ts`, never through the environment.** `option.setLlm({ apiKey, model, host })`
685
+ — or `setLlm((options) => …)` to read the key out of the app's own env object, which is where a secret belongs —
686
+ fills whichever adaptor holds `LlmAdaptorRole`, reaching it as the `llmOption` use. The settings are the role's
687
+ rather than one provider's, so they survive a swap. **DeepSeek is the built-in default** (`deepseek-v4-flash` at
688
+ `https://api.deepseek.com`); with no `apiKey` the app still boots and the chat answers `llmUnavailable`. Swap
689
+ providers the way middleware is applied: `option.applyAdaptor(LlmAdaptorRole, ClaudeLlm)`, where the
690
+ implementation is an `adapt()` class in a `srvkit/` implementing `LlmAdaptor.chat(request, onDelta?)` — ignore
691
+ `onDelta` and the chat still answers whole.
692
+ - **A file the user attaches rides the message, and nothing is stored.** The composer takes a paperclip, a drop and
693
+ a paste; an image rides as bytes and a text file as text, which is all a browser reads with no dependency.
694
+ Everything else is the app's own reader — `<Agent.Chat attach={…} />`, one `File` in, a `MessageAttachment` or
695
+ `null` out — because extracting a PDF needs a parser and the framework carries attachments without depending on
696
+ one. It runs ahead of the built-in, so it is also where an image is downscaled before it costs a megabyte of
697
+ prompt. **What the provider cannot read is replaced by a note naming the file**, never dropped: an attachment the
698
+ model never saw is one it answers about from the filename. An adaptor declares `accepts: { image, document }` and
699
+ `AgentService.readable` degrades the rest, so a text-only provider needs no attachment code at all — DeepSeek
700
+ declares none, which is why an image against the default provider is refused out loud while an extracted PDF
701
+ works, `text` being readable by every model there is. **A `prompt()`'s `Msg.image` is the same wire shape** and
702
+ reaches the chat as an attachment rather than the literal `[image]` it used to become. Persisting keeps each
703
+ attachment's name and drops its content: web storage is a few megabytes, one screenshot fills a chunk of it, and
704
+ a save that fails is silent — so keeping the bytes would quietly stop keeping the transcript.
705
+ - **Speech is one engine contract and the framework's own policy.** `<Agent.Chat voice={engine} />` takes a
706
+ `VoiceEngine` — `listen(handlers)` and `speak(sentence)`, both cancellable — and the chat decides everything
707
+ else: a press-to-talk microphone whose transcript lands in the composer to be corrected, one utterance per
708
+ press, sentence-at-a-time reading, barge-in on the next press or on Stop, and markdown stripped so `**bold**`
709
+ is not pronounced. **A reply is read aloud only when the ask arrived by voice**, so a typed question never turns
710
+ on the speakers — and it needs no wire field, because how a message was sent is the composer's own business.
711
+ The contract is a subscription rather than `listen(): Promise<string>` on purpose: a promise fits push-to-talk
712
+ and nothing else, so hands-free could then only arrive as a breaking change. `useSpeech` in
713
+ `libs/util/webkit` is the engine — the browser's own recognition and synthesis on the web, the Capacitor
714
+ plugins in a WebView, **which has neither on Android or iOS**, so `speech.plugin.ts` declares the permission and
715
+ the packages the native build needs. An engine answering `available()` false renders no microphone at all,
716
+ the same rule as publishing no tool for a control the screen does not draw.
717
+ - **`attach` and `voice` both carry functions, so a server layout cannot pass either.** A closure does not cross
718
+ the RSC boundary — `non-scalar-props-restricted` says so on `page/**` — so an app that wants either mounts the
719
+ chat from a small client component in `ui/` that calls the hook. `apps/akan/ui/DocsAgentChat.tsx` is the shape.
720
+ - **A dialog's close is the dialog's own dismissal, not a state flip.** `closeDialogIn<Ns>` (and `Dialog.Close`)
721
+ run through whatever `Dialog.Modal` registered, so the agent takes the exact path the X button takes —
722
+ `confirmClose` still prompts and `onCancel` still fires. A close that only set `open` to false would skip both,
723
+ which is a different action wearing the same name.
724
+ - **`<Agent.Zone id="comments">` runs a second agent over one section, in parallel with the root.** Everything
725
+ mounted inside — `st.use` subscriptions, hook tools, Guides — belongs to that zone's own conversation *and*
726
+ stays visible to the root agent: **zones are views, never walls**, so wrapping a section costs the root nothing.
727
+ An `Agent.Chat` inside binds to the zone session automatically; a zone's `readScreen` reads only its own
728
+ `data-agent-zone` container; guides follow the layout cascade (ancestors and own, never a sibling's). Zone
729
+ membership is positional — there is no per-declaration zone key, so a lib component joins whatever zone the app
730
+ mounts it in.
731
+ - **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or a page — the render
732
+ tree is the cascade: nested Guides concatenate outer-to-inner and navigating away withdraws them. It is a
733
+ component, not a pageConfig field. Module `*.abstract.md` files are developer docs and are never served to the
734
+ agent.
735
+ - **Declare the tool beside the control that already does it.**
736
+ `st.tool("x", { desc }).arg("id", ID).exec(fn)` publishes one action and returns the callable to hand to
737
+ `onClick` — one handler for the person and the agent, which is the point: a button wired to an inline arrow can
738
+ be clicked by a person and by nobody else. `.exec()` is the only hook, so the chain completes in one
739
+ unconditional statement, and the callable carries `data-akan-action` like a store setter does. A `remove*` name
740
+ defaults to a confirm gate. Reach a store action from the body — `.exec((id) => st.do.removeX(id))` — which is
741
+ how an agent gets CRUD; `st.do` on its own reaches nobody.
742
+ - **A falsy name declares the tool without publishing it** — the callable still drives the click a person makes,
743
+ and the agent never learns the tool exists. That is the only way a conditional surface stays legal, because
744
+ `.exec()` is a hook and the declaration can never be skipped: withhold the name, not the call. `st.useState`
745
+ and `st.expose` take a falsy name the same way, and an unpublished callable carries no `data-akan-action` —
746
+ that attribute names a tool an agent can reach. **Publish a tool only where the screen already renders the
747
+ control**: a lever no one can pull by hand is not one to hand an agent, and every published tool is paid for in
748
+ every turn's prompt. The mirror of the same rule is why the control gets a tool at all.
749
+ - **An `enumOf` class is a complete argument type on its own**: `.arg("mode", TaskStatus)` publishes the values as
750
+ the argument's `enum`, refuses anything off them by name at call time, and narrows the `.exec` parameter to the
751
+ value union — nothing else to write, and the scalar (`string` / `integer` / `number`) comes from the values.
752
+ **A value set the *render* decides takes `.arg(name, type, { oneOf })`** instead, because `enumOf` registers
753
+ globally and a component cannot build one per render: pass the list it has — a slice's sort keys, the options a
754
+ prop carried — and it is published and enforced the same way. Neither reaches a set that fills in *after* the
755
+ first render, since a declaration is mount-static; put that in the tool's `guard`, which is re-read per call and
756
+ can name the current values in its refusal. **An argument type nothing can describe — a model class, `Any`, a
757
+ `Map` — withdraws the whole tool and says so on the console**, naming the tool, the argument and the type; the
758
+ callable still drives the click a person makes. It does not throw: a tool schema is built during render, and an
759
+ agent-tooling mistake that aborted the render would cost the route its server rendering. `st.useState`'s `set`
760
+ degrades the same way, to read-only.
761
+ - **A component that renders once per row never closes over its row's id — it takes the id as an argument.** A
762
+ tool that captured its own row would be fifty registrations of one name, forty-nine of them shadowed, and the
763
+ survivor would remove whichever row happened to mount last. Take the id instead — `removeTask(taskId)`, never
764
+ fifty `removeTask` — and every row's registration is then interchangeable, so a row component may publish after
765
+ all: say so with `shared: true` and the repeats are one declaration rather than a warned-about clash. The ids
766
+ come from the `<slice>.items` resource `Load.Units` and `Data.ListContainer` already expose. `shared` is a claim
767
+ about the *tool*, not a way to quiet a console: two rows whose tools would do different things (a different
768
+ `modal`, a different redirect) are not interchangeable, and that one wants a `namespace` or nothing at all.
769
+ - **`akanjs/ui` publishes its own controls, so an app writes nothing for them.** `Data.ListContainer` (and every
770
+ `Model.AdminPanel`) publishes its toolbar and its row verbs; `Model.NewWrapper` (so `Model.New` too) publishes
771
+ `new<Model>`; `Load.Units`, `Load.Pagination` and `Data.Pagination` publish `setPageOf<Model>`; `Layout.Sider`,
772
+ `System.SelectLanguage`, `Link.Back` and `System.ThemeToggle` publish the shell. **A component that can render
773
+ twice on one screen takes a `namespace` prop and publishes nothing without it** — `Tab`, `Dialog`,
774
+ `ScreenNavigator`, `Dropdown`. Pass one (`<Tab namespace="detail">`) and the tool becomes `switchTabInDetail`;
775
+ leave it off and that tab is invisible to the agent, because two tabs answering to `switchTab` would mean the
776
+ first to mount loses. A named `Dropdown` publishes `openDropdownIn<Ns>` / `closeDropdownIn<Ns>` and the state
777
+ `dropdownIn<Ns>`, and its trigger annotates whichever of the two its next click performs. `Model.NewWrapper`
778
+ takes the same prop but publishes without one, because its slice already names it — a second create trigger for
779
+ the same slice, opening a form seeded differently, is what needs the suffix.
780
+ - **The `Model.*` row wrappers publish their verb, taking the id.** `Model.EditWrapper`, `Model.ViewWrapper`,
781
+ `Model.RemoveWrapper` and `Model.Remove` publish `edit<Model>` / `view<Model>` / `remove<Model>` with a
782
+ `modelId` argument, so a list built from `Load.Units` and an app's own `Unit` reaches the same verbs an
783
+ `AdminPanel` does. `Model.SureToRemove` publishes the same — except under `typeNameToRemove`, where it
784
+ publishes nothing: that gate makes a person retype the model's name, an approval card is one click, and
785
+ offering the lever at a friction the screen does not have is not the same control.
786
+ - **A dropdown's menu is mounted from the first render and hidden while closed** — the deliberate opposite of the
787
+ modal rule below, because a menu is one click away rather than a surface of its own. A tool is declared by a
788
+ mount effect, so an unmounted menu is one whose row verbs and field setters do not exist yet: an agent asked for
789
+ one finds nothing, and no catalogue entry hints that opening the menu would help. `readScreen` still skips
790
+ hidden content, so the items themselves are read only after `openDropdownIn<Ns>` — what a closed menu publishes
791
+ is its tools, not its text. The cost is that `content` renders on page load, so a heavy panel belongs behind a
792
+ `Dialog` instead.
793
+ - **A modal publishes its verbs while it is open, and only then.** `Model.EditModal` publishes `submit<Model>`
794
+ and `cancelEditOf<Model>`, `Model.ViewModal` publishes `closeViewOf<Model>`, and `Model.ViewEditModal`
795
+ publishes `edit<Model>` / `submit<Model>` / `closeViewOf<Model>` — each from a subtree that mounts with the
796
+ open modal, never from the component that merely holds it. That is what makes a list legal: `Data.CardList`
797
+ renders one editor per row, and at most one of them is ever open, so one name is registered rather than fifty.
798
+ It also means the verb is absent from the catalogue while nothing is open, which is honest — and costs the
799
+ agent nothing, because the catalogue is re-read on the turn that follows the tool call that opened the form.
800
+ - **A form control publishes its own setter, and reading a form publishes one tool that fills several at once.**
801
+ Both are free: an app writes no `st.tool` for a form. A `Field.*` / `Input.*` / `Select` / `Switch` handed
802
+ `onChange={st.do.setTitleOnTask}` **by reference** publishes `setTitleOnTask` while it is on screen — the same
803
+ reference that earns `data-akan-action`, so the tool and the person press one function and an inline arrow
804
+ still publishes nothing. `st.use.taskForm()` adds `fillTaskForm(patch)`, which takes several fields in one call
805
+ and is the only way to reach a list, a map, or an embedded object, whose rows are written through
806
+ `writeOnTask(path, value)` and can carry no annotation. It is a patch: a field left out keeps its value.
807
+ `fillTaskForm` refuses a plain field whose control is not on screen and names the ones that are; a composite it
808
+ cannot see is let through, which is the one place an agent reaches a field the screen may not draw. Never a
809
+ relation (picked or uploaded, not typed), a base document field, or a `hidden`/`secret` one at any depth —
810
+ their reads are masked and a writer would be the door around that. `st.use.taskForm({ agent: false })`
811
+ withholds the patch tool; an inline arrow withholds a control's own.
812
+ **The patch writes each plain field through that control's own published tool, not the setter underneath it** —
813
+ which is what carries the control's `transform`, so a field cannot normalize one way for `setPhoneOnBizAccount`
814
+ and another way for `fillBizAccountForm`. Only a composite, having no control, dispatches its setter directly.
815
+ The patch tool is registered `shared`, because the entry is a pure function of the model: a form put on screen by
816
+ a shell that subscribes it (`Model.EditModal`) *and* by the `Template` inside it is one declaration twice over,
817
+ not a clash — so neither has to suppress the other, and neither is asked to.
818
+ - **A `disabled` control publishes nothing, so the agent never gets a lever the person cannot pull.** Every value
819
+ control reads it — `Field.*`, `Input.*`, `Select`, `Switch`, and the four relation pickers — and disabling a
820
+ mounted control withdraws its tool for as long as it stays disabled. One gate covers both writers: with no
821
+ control published, `fill<Model>Form`'s guard refuses that field too. `readScreen` says `(disabled)` beside the
822
+ control, from the native attribute or `aria-disabled`, so a refusal is something the agent could have read first
823
+ rather than a surprise. This is the same rule as publishing only where the screen renders the control, applied to
824
+ a control the screen renders but withholds.
825
+ - **Whatever the wrapper was for, there is a place to put it that is not the wrapper.** An inline arrow is the one
826
+ shape that publishes nothing, so each reason for writing one has its own home, and reaching for that home is what
827
+ keeps the field reachable:
828
+ - *normalize* — `(v) => set(formatPhone(v))` becomes the control's own `transform` prop, which every text and
829
+ number `Field.*` already takes (`Field.Phone` defaults it to `formatPhone`). `onChange` stays a reference, and
830
+ **`transform` runs on the agent's write too**, by both paths — the field's own tool and `fill<Model>Form`,
831
+ which goes through the control to get it — otherwise a person would store `010-1234-5678` and an agent the raw
832
+ digits. It normalizes one scalar, so an array control applies it per element and a cleared nullable field stays
833
+ null. It is the *control's* rule, though: a rule that must hold however the field is written — including a
834
+ composite path or a base-document write — belongs in `_postSet<Field>` below.
835
+ - *multi-write* — `(v) => { set(v); other(v); }` becomes a **`_postSet<Field>` method on the store**, and the
836
+ control keeps handing over the generated setter by reference. It runs right after the field is written, so it
837
+ reads the new value, and it reaches every other generated action with `this.` —
838
+ `_postSetToBiz(toBiz) { if (toBiz) this.addSendEmailsOnEstSheet(toBiz.sendEmails ?? []); }`. Nothing about the
839
+ control changes, so `data-akan-action` **and** `data-akan-state` both survive, and the rule now fires for every
840
+ writer — the person, the agent, `fill<Model>Form` — which is what a rule about a field should do.
841
+ **A generated action cannot be overridden, so do not try.** They all come from mapped types, and a mapped type
842
+ produces *properties*: a subclass method of the same name is `TS2425`, optional or not, and the two shapes
843
+ TypeScript does allow — a class field and a getter — are both skipped by `StoreRegistry.register`, which only
844
+ collects prototype descriptors holding a function. There is no legal middle, which is exactly why the hook
845
+ carries a leading `_` and no model suffix: a name no mapped type can produce is the only name a subclass may
846
+ declare. It cannot be typed either, for the same reason, so a misspelled field is named on the console at
847
+ registration instead. Calling a generated action *from* a custom one is fine and always was — `this.setXOnY(v)`
848
+ typechecks anywhere.
849
+ - *nested path* — `(v) => writeOnTask("payments.3.name", v)` has no home and needs none: an embedded row is
850
+ unannotatable by design, and an agent reaches it through `fillTaskForm`, which waves composites through.
851
+
852
+ `no-unpublished-form-setter.grit` errors on the pure-forwarding shape only, because every other one has a
853
+ legitimate reading. **`akan quality scan` counts them all** (`akan.agent.unpublished-form-setter`, one warning per
854
+ file): the lint rule is the per-line enforcement, the scan is the inventory of fields this screen writes but
855
+ cannot be asked to write.
856
+ - **A relation reaches an agent from its picker, not from the form patch.** `fillTaskForm` publishes no schema for
857
+ one and is right not to: the form holds the whole related document, so an id would need a lookup the store does
858
+ not do. The picker is where that lookup lives, so `Field.Parent` / `Field.Children` publish the pair themselves —
859
+ `load<Field>OptionsOn<Model>`, which loads the slice and returns `[{ id, label }]`, and the field's own
860
+ `set<Field>On<Model>` taking `<field>Id` / `<field>Ids`. Listing is its own tool because loading is its own step
861
+ for a person too: the options arrive when the dropdown opens, and an agent never opens it. `Field.ParentId` /
862
+ `Field.ChildrenId` need none of that — the id *is* the value, so the ordinary setter describes it. All four still
863
+ require the setter **by reference**, and a `disabled` picker publishes nothing.
864
+ - **An array of embedded rows also publishes `add<Field>On<Model>` and `sub<Field>On<Model>`** — append, and
865
+ remove-by-position — beside the whole-array setter. Not new authority: the setter can already produce any array
866
+ those two can, so they are strictly weaker. What they add is that neither can touch a row it was not given, and
867
+ that is the point: writing the whole array means echoing every row the agent is *not* changing, `checked`
868
+ validates types and not values, so one mistyped row nobody asked about is written silently. Both take a list and
869
+ act atomically, because removing positions one call at a time would shift the ones not yet removed. **Only an
870
+ embedded-row array gets them** — an array of primitives or of relation ids has nothing to retype wrong, its
871
+ values *are* the payload, so it keeps one setter and pays for no extra tools. `add` appends and publishes no
872
+ insert position, matching the `+` a person presses; `addOrSub` is never published, since it matches by `indexOf`
873
+ and would compare rows by reference. Editing a row in place stays `fill<Model>Form`'s job.
874
+ - **A list the person can drag also publishes `move<Field>On<Model>(from, to)`**, and `DraggableList` is a form
875
+ control like any other: handed the generated setter by reference it publishes that field, so an app that renders
876
+ its own rows with `DraggableList` writes no `st.tool`. The reorder tool exists for the same reason `add`/`sub` do
877
+ — the drag is the lever the screen offers and it changes no entry's content, so moving one row should not mean
878
+ retyping the nine beside it. No store action answers to it: reordering *is* a whole-array write, so the tool
879
+ splices the live entries and hands them to the setter the drag hands them to, `transform` deliberately not
880
+ applied, since the values are stored already and dragging normalizes nothing. It comes from the control saying
881
+ it sorts, not from the field, so a plain `Field.List` publishes no reorder and a scalar field never gets one.
882
+ **A component that composes `DraggableList` and already published the field hands the inner list a wrapper** —
883
+ the two would otherwise register one name twice, and the outer one is the one holding `transform`. That is what
884
+ `Field.TextList` does, and the only place an inline arrow is the right answer rather than a bug.
885
+ - **Reading is per key, not per store.** `st.useState(name, initial, meta)` publishes local state (read-only
886
+ unless `set:` names a type) and `st.expose(name, value)` a derived value. A subscribed store key is listed in
887
+ the state context block by name and pulled with `readState(key)`, masked by the model that key declares — while
888
+ a key the screen does not read stays unreadable even when a sibling key of the same store is live. **There is no
889
+ store-level exposure declaration**: a store class says nothing about agents, and `st.use.x({ agent: false })` is
890
+ how the component that subscribes a value keeps it off the surface. Base-store plumbing does the same at the
891
+ call site — `st.use.path({ agent: false })`, `st.use.tryJwt({ agent: false })` — so routing and the caller's
892
+ credential stay off the surface unless a component opts a key in, as ThemeToggle does for `theme`.
893
+ - **Model-facing text is English, always** — tool `desc`, `instructions`, Guide text. The `l()` rule covers
894
+ strings a *user* reads: Chat's own buttons go through `l("base.*")`, the model's text never does.
895
+ - A masked model never crosses the boundary: a value whose `hidden`/`secret` fields are populated is refused at
896
+ read unless a `mask:` model is named — the same rule and wording as `AgentBridge.read`.
897
+ - **`prompt()` endpoints double as the chat's slash commands.** There is no listing endpoint — the client reads
898
+ its own serialized signals — so a prompt's dictionary `.desc()` is what the menu shows, and its guards are
899
+ enforced by the prompt's own GET at call time.
900
+ - **The chat answers five slash commands of its own**, listed in the same `/` menu ahead of the prompts:
901
+ `/new` (`/clear`), `/retry`, `/copy`, `/help` and `/tools`. An app writes none of them and cannot add one — the
902
+ extension point for a product's own command is a `prompt()` endpoint, which is guarded and server-side.
903
+ **A built-in wins a name collision with a prompt of the same name**, the mirror image of the tool rule: a
904
+ component's `st.tool` shadows a built-in it means to replace, but no library's prompt may take `/new` away from
905
+ the user who typed it — so a shadowed prompt is dropped from the menu rather than listed twice. `/new` and
906
+ `/copy` are also dispatched *before* the is-a-turn-running check, because mid-turn is exactly when they are
907
+ reached for; `/new` therefore aborts the turn it is clearing and waits for it to wind down, since the loop
908
+ clears its own running flag a microtask later and a transcript emptied before that lands is one the dying turn
909
+ appends onto.
910
+ - **A command's output is a `local` message: rendered in the transcript, withheld from the wire.** The transcript
911
+ *is* the model's history, so `/help` text appended plainly would come back next turn as something the assistant
912
+ believes it said. `session.note(text)` is the only way to write one, `session.report(error)` stays what a
913
+ host-side *failure* lands in, and `local` messages are left out of a `/copy` export too — they are the chat
914
+ talking to itself. Their text is user-facing, so it goes through `l("base.*")` like every other chat string.
915
+ - **`/copy` exists because nothing else keeps the transcript.** The relay is stateless and the conversation lives
916
+ only in that browser, so an export is the one path a wrong answer has to whoever could fix it — which is why it
917
+ carries the route and the timestamp. `/retry` replays only the trailing user message, leaving anything before it
918
+ in place, so a prompt's own preamble is not sent twice.
919
+ - **↑ and ↓ in the composer walk what was sent.** A single-line input has nothing of its own on the vertical
920
+ arrows, and the half-written draft they were walked away from comes back at the bottom of the walk.
921
+ - The framework publishes six built-ins on every store surface: `navigate` (internal paths only, the same
922
+ router `Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and
923
+ a page that draws no back link is not one you may not leave), `readScreen` (the rendered DOM as compact text —
924
+ headings, links, control values, and `(disabled)` on a control or button that has it; the chat's own UI is
925
+ skipped via `data-agent-ui`, and a password value is never read), `readState(key)` (one masked store key),
926
+ `waitFor(key)` (park until that key moves), and `highlight(target)`. Declaring a hook tool under one of those
927
+ names shadows the built-in, so reuse them only to mean that.
928
+ - **A tool that changes the screen waits for the screen before it answers.** `router.push` returns while the RSC
929
+ payload is still in flight and a store action that fires `void fetch.*` commits a tick later, so `navigate`
930
+ awaits `ScreenSettle.wait()` — DOM quiescence, bounded, because the client router hands its promise to nobody —
931
+ and the session awaits it after every non-`query` tool before taking the change report. Without it the report
932
+ describes the moment before the change landed and the `readScreen` that follows reads the page the user left.
933
+ New tools and state from a fresh route are still only listed from the next turn: the catalogue is snapshotted
934
+ when the turn starts.
935
+ - **A tool that waits for its own work costs no model turns; one that returns early costs one round trip per
936
+ look.** The session awaits `run`, so a `.exec` that awaits the store action finishing the job simply makes the
937
+ turn take that long — and the change report that follows carries whatever landed, so the model needs no second
938
+ call to read the result. A fire-and-forget tool leaves the agent to poll instead, which burns the whole
939
+ `maxTurns` budget in seconds on a job measured in minutes. Say so in the `desc` ("takes about two minutes; do
940
+ not poll while it runs") and, for a route full of slow work, in an `Agent.Guide`.
941
+ - **`waitFor(key, equals?, timeoutSeconds?)` is for the job the *tool* cannot await** — started in an earlier turn,
942
+ or by a person clicking the button. It parks on a published state key, the ones `readState` lists, and resumes
943
+ the moment the key moves; `equals` waits for one value, omitted it waits for any change. Deliberately not a bare
944
+ sleep: a sleep only makes the polling slower, and a screen able to report progress reports it into the store, so
945
+ a screen that reports none publishes a key with `st.expose` — worth doing anyway, since the key becomes readable
946
+ too. Running out is not a failure, it answers with what the key holds now (default 120s, clamped to 600), and a
947
+ key this screen does not read is refused by name with the ones it does.
948
+ - **Stop reaches a tool that is still running.** The session races every call against its abort signal, so a
949
+ two-minute tool does not hold the loop for two minutes after the user presses Stop. The signal itself arrives
950
+ through `AgentAbort.current` — the same module slot `AgentProgress` is — and honouring it is optional, since the
951
+ race lands whatever the tool does; what it buys is the tool's own cleanup, a timer or a poll loop that would
952
+ otherwise run out with nobody left to answer. A tool that ignores it is left running rather than cancelled: the
953
+ work is usually a job a server is already doing.
954
+ - **`readScreen` takes a `section`, and `highlight` a `target`.** Both resolve a name the agent has already seen —
955
+ a `data-akan-action` / `data-akan-state` annotation, an `Agent.Zone` or `useScreenScope` container
956
+ (`data-agent-scope`, which `Load.Units` / `Load.View` / `Data.ListContainer` put on the container they render),
957
+ an element id, or **a heading by its own text**, matched on letters and digits so the slug an agent writes for a
958
+ heading it read resolves. That tolerance stops at headings: a heading is a landmark and scrolling to the wrong
959
+ one costs nothing, while two buttons reading "Save" are not the same control. **Nothing hidden ever resolves** —
960
+ a ring nobody can see reads as a broken tool, not as a miss. A section named by a heading is read to the next
961
+ heading of its level or higher.
962
+ - **A screen is only aimable if its names are printed.** `readScreen` writes `(#anchor)` beside a heading that
963
+ opens an id'd or scoped container, and a truncated read ends with the headings below the cut — otherwise
964
+ everything past the 8000-character limit is unreachable, because nothing names it, and an agent asked to point
965
+ at a section it cannot name guesses a slug and is refused. A refusal lists the sections actually on screen.
966
+ `highlight` scrolls its target into view and flashes it **once the scroll lands**, since a smooth scroll across a
967
+ long page outlasts the flash; it is the one built-in that exists for the *user's* benefit, because showing where
968
+ a control is beats writing directions to it.
969
+ - **A slow tool reports its own progress with `AgentProgress.report(message, { done, total })`** from wherever the
970
+ work is — a store action, an upload loop, an adapter — reached through a module slot rather than a parameter, and
971
+ a no-op when nobody is rendering it. The chat shows it on that call's row until the row resolves. It is the
972
+ browser twin of `McpProgress.report`. Import it and `AgentAbort` from `akanjs/store`: an app may not reach
973
+ `use-agentic` directly (`no-import-external-library`), and those two are the channels a long tool body needs.
974
+ - **The turn cap is a question, not a dead end.** At `maxTurns` the session asks whether to keep going through the
975
+ same card `askUser` uses, and the answer rides as the user's own turn — so a steer typed instead of the
976
+ keep-going choice reaches the model as guidance. A host that renders no `pendingQuestion` passes no
977
+ `continueAsk` and keeps the old failure, because asking with nobody listening would hang.
978
+ - **`askUser` is a fourth built-in the *session* owns, not the surface.** The answer comes from the conversation
979
+ rather than the screen, so it rides on every turn whatever the page declares, and a zone agent asks inside its
980
+ own transcript. `choices` offers a pick (`multiple` for several) and omitting them asks for free text; the card
981
+ keeps a free-text row either way, because the model wrote the options and only the user knows whether the answer
982
+ is among them. The loop parks on the question exactly as it parks on an approval, a dismissal is the tool's
983
+ error result rather than a silent empty answer, and the settled exchange renders as question-and-answer instead
984
+ of a tool row. **Never re-implement it per screen** — a `st.tool("askAboutX")` that opens a modal is the same
985
+ thing with a worse transcript — and a hook tool named `askUser` shadows it like any other built-in.
986
+
987
+ ## Scalar Modeling (`**/*.constant.ts`)
988
+
989
+ - Define Akan models in `.constant.ts` files with `via` from `akanjs/constant`.
990
+ - Use `Int` for whole-number counts and quantities; use `Float` only for values that need decimals.
991
+ - Use `ID` for document references and prefer explicit structured fields over `Any` unless the content is genuinely flexible.
992
+ - For date defaults, prefer a function such as `default: () => dayjs()` so the value is created at runtime.
993
+ - 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.
994
+ - Put display and predicate logic on the `Light<Model>` class rather than in a util module — see Module File Playbook.
995
+ - Defaults are a literal for scalars and a thunk for anything constructed. Arrays are `field([T])`; optional is the postfix `.optional()`.
996
+
997
+ ### Scalar & Field Type Reference
998
+
999
+ - **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Upload`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible payloads.
1000
+ - **Use the JS globals directly (no import needed)**: `String`, `Boolean`, `Date`. They are monkey-patched to behave like scalars, so `field(String)` typechecks.
1001
+ - **`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.
1002
+ - Runtime resolution of every scalar (globals included) goes through `PrimitiveRegistry` by `refName` (`pkgs/akanjs/base/primitiveRegistry.ts`).
1003
+
1004
+ ### Text Search Fields — the `text` role
1005
+
1006
+ - A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
1007
+ `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
1008
+ - Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
1009
+ `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
1010
+ scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
1011
+ - `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
1012
+ - **A `secret`, `hidden`, or `resolve()` field with `text` throws at class-build time**, not at query time. That is
1013
+ deliberate: the mirror is plaintext, so an indexed secret would leak through search. Do not work around it. The
1014
+ same throw covers a `text` field *underneath* one of those — a scalar's own field is reachable through its parent,
1015
+ so `f.secret(Noti)` where `Noti.label` carries a role is rejected at the parent, not silently indexed.
1016
+ - The role works on a relation too (`image: field(File, { text: "thumb" })`) and on an array (`playing: field([String],
1017
+ { text: "tag" })`); an array of objects indexes by leaf key, including an array leaf (`works[*].tags`). A field
1018
+ inside a `Map` indexes nothing: there is no fixed path to extract it from.
1019
+ - Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
1020
+ because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
1021
+ path.
1022
+ - Search runs on sqlite/libsql only. `q.search()` against Postgres throws, loudly, rather than returning every row.
1023
+ - `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
1024
+ re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
1025
+ it does not mount, so a mixed fleet leaves stale triggers behind.
1026
+ - The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
1027
+ `unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
1028
+ tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
1029
+ rebuild takes no cross-process claim, so a fleet restarted at once repeats it in every process; stagger the
1030
+ restart when the mirror is large.
1031
+
1032
+ ### Image & File Fields
1033
+
1034
+ - **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.
1035
+ - Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many (see `libs/shared/lib/user/user.constant.ts`, `libs/shared/lib/banner/banner.constant.ts`).
1036
+ - 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`).
1037
+ - Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`, `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` lib (`libs/shared/lib/file/*`). Do not hand-roll data-URL fallbacks.
1038
+
1039
+ ### Cascade Remove — the `cascade` option
1040
+
1041
+ **The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
1042
+ shape, so `cascade` never means "related" — it means one of exactly these:
1043
+
1044
+ - `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
1045
+ `image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it; a primitive, a bare
1046
+ `ID`, and a scalar each fail the class build, because none of them names a document to remove.
1047
+ - `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
1048
+ its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three forms:
1049
+ a relation (`agentSession: field(AgentSession, { cascade: "removeWith" })`), an id with `ref`
1050
+ (`field(ID, { ref: "agentSession", cascade: "removeWith" })`), or a polymorphic id with `refPath`
1051
+ (`field(ID, { refPath: "parentType", cascade: "removeWith" })`). An array, a Map, `ref` together with `refPath`,
1052
+ and a field naming no owner each fail the class build.
1053
+ - **A `refPath` must name an `enumOf` field.** A free-form owner type is unknowable at build time, so every model's
1054
+ removal would have to sweep the polymorphic table on the chance it is the owner. The enum names the candidates and
1055
+ the reverse index reaches only them.
1056
+ - **A cascade goes through the target's service, never its model** — unless it provably makes no difference. The
1057
+ service path is what runs the target's `_postRemove`, which is where a module puts the side effect the removal has
1058
+ to carry (`FileService._postRemove` deletes the stored blob there).
1059
+ - **Bulk is decided at boot, per target model, for both directions.** When the target has no `remove` schema hook, no
1060
+ `_pre`/`_postRemove` (its own or a lib's), no cascade of its own, and no children, one `removeManyByQuery` leaves
1061
+ exactly the rows the loop would, so the framework takes it. Adding a `_postRemove` to that model silently flips it
1062
+ back to one document at a time — the boot log (`info` summary, `verbose` per edge) is the only place that shows.
1063
+ - **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` still counts
1064
+ and a `removeRef` target the app never mounted fails the boot rather than the first removal. An unmounted
1065
+ `removeWith` owner fails the boot too; an unmounted `refPath` candidate only warns, since that list spans optional
1066
+ modules by design.
1067
+ - **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
1068
+ `origin`, so two parents can share one row; declaring `removeRef` says the field owns its target exclusively, and
1069
+ that judgement is the declaring model's to make.
1070
+ - Removal is soft (`removedAt`) but the storage delete a `_postRemove` performs is not — a cascade is not
1071
+ restorable, and reviving the owner does not revive what went with it.
1072
+ - A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
1073
+ polymorphic). Every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table
1074
+ on every owner removal.
1075
+ - **Query-level removes fire no hooks and therefore no cascade.** `removeManyByQuery` / `updateManyByQuery`, the
1076
+ generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById` stamp
1077
+ `removedAt` in one atomic UPDATE, so nothing downstream runs. Remove one document at a time when it cascades.
1078
+ - Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write, so a retry after a partial
1079
+ failure re-stamps nothing. Cycles are cut by a visited set carried down the whole chain, with a depth cap of 16.
1080
+
1081
+ ## Akan Page Routing (`apps/**/page/**`)
1082
+
1083
+ - `apps/<app>/page` may contain route modules only. Do not add helper logic or component-only files there.
1084
+ - Route source files under `page/` must use `.tsx`. Do not add `logic.ts`, `.js`, or `.jsx` files under `page/`.
1085
+ - Route pages use `_index.tsx`; layouts use `_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
1086
+ - Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add files like `_Component.tsx` or `_helper.tsx`.
1087
+ - Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`, `common`, or `lib` instead.
1088
+ - Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
1089
+ - Page modules should usually export `default`, `pageConfig`, `head`, `generateHead`, or `Loading`.
1090
+ - `_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.
1091
+ - Prefer `export default function Page` or `export default async function Page` for page components.
1092
+ - `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.
1093
+ - `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.
1094
+ - Both path segments are route groups, so a lib route mounts at its own path (`libs/shared/page/login/_index.tsx` serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
1095
+ - `export const pageConfig = { 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.
1096
+ - Before changing route behavior, check `pkgs/akanjs/server/src/routeTree.tsx` and nearby routes for the expected pattern.
1097
+
1098
+ ### Page Body Shape
1099
+
1100
+ ```tsx
1101
+ interface PageProps {
1102
+ params: { orgId: string };
1103
+ }
1104
+
1105
+ export default async function Page({ params }: PageProps) {
1106
+ const { l } = usePage();
1107
+ getSelf({ unauthorize: "/signin" });
1108
+ const { orgId } = params;
1109
+ const [{ org }, { taskInitInOrg }] = await Promise.all([fetch.viewOrg(orgId), fetch.initTaskInOrg(orgId)]);
1110
+ return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
1111
+ }
1112
+
1113
+ export const pageConfig = { transition: "stack" } satisfies PageConfig;
1114
+ ```
1115
+
1116
+ - There is no `loader=` / `render=` page prop. Pages are `export default async function Page`.
1117
+ - Declare `interface PageProps { params: {...}; searchParams?: {...} }` immediately above the default export.
1118
+ - Body order: `usePage()`, auth, destructure params, fetch, return.
1119
+ - Run independent fetches through `Promise.all`, even when there is only one.
1120
+ - Gate auth at `_layout.tsx`; repeating `getSelf({ unauthorize: "/signin" })` in the page is fine and common.
1121
+ - Keep `async` even when nothing is awaited — it marks a real server page.
1122
+ - No `useState`, no `useEffect`, and no comments in page files.
1123
+
1124
+ ## Akan Sync Conventions (`apps/**`, `libs/**`)
1125
+
1126
+ - `apps/<appName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `capacitor.config.ts`, `client.ts`, `main.ts`, `package.json`, `server.ts`, `tsconfig.json`.
1127
+ - `apps/<appName>` root may only contain these folders: `.akan`, `android`, `common`, `env`, `ios`, `lib`, `mobile`, `page`, `plugin`, `private`, `public`, `script`, `secrets`, `srvkit`, `ui`, `webkit`.
1128
+ - That allowlist has one source — `pkgs/@akanjs/devkit/workspaceLayout.ts`. `akan sync` (error), `akan doctor`
1129
+ (diagnostic), and `akan quality scan` (warning) all read it, so add a new root entry there and mirror it into this
1130
+ list, never into one of the three call sites.
1131
+ - `akan sync` maintains a scoped agent guide per app/lib: `apps/<app>/AGENTS.md` / `libs/<lib>/AGENTS.md`. The
1132
+ section between the `akan:agent` markers (the `## Recipes In Scope` index) is generated — do not hand-edit it;
1133
+ content outside the markers is yours. `akan lint` fails when the generated section is stale.
1134
+ - 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.
1135
+ - Do not add `apps/*/base`; place shared app utilities under `apps/*/common`.
1136
+ - `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`.
1137
+ - Domain module folders are `lib/<model>` for database modules, `lib/_<service>` for service modules, and `lib/__scalar/<scalar>` for scalar modules.
1138
+ - Database module UI files are limited to `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.Util.tsx`, `<Model>.View.tsx`, and `<Model>.Zone.tsx`.
1139
+ - Service module UI files are limited to `<Service>.Util.tsx` and `<Service>.Zone.tsx`.
1140
+ - Scalar module UI files are limited to `<Scalar>.Template.tsx` and `<Scalar>.Unit.tsx`.
1141
+ - Module `*.test.ts`, `*.test.tsx`, `*.spec.ts`, and `*.spec.tsx` files are allowed.
1142
+ - `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.
1143
+ - Generated facet indexes export only 1-depth files/folders with `export * from "./name";`.
1144
+ - `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`.
1145
+
1146
+ ## Layer Placement (`common/`, `webkit/`, `srvkit/`, `ui/`)
1147
+
1148
+ | Folder | Admission test | Naming |
1149
+ |---|---|---|
1150
+ | `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 |
1151
+ | `webkit/` | touches `window` / `navigator` / Capacitor, or is a React hook | `use<Thing>.tsx` — `.tsx` even with no JSX |
1152
+ | `srvkit/` | touches `node:*`, `Bun`, `process.env`, a secret, or a server SDK | camelCase file, PascalCase class |
1153
+ | `ui/` | renders JSX and is not bound to one model | PascalCase component, camelCase sidecar (`swipeCard.util.ts`) |
1154
+ | `plugin/` | build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
1155
+
1156
+ - Hooks return a named object of async closures, never a tuple.
1157
+ - `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.
1158
+ - 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.
1159
+ - `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.
1160
+
1161
+ ## Present In The Code — Do Not Imitate
1162
+
1163
+ Older files contain these. They are warts, not conventions: do not copy them forward, and prefer the newer neighbour
1164
+ when two shapes disagree.
1165
+
1166
+ - `children: any` — newer files use `children: ReactNode`.
1167
+ - Hard-coded API keys or secrets in source; they belong in `option.ts` or env.
1168
+ - Large blocks of commented-out code left in place.
1169
+ - Cross-store writes through a `RootStore` cast — it collides with the Bun SSR-bundler segfault.
1170
+ - Stale `// TODO: Implement …` comments above implemented methods.
1171
+ - `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
1172
+ - Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
1173
+ - Raw palette grays such as `text-gray-400` instead of daisyUI semantic tokens.
1174
+
1175
+ ## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
1176
+
1177
+ - Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env` contents.
1178
+ - If env keys are needed for documentation, list only key names and example placeholders, not live values.
1179
+ - Preserve the existing env/secret flow through root scripts such as `bun run downloadEnv`, `bun run uploadEnv`, `bun run downloadSecret`, and `bun run uploadSecret`.
1180
+ - When editing infra env or secret scripts, keep Jenkins and deployment assumptions intact unless the task explicitly asks to change them.
1181
+ - Treat generated env/secret artifacts as sensitive even when they are not named `.env`.
1182
+
1183
+ ## Application Test Commands
1184
+
1185
+ - After changing application source code, test the app with `bun run akan start <appName>`.
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
+
1189
+ ```bash
1190
+ cd dist/apps/akan && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=akanjs AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com" AKAN_PUBLIC_APP_NAME=akan AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation AKAN_PUBLIC_BASE_PATHS=akanjs,soft,office bun main.js
1191
+ ```
1192
+
1193
+ - Adjust `<appName>`, `AKAN_PUBLIC_APP_NAME`, and `AKAN_PUBLIC_BASE_PATHS` to match the app being tested.