@akanjs/cli 3.1.0-rc.1 → 3.1.0-rc.11

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 (98) hide show
  1. package/.build-stamp +1 -1
  2. package/{CodeAgent-kyprmy06.js → CodeAgent-rkjyxgec.js} +2 -2
  3. package/{CodeTui-21mtze3j.js → CodeTui-h7pyj6xa.js} +1 -1
  4. package/TunnelShare-661cxz5e.js +14 -0
  5. package/{agent.command-f3gsc1gd.js → agent.command-3qxf4xcj.js} +5 -5
  6. package/akanCode.js +2 -2
  7. package/{application.command-5s780vnz.js → application.command-m1kcd7jf.js} +54 -42
  8. package/{applicationBuildRunner-1yaqsrdq.js → applicationBuildRunner-dj0xkrep.js} +26 -33
  9. package/buildBatch.proc.js +24 -15
  10. package/{cloud.command-yj6xb2a7.js → cloud.command-9cp1t0f5.js} +60 -67
  11. package/cloud.script-dgstqt1m.js +24 -0
  12. package/{code.command-3wkn4ja5.js → code.command-0ad6gw2a.js} +6 -6
  13. package/{context.command-9jjn3gcw.js → context.command-0qx447mb.js} +17 -13
  14. package/context.runner-1vsjztjg.js +25 -0
  15. package/{dependencyScanner-06z2dmrr.js → dependencyScanner-3afj2vt1.js} +1 -1
  16. package/{guideline.command-dtczrqpf.js → guideline.command-cv5bmrx2.js} +2 -2
  17. package/guidelines/agentRule/agentRule.instruction.md +3 -3
  18. package/guidelines/conventions/conventions.instruction.md +457 -1071
  19. package/guidelines/cssRule/cssRule.instruction.md +28 -7
  20. package/guidelines/fieldRule/fieldRule.instruction.md +62 -0
  21. package/guidelines/framework/framework.instruction.md +4 -7
  22. package/guidelines/mcpRule/mcpRule.instruction.md +43 -20
  23. package/guidelines/modelConstant/modelConstant.instruction.md +2 -1
  24. package/guidelines/modelStore/modelStore.instruction.md +9 -0
  25. package/guidelines/pageRule/pageRule.instruction.md +80 -0
  26. package/guidelines/queryRule/queryRule.instruction.md +44 -11
  27. package/guidelines/runtimeRule/runtimeRule.instruction.md +113 -17
  28. package/guidelines/ssrRule/ssrRule.instruction.md +13 -0
  29. package/guidelines/transportRule/transportRule.instruction.md +135 -13
  30. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +4 -4
  31. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +68 -7
  32. package/incrementalBuilder.proc.js +10 -7
  33. package/index-192ha4xh.js +1173 -0
  34. package/{index-c76k723e.js → index-1p98exm2.js} +2 -2
  35. package/{index-rwq3ekqc.js → index-1xj2t7cb.js} +7 -7
  36. package/{index-rtct7d43.js → index-20bxxyvn.js} +80 -80
  37. package/{index-qt22xstj.js → index-35hc95h5.js} +5 -3
  38. package/{index-8r4d7cmt.js → index-4mhy6qme.js} +4 -4
  39. package/{index-ww9bhp67.js → index-4pf8g7ff.js} +1 -1
  40. package/{index-2mx692qq.js → index-5edzv8fn.js} +5 -5
  41. package/{index-k9rrten6.js → index-5nd1wtkv.js} +92 -94
  42. package/index-62md4dwy.js +260 -0
  43. package/index-665zp9rs.js +271 -0
  44. package/{index-2etcv0sc.js → index-86jt2a0e.js} +1 -1
  45. package/{index-krq7btbt.js → index-8fp3b47h.js} +419 -418
  46. package/{index-c2yrsxdh.js → index-9z426msb.js} +109 -328
  47. package/{index-ctanya41.js → index-awbdfcf2.js} +4 -4
  48. package/{index-3hx1z96c.js → index-bqbf654f.js} +1 -1
  49. package/{index-rc5rwyps.js → index-bspm21y1.js} +71 -60
  50. package/index-cpb3nvma.js +667 -0
  51. package/{index-jx4bzvwg.js → index-d47ypebv.js} +97 -55
  52. package/{index-ae5pwhgs.js → index-d72hfmdm.js} +15 -13
  53. package/{index-e0cvze1s.js → index-fmzcs8v6.js} +4 -4
  54. package/{index-8ggztp8k.js → index-fyq1z6kt.js} +68 -201
  55. package/{index-1w2qaee2.js → index-g1mbppa9.js} +331 -259
  56. package/{index-gnyf1fqy.js → index-j237xtew.js} +6 -6
  57. package/{index-fm46xrf1.js → index-j4mkhz93.js} +3 -3
  58. package/{index-6jjtnhkw.js → index-jw1zm273.js} +74 -34
  59. package/{index-q958tqqg.js → index-m9jj544v.js} +1 -1
  60. package/index-mdjc93e1.js +33 -0
  61. package/{index-c1x0be5j.js → index-pydbnfez.js} +2 -2
  62. package/{index-56d2g8ms.js → index-qwa5k1j8.js} +1 -1
  63. package/{index-v29746vb.js → index-tamvyvj3.js} +3 -3
  64. package/{index-05g3jayg.js → index-trpasv9q.js} +260 -259
  65. package/{index-2ahj896a.js → index-tvehn2sp.js} +1 -1
  66. package/{index-55mynnqs.js → index-va06nr10.js} +1434 -1173
  67. package/index-vk0pa0mn.js +240 -0
  68. package/{index-aprt7pkz.js → index-w44cy4vs.js} +2271 -3369
  69. package/index.js +22 -22
  70. package/{library.command-gaga5bmq.js → library.command-erp2f56n.js} +3 -3
  71. package/{localRegistry.command-vsreyy42.js → localRegistry.command-y176p558.js} +15 -11
  72. package/{module.command-7vpf6db4.js → module.command-gs2r615e.js} +5 -5
  73. package/{package.command-twmvvf69.js → package.command-j10r76ax.js} +4 -4
  74. package/package.json +21 -21
  75. package/{page.command-5asq7fdf.js → page.command-4h4a8pnh.js} +3 -3
  76. package/{pipeline.command-d0v38572.js → pipeline.command-a2nrk26m.js} +221 -220
  77. package/{primitive.command-dyrs2fm8.js → primitive.command-w6t0asqz.js} +6 -6
  78. package/{projectTypecheck-cvtkrzp2.js → projectTypecheck-f534enqj.js} +1 -1
  79. package/{quality.command-6emsqpbg.js → quality.command-77x7vbbx.js} +4 -4
  80. package/{repair.command-8t6aegv5.js → repair.command-y6sstev7.js} +5 -5
  81. package/{scalar.command-bnggy1x7.js → scalar.command-20b8cy0k.js} +4 -4
  82. package/{subspace.command-md2teygq.js → subspace.command-5c7yasd7.js} +424 -364
  83. package/templates/appSample/lib/task/Task.Util.tsx +7 -7
  84. package/templates/appSample/lib/task/Task.Zone.tsx +1 -1
  85. package/templates/appSample/ui/QuantityControl.tsx +2 -2
  86. package/templates/crudSinglePage/page.tsx +1 -1
  87. package/templates/lib/cnst.ts +0 -1
  88. package/templates/workspaceRoot/.gitignore.template +23 -52
  89. package/templates/workspaceRoot/biome.json.template +1 -1
  90. package/{tunnel.command-jx3tdm2d.js → tunnel.command-2kw8vt9g.js} +8 -4
  91. package/{workflow.command-jx0wag5r.js → workflow.command-2djwmpqf.js} +10 -10
  92. package/{workspace.command-yxd7rr7g.js → workspace.command-6qfgq4b4.js} +38 -34
  93. package/TunnelShare-q4jq08qg.js +0 -9
  94. package/cloud.script-h6pvcpa2.js +0 -20
  95. package/context.runner-atcrzz5b.js +0 -21
  96. package/index-6w0rbr9d.js +0 -68
  97. package/index-n68jx79w.js +0 -120
  98. package/index-pgj2rt78.js +0 -404
@@ -11,13 +11,28 @@ Use TailwindCSS with Akan's semantic design-token layer and the `akanjs/ui` prim
11
11
  - Compose classes with `cn` from `akanjs/client` (token-aware tailwind-merge) — the only class-combining function. No object syntax: write `cond && "x"`.
12
12
  - Forward `className` last so callers can extend styles.
13
13
 
14
+ ## Color Vocabulary — What Lint Refuses
15
+ - Vocabulary closure strips the raw Tailwind palette, so these render as no CSS and fail lint: raw palette classes
16
+ (`bg-blue-500`, `text-gray-400`), arbitrary color values (`bg-[#3b82f6]`), an arbitrary class built by
17
+ interpolation, daisyUI legacy classes (`btn-primary`, `card-body`) and its dropped color slots (`bg-base-100`,
18
+ `text-base-content`, `text-primary-content`, `bg-error` — use `background`/`muted`/`border`, `foreground`,
19
+ `<color>-foreground`, `destructive`), and color literals in `style={{...}}` (`no-raw-palette-class`,
20
+ `no-arbitrary-color`, `no-daisyui-legacy-class`, `no-inline-color`, `no-interpolated-arbitrary-class`).
21
+ - A legitimate fixed color (OS-chrome mockups, data-viz) takes a `// biome-ignore lint/plugin: <reason>`.
22
+ - Color literals in SVG color attributes (`fill`, `stroke`, `stopColor`, `floodColor`, `lightingColor`, `color`) and
23
+ in `el.style.<prop> = …` / `el.style.setProperty(…)` writes are flagged as warnings for now; `currentColor`,
24
+ `none`, `transparent`, `var(--…)` and `url(#id)` stay legal.
25
+ - Class order is the sorter's: `nursery/useSortedClasses` is an error and also sorts the string arguments to `cn()`.
26
+ Output such as `font-bold text-2xl text-foreground` or `border-foreground/5 border-t` is correct — write the
27
+ classes in any order, run the formatter, leave the result.
28
+
14
29
  ## Recipe Layer
15
30
  - Reusable or variant-like styling belongs in a recipe (a cva-style factory), not repeated inline class stacks.
16
31
  - Framework primitives ship recipes: `buttonRecipe` and `badgeRecipe` from `akanjs/ui`. Call them as `buttonRecipe(variants, className?)` — the second argument is merged automatically, so you never wrap it in `cn()`:
17
32
  - `buttonRecipe({ variant: "primary", size: "lg" }, "w-full rounded-2xl")`
18
33
  - Prefer the `akanjs/ui` primitives (`Button`, `Badge`, `Input`, `Field`, `Table` …) over re-implementing a component's look with utility stacks. There is **no `Card`/`Box` primitive** — a card/box is a recipe surface (e.g. `appCard`), not an `akanjs/ui` component; do not `import { Card }`.
19
34
  - App-specific repeated surfaces go one-per-file in `apps/<app>/ui/Recipe/` (server-safe, no `"use client"`) via `recipe(tv({ base, variants }))`, imported from `@apps/<app>/ui`. App recipes **extend** (surfaces the lib lacks — chat bubble, tile); they never re-define a lib component in parallel.
20
- - Full recipe authoring/consumption policy: the `recipeRule` guideline. Never guess recipe names/imports — the authoritative list is `AGENTS.md` `## UI Recipes` plus the `list_recipes` tool.
35
+ - Full recipe authoring/consumption policy: the `recipeRule` guideline. Never guess recipe names/imports — the authoritative list is `AGENTS.md` `## Recipes` plus the `list_recipes` tool.
21
36
 
22
37
  ## Customization Decision (where a design delta goes)
23
38
  Screen code is invariant — a plain `<Button variant="primary">` never changes. Only these config files do:
@@ -64,19 +79,25 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
64
79
 
65
80
  ## Frame Variables (the page's chrome and the device's insets)
66
81
 
67
- The frame writes these on `#frameRoot` and `<html>`, so any class can read them, web and native alike:
82
+ The frame writes these on `#frameRoot` and `<html>`, so any class can read them. The insets and the content height
83
+ are there on every surface; the three keyboard variables are written only on the server-rendered web:
68
84
 
69
85
  - `--akan-top-safe-area` / `--akan-bottom-safe-area` — the notch and the home indicator. On the web they are
70
86
  `env(safe-area-inset-*)` where the route chain declares `safeArea` (`.config({ safeArea: true })` on the root
71
- layout) and `0px` where it does not; in a native app they are the shell's inset. Use the variable alone: never
72
- write `env(safe-area-inset-*)` beside it, and never subtract one from the other. The frame paints the bottom strip
73
- only under bottom chrome (a `BottomInset` or `BottomTab`) or a route's own `bottomSafeAreaColor`; elsewhere a page's
74
- `fixed bottom-0` bar paints its own safe-area padding.
87
+ layout) and `0px` where it does not, and `env()` measures nothing unless the viewport meta carries
88
+ `viewport-fit=cover` — the root layout's `.viewport({ fitCover: true })`, which a native app has by default; in a
89
+ native app they are the shell's inset. Use the variable alone: never write `env(safe-area-inset-*)` beside it, and
90
+ never subtract one from the other. The frame paints the bottom strip only under bottom chrome (a `BottomInset` or
91
+ `BottomTab`) or a route's own `bottomSafeAreaColor`; elsewhere a page's `fixed bottom-0` bar paints its own
92
+ safe-area padding.
75
93
  - `--akan-top-inset` / `--akan-bottom-inset` — the height of the page's top bar and bottom tab slot, per breakpoint
76
94
  when the page declares a responsive inset. A slot whose inset is 0 at a width clips its chrome there and hides it
77
95
  from the keyboard and screen readers, so a phone-only bar needs no `md:hidden`.
78
96
  - `--akan-keyboard-inset`, `--akan-viewport-height`, `--akan-viewport-offset-top` — the on-screen keyboard over the
79
- page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it).
97
+ page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it). Only
98
+ an SSR page in a browser writes them; a CSR bundle and a native app never do, so read them with a fallback
99
+ (`var(--akan-keyboard-inset, 0px)`), or take the keyboard from the store's `keyboardHeight`, which a native app's
100
+ keyboard plugin fills.
80
101
  - `--akan-content-height` — the room between the insets. iOS pans under the keyboard instead of shrinking the page,
81
102
  so a column sized by it leaves the panned-to area blank: keep a keyboard-heavy screen's input bar in flow at the end
82
103
  of its column, or in the keyboard slot.
@@ -10,8 +10,70 @@ Use this whenever generating constants or scalar constants. It defines the curre
10
10
  - `field.hidden(Type).optional()` is for internal state that should not be treated as a normal public field.
11
11
  - `field.secret(Type).optional()` is for sensitive values that should not be selected by default.
12
12
  - Neither survives an endpoint response: both are stripped on the way out and hydration writes `null` over the key, while the generated type still declares the field — so a client holds a deliberate `null` behind a type that promises a value, and only `??` / `== null` catch it. A field a browser has to read is neither.
13
+ - `field.visual(Type)` is a field the page renders and an agent never sees (below).
13
14
  - `field([Type])` defines arrays; provide empty array defaults when the app expects list operations.
14
15
 
16
+ ## Field Types
17
+ - **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`,
18
+ `ImageResult`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible
19
+ payloads, and only when the content is genuinely flexible.
20
+ - **Use the JS globals directly (no import needed)**: `String`, `Boolean`, `Date`. They are monkey-patched to
21
+ behave like scalars, so `field(String)` typechecks.
22
+ - **`Number` is not a valid field/body type.** `NumberConstructor` is intentionally not augmented, so
23
+ `field(Number)` / `.body("x", Number)` fails to typecheck. Use `Int` for whole-number counts and quantities and
24
+ `Float` only for values that need decimals.
25
+ - Runtime resolution of every scalar (globals included) goes through `PrimitiveRegistry` by `refName`
26
+ (`pkgs/akanjs/base/primitiveRegistry.ts`).
27
+ - **`Binary`, `Upload` and `ImageResult` are never model fields.** The class build refuses `field(Binary)` naming
28
+ the `File` model instead, because every non-base field lives in the `_doc` JSON column and bytes would sit there
29
+ as base64 and ride every read of the row. How each travels on the wire: `transportRule` and `mcpRule`.
30
+ - Defaults are a literal for scalars and a thunk for anything constructed (`default: () => dayjs()`, so the value
31
+ is created at runtime). Optional is the postfix `.optional()`.
32
+
33
+ ## Masked And Visual Fields
34
+ - **A `field.hidden` / `field.secret` value reads `null` on the client, and the type still declares it.**
35
+ `SignalContext.resolveReturn` skips both field types while it builds an endpoint's response, and hydration
36
+ writes `null` over the key rather than leaving it absent — the first branch of the loop in
37
+ `constant/getDefault.ts`, ahead of the field's own default. Every use of it typechecks, and the failure lands
38
+ wherever it is finally dereferenced instead of where it was read. **Guard with `??` or `== null`**:
39
+ `=== undefined`, a destructuring default and an optional parameter default all catch only a missing key and sail
40
+ straight past this one. A projection (`pickById(id, { secret: true })`) widens the *server's* read, never the
41
+ response. If a screen needs the value, the field is neither hidden nor secret; if it only needs to be cheap for a
42
+ model rather than unseen, that is `field.visual`.
43
+ - **`field.visual(T)` is a field the page renders and an agent never sees** — a blur placeholder, a rendered HTML
44
+ body, a serialized geometry. It stays an ordinary stored `property` (persistence, search, forms and the page
45
+ response untouched) and is stripped wherever a value is masked for an AI caller: every in-page-agent read and
46
+ every MCP result, along with the MCP readable schema. Unlike `hidden`/`secret` it is cost, not secrecy — nothing
47
+ is refused over one. Reach for it whenever a field is bulky and useless to a model; that is cheaper than every
48
+ tool learning to avoid it.
49
+ - `field.secret`, `field.hidden` and `resolve()` take no `text` search role (`queryRule`).
50
+
51
+ ## Immutable Fields
52
+ - A document-path change to an `{ immutable: true }` field is refused with `base.error.immutableField` (400,
53
+ `data.field`), which the client toasts in the user's language; query-level writes are not checked.
54
+
55
+ ## Date Fields On An Instance
56
+ - **A model instance's `Date` fields are prototype accessors, not own properties.** `user.createdAt` is still a
57
+ `Dayjs`, built on first read from a native `Date` the instance holds under a symbol. So `Object.keys(user)` and
58
+ `{ ...user }` do **not** include them, while `"createdAt" in user`, `for...in`, `JSON.stringify(user)`,
59
+ `plainFieldsOf(user)` (`akanjs/common`), `immerify`, and `deepObjectify` all do. To copy a model,
60
+ `new cnst.X().set(user)`, never a spread.
61
+
62
+ ## Image And File Fields
63
+ - **Do not declare `Upload` as a model field.** `Upload` is a signal-body-only primitive. Declare an image/file
64
+ field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many.
65
+ - The store then auto-generates an `upload<Field>On<Model>(fileList)` action that calls the framework upload
66
+ mutation and polls file status until it leaves `"uploading"` (`pkgs/akanjs/store/action.ts`).
67
+ - Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`,
68
+ `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` module.
69
+ Do not hand-roll data-URL fallbacks. `{ cascade: "removeRef" }` on the field removes the file with its owner
70
+ (`queryRule`).
71
+ - For a file your own code wrote through `plug(StorageAdaptorRole)`, the disk path is
72
+ `storage.resolveLocalPath?.(path, access)` — `null` means refused (rooted, NUL, or outside the root; never fall
73
+ back to `readData()`), `undefined` means the adaptor keeps no local files, and an untrusted key passes
74
+ `access: "public"`. A `File` field is not one: `libs/shared` stores it through its own `StorageApi`, which may be
75
+ object storage. Never cast the role to read `BlobStorage`'s `root` / `privateRoot`.
76
+
15
77
  ## Current Akan Patterns
16
78
  - Use `String`, `Boolean`, `Date`, `Int`, `Float`, `ID`, scalar classes, model light classes, or enum classes as field types.
17
79
  - Use `enumOf()` classes for categorical values that need type-safe values and dictionary entries.
@@ -1,16 +1,13 @@
1
1
  # Akan.js Framework Guide
2
2
 
3
3
  ## Ownership
4
- - `apps/<app>` contains app code, pages, env files, module folders, UI, webkit, srvkit, and common utilities.
5
- - `libs/<lib>` contains reusable domain and utility libraries consumed by apps.
6
- - `pkgs/akanjs` contains framework facets such as base, constant, document, service, signal, store, client, ui, and CLI tooling.
4
+ - `pkgs/akanjs` facets include base, constant, document, service, signal, store, client, ui, and CLI tooling.
7
5
  - Domain behavior lives near the model folder instead of being split by technical layer first.
8
6
 
9
7
  ## Current Akan Patterns
10
8
  - Database module flow is `constant -> dictionary -> document -> service -> signal -> store -> UI`.
11
- - Scalars live under `lib/__scalar/<scalarName>` and represent embedded value objects.
12
- - Service modules live under `lib/_<serviceName>` when behavior is not centered on one stored model.
13
- - Generated registry files such as `cnst.ts`, `db.ts`, `dict.ts`, `sig.ts`, `srv.ts`, and `st.ts` are scanner outputs and should not be hand-authored.
9
+ - Scalars (`lib/__scalar/<scalarName>`) represent embedded value objects; service modules (`lib/_<serviceName>`) hold
10
+ behavior not centered on one stored model.
14
11
  - Pages and components should consume generated client/server helpers rather than duplicating model shapes.
15
12
 
16
13
  ## Codegen Rules
@@ -23,5 +20,5 @@
23
20
  When a request implies a distinct look and feel, do not stop at colors — customize both the theme and, when needed, the components.
24
21
 
25
22
  - **Theme (`apps/<app>/page/styles.css`).** The app imports Tailwind and `akanjs/ui/styles.css`, then overrides semantic token *values* per theme under `:root, [data-theme="dark"]` and `[data-theme="light"]` (`--background`, `--foreground`, `--primary`, `--muted`, `--border`, … each with a `-foreground` pair for text). The framework maps them to Tailwind color names, so `bg-primary` / `text-foreground` follow the `data-theme` attribute; corner rounding uses `--radius-box` / `--radius-field`. Fetch `get_guideline` with `cssRule` for the full token set before a deep theme pass.
26
- - **Lib tokens (`libs/<lib>/ui/tokens.css`).** Colors a lib's own components pin — a vendor brand color, a fixed surface — are declared once there as plain `:root` custom properties and compiled into every app that reaches the lib, ahead of the app's stylesheets. Reference them as `bg-[var(--kakao)]`; never copy the block into each app.
23
+ - **Lib tokens (`libs/<lib>/ui/tokens.css`).** The one CSS file a lib owns: colors its own components pin that must **not** follow the theme — a vendor brand color, a fixed surface — declared once as plain `:root` custom properties and compiled automatically into every app whose pages reach the lib, ahead of the app's stylesheets, so nothing is imported by hand. Reference them as `bg-[var(--kakao)]`; never copy the block into each app. `@theme` extensions stay in the app stylesheet, because the color vocabulary is closed per stylesheet, and theme-following colors are the app's.
27
24
  - **Components (`page/**/_overrides.tsx`).** When a default `akanjs/ui` component (Button, Modal, Table, Input, Select, …) is too restrictive for the design, re-skin it per route instead of forking, wrapping, or fighting it with utility classes. Write a drop-in replacement in `apps/<app>/ui/` typed against the slot contract (`AkanModalComponent`, or `AkanUiOverrides["<Slot>"]`), composing the framework's headless parts, then bind it in a `page/**/_overrides.tsx` manifest with a single `export default override({ Slot: BrandComponent })`. Overrides cascade down the route tree like layouts (closest ancestor wins). Fetch `get_guideline` with `componentRule` and read the `references/ui/customize` docs page for the slot list and patterns.
@@ -34,7 +34,8 @@ export class FileSlice extends slice(srv.file, {
34
34
  ```
35
35
 
36
36
  - **It is curation, not authorization.** HTTP serves the endpoint exactly as before and the guards are still the
37
- only thing deciding who may call it. Never write `mcp: false` where you meant a guard.
37
+ only thing deciding who may call it. Never write `mcp: false` where you meant a guard. To take generated CRUD off
38
+ HTTP too, write `guards: { cru: false }`.
38
39
  - **The `mcp` map on `slice()` mirrors the `guards` map above it** — same keys (`root`, `get`, `cru`, `create`,
39
40
  `update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
40
41
  the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
@@ -55,7 +56,8 @@ export class FileSlice extends slice(srv.file, {
55
56
  ## Configuration
56
57
  Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
57
58
  `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
58
- `promptBudget`, `apps`, `modern`, and `auth`. **Not `main.ts`**: the gateway
59
+ `promptBudget`, `apps`, `modern`, and `auth` — or `setMcp((env) => …)` for settings derived from the server env.
60
+ **Not `main.ts`**: the gateway
59
61
  there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
60
62
  mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
61
63
  declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
@@ -155,12 +157,23 @@ export default page()
155
157
  clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
156
158
  ships as the value itself either way. `resources/read` is unaffected too: `ReadResourceResult` has no
157
159
  `structuredContent`, so a resource's text is its only channel and always carries the serialized value.
160
+ - **A picture is an `ImageResult` return, and the one result a model sees rather than reads.**
161
+ `query(ImageResult, { guards: [...] })` (or `[ImageResult]`) returns `{ data, mimeType, caption? }`: `data` is
162
+ padded standard base64 with no `data:` prefix, `mimeType` one of `image/png`, `image/jpeg`, `image/webp`,
163
+ `image/gif` — the types the Anthropic and OpenAI models take. `tools/call` answers the caption as a text block and
164
+ the picture as an image block, one pair per picture, and publishes no `outputSchema` and no `structuredContent`:
165
+ either would repeat the base64, a few hundred KB, as text the model pays for and cannot see. A value that breaks
166
+ the shape is not sent — the dispatcher logs what an `ImageResult` needs and answers the generic failure. Put in
167
+ the caption what the picture cannot say on its own (the region or scale it spans). Over HTTP the endpoint is the
168
+ JSON object, so the app's own client and tests read it as usual; everywhere else an agent reads it — a page
169
+ prompt's resource, the in-page agent — its agent face carries `mimeType` and `caption` only. Nothing caps the
170
+ size, and Anthropic refuses an image over 5 MB, so keep a picture well under that. It is never a model field.
158
171
  - **A request schema asks for a relation's id, and a response schema names a nested model.** `serialize` sends a
159
172
  relation field as its id and the server never converts an object back, so publishing the related model's shape
160
173
  asked an agent for what the document layer cannot take. On the response side every entry inlines the transitive
161
174
  closure of every model it mentions (a `$ref` may not cross entries), and on an open catalogue 83% of the bytes
162
- were the same schemas repeated — so a nested database model is published as `{ type: "object", description:
163
- "<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
175
+ were the same schemas repeated — so by default (`outputSchema: "shallow"`) a nested database model is published
176
+ as `{ type: "object", description: "<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
164
177
  that took a 347-tool listing from 1194KB to 532KB, and one user's paged `tools/list` to 320KB.
165
178
  `option.setMcp({ outputSchema: "full" })` (`AKAN_MCP_OUTPUT_SCHEMA`) restores the closure; `"none"` publishes no
166
179
  `outputSchema` at all — results still ship as `structuredContent`, and the text block stays on whatever
@@ -225,15 +238,20 @@ export default page()
225
238
  - **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
226
239
  unparseable, outside its `enumOf`, or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
227
240
  published schema and nothing on the wire enforces it — and so does a document that is not there, as
228
- `No <model> found for the arguments given.` A prompt, having no `isError` to carry a refusal, answers `-32602`.
241
+ `No <model> found for the arguments given.`, and an MCP App view also receives the 404 `base.error.documentNotFound`
242
+ `Err` a browser would get. A prompt, having no `isError` to carry a refusal, answers `-32602`.
243
+ A document missing on another server this one called (`fetch.clone({ origin })`) arrives as that server's 404
244
+ `base.error.documentNotFound`, so a tool reads its dictionary sentence (`The requested item does not exist.`) and an
245
+ MCP App view receives that `Err`; a prompt whose page reads it is `No screen exists for these arguments.`
229
246
  Only a real failure logs a stack; an agent can drive the rest at will.
230
247
  - **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
231
248
  wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
232
249
  disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
233
250
  method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
234
- gone". **While MCP Apps renders views only the legacy pair is spoken**: `server/discover` answers `-32601` and
235
- `initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached through
236
- `initialize`. `modern: true` / `AKAN_MCP_MODERN=true` offers `2026-07-28` anyway; `false` withholds it everywhere.
251
+ gone". **While a `.prompt()` page may show as a view only the legacy pair is spoken**: `server/discover` answers
252
+ `-32601` and `initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached
253
+ through `initialize`. An app that renders no view, or whose RSC worker lists no `.prompt()` page, speaks all three.
254
+ `modern: true` / `AKAN_MCP_MODERN=true` offers `2026-07-28` anyway; `false` withholds it everywhere.
237
255
  - **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
238
256
  body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
239
257
  rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
@@ -311,9 +329,9 @@ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthReg
311
329
  (`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600, also how long a revoked
312
330
  access token can outlive its grant), `clientIdMetadata: { enabled, refusePrivateAddresses }` (both on; `enabled:
313
331
  false` turns Client ID Metadata Documents off and the metadata stops advertising them — Claude Code then registers
314
- dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong),
315
- `enabled: false` to take the server and the `/mcp` credential requirement away together. The signin page must
316
- honour `?redirect=` to return the user to the consent page.
332
+ dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong, since
333
+ an MCP client compares the issuer byte for byte), `enabled: false` to take the server and the `/mcp` credential
334
+ requirement away together. The signin page must honour `?redirect=` to return the user to the consent page.
317
335
  - **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
318
336
  opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
319
337
  (needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
@@ -362,7 +380,8 @@ rule: a page taking no arguments is the most useful prompt, since MCP has no com
362
380
  when the id is already in hand.
363
381
 
364
382
  ## MCP Apps — The Page, Rendered In The Chat
365
- **A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint`. Its result is the
383
+ **A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint: false`, since running
384
+ the page makes its own `fetch.*` calls, mutations included. Its result is the
366
385
  prompt's briefing plus `Open this screen in a browser: <url>` — what a client without the
367
386
  `io.modelcontextprotocol/ui` extension (Claude Code, Codex) reads. A host that declares the extension (claude.ai,
368
387
  Claude Desktop, ChatGPT, VS Code, Goose) is also given `_meta.ui.resourceUri` (`ui://<app>/app`, or
@@ -371,14 +390,17 @@ in a sandboxed iframe started on the page the tool ran. Its first queries are an
371
390
  the result's `_meta`, the one part no host shows the model — so the first paint costs no round trip; every later
372
391
  call is relayed by the host as `tools/call` under the host's token. Nothing is declared for any of it.
373
392
 
374
- - **What a view may call** is every published tool and every endpoint kept off the shelf by `mcp: false` alone,
375
- listed with `visibility: ["app"]` — which a host offers to the view and never to the model. Visibility is a host's
376
- UX, not authorization: the guards decide, as everywhere. A client that did not declare the extension is listed
377
- neither those tools nor the `ui://` resources, a call to one answers `Unknown tool`, and a page tool's result
378
- carries none of the view's data for it.
393
+ - **What a view may call** is every published tool, and every endpoint kept off the shelf by `mcp: false` alone or
394
+ the generated `light<Model>` read, listed with `visibility: ["app"]` — which a host offers to the view and never to
395
+ the model; a `Person` guard still refuses one. Visibility is a host's UX, not authorization: the guards decide, as
396
+ everywhere. A client that did not declare the extension is listed neither those tools nor the `ui://` resources, a
397
+ call to one answers `Unknown tool`, and a page tool's result carries none of the view's data for it.
379
398
  - **The capability is read per request.** A legacy client declares it once at `initialize` and is issued an
380
- `Mcp-Session-Id` (`ui.<uuid>`) that stores nothing, since its id is the capability; with `modern: true` a modern
381
- request carries it in `_meta`. A client that declares nothing is issued no session, exactly as before.
399
+ `Mcp-Session-Id` (`ui.<nonce>.<signature>`) that stores nothing: the app signs it, and an id the app did not sign is
400
+ served as a client without views. With `modern: true` a modern request carries the capability in `_meta`. A client
401
+ that declares nothing is issued no session, exactly as before.
402
+ - **A read-only deployment has no MCP Apps.** With `readOnly` / `AKAN_MCP_READONLY` there are no page tools, app-only
403
+ tools, views or UI sessions, since running a page makes its own `fetch.*` calls, mutations included.
382
404
  - **The view is the page, not the app around it.** Each layout above it still runs — guards, providers,
383
405
  `<Agent.Guide>` — but what it draws around `{children}` is hidden, with its frame chrome (`Layout.Navbar`,
384
406
  `TopInset`, `BottomInset`, `BottomTab`), the `topInset` / `bottomInset` it reserves and the tools its controls
@@ -469,7 +491,8 @@ the same guards.** What stays closed is closed because no agent has a reason to
469
491
  - **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
470
492
  and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
471
493
  listed, and stops the call. Marking a resource guard `"account"` would make the listing lie — the marker has no
472
- default for that reason.
494
+ default for that reason. An `"account"` guard that throws at listing time reached for arguments it does not
495
+ have: the entry is hidden from every caller and the guard is named once in the log as mismarked.
473
496
  - **Identity comes from the internal args** (`.with(Self)`, `.with(Account)`, `.with(Me)`), never from an id the
474
497
  client sent, and the service re-checks ownership behind the guard.
475
498
  - **An admin who signs in on the consent page mints an admin token.** A connector created under an admin account
@@ -9,7 +9,7 @@ Generate the business data shape for a collection-backed Akan module. This file
9
9
  - Define `ModelObject` by extending `via(ModelInput, (field) => ({ ... }))` for stored fields controlled by system or service behavior.
10
10
  - Define `LightModel` by selecting compact list/reference fields from the object layer.
11
11
  - Define full `Model` by combining object and light layers and adding only small static or type helpers when needed.
12
- - Define `ModelInsight` only when aggregation or dashboard values are useful.
12
+ - Always define `ModelInsight`, empty when the model has no aggregation or dashboard values yet.
13
13
 
14
14
  ## Current Akan Patterns
15
15
  - Use `via()` and the `field` helper for all model layers.
@@ -21,6 +21,7 @@ Generate the business data shape for a collection-backed Akan module. This file
21
21
  - Keep the light model small enough for lists, cards, and relation previews.
22
22
  - Do not put persistence queries, service workflows, fetch calls, or React UI in constant files.
23
23
  - Use stable defaults for arrays and frequently read values.
24
+ - Never declare a `static` on the Object or Light class: `via(XObject, LightX, …)` mixes in their prototypes only, so a static there never reaches `X` — put it on `XInput` (or a scalar), `X`, or `XInsight`. `no-static-in-object-light-model` recognises the Object class by its `*Object` name (its `via()` shape is the Insight's) and the Light class by its `Light*` name or its `[...] as const` field tuple.
24
25
  - Add dictionary coverage for public fields, enums, insights, and query-facing names.
25
26
 
26
27
  ## Review Checklist
@@ -22,6 +22,15 @@ Generate client state coordination for a module. Store connects generated fetch
22
22
  - Do not call private or internal signal operations from UI state.
23
23
 
24
24
  ## Form Draft Recovery
25
+ - `Load.Edit`, `Model.EditModal` and `Model.New` save the whole `<model>Form` as the user types and offer it back on
26
+ the next open, so a modal closed by accident, a route change, or a killed app costs nothing. The scope is the
27
+ record id for an edit and the seed plus the route for a new form, both keyed to the signed-in user, so another
28
+ record or another parent never loads someone else's half-finished form.
29
+ - A new form's draft is applied on open with a "start over" chip; an edit whose record moved in the meantime is
30
+ *not* applied, and asks. Turn it off with `draft={false}`, or name the scope yourself with `draft="<scope>"` when
31
+ the context is in neither the id nor the seed.
32
+ - The old per-field `cache` / `cacheKey` props are deprecated and store nothing — they covered five control types,
33
+ keyed on the translated label, and restored over server data.
25
34
  - `<model>FormDraft`, `restore<Model>FormDraft`, `discard<Model>FormDraft` and `load<Model>FormDraft` are
26
35
  generated. Do not hand-write a draft action, and do not write `<model>Form` to browser storage yourself.
27
36
  - The draft key carries the signed-in user, derived by hashing the auth token's payload with `iat`, `exp`, `nbf`
@@ -0,0 +1,80 @@
1
+ # Page Rule — Route Files, The Route Chain, Heads, Lib Pages
2
+
3
+ What a file under `page/` may be, how a route declares its arguments and settings, how heads merge, what a CSR
4
+ bundle shows of a root layout, and how a lib's routes reach an app. `conventions` carries the invariants and the page
5
+ body shape — this is the full contract behind them. Before changing route behavior, check
6
+ `pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
7
+
8
+ ## Route Files
9
+
10
+ - `apps/<app>/page` may contain route modules only. Do not add helper logic or component-only files there.
11
+ - Route source files under `page/` must use `.tsx`. Do not add `logic.ts`, `.js`, or `.jsx` files under `page/`.
12
+ - A route page is either `<routeName>.tsx` (serving `/routeName`) or a directory's `_index.tsx`; layouts use
13
+ `_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
14
+ - Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add
15
+ files like `_Component.tsx` or `_helper.tsx`.
16
+ - Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`,
17
+ `common`, or `lib` instead.
18
+ - Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
19
+ - `_overrides.tsx` is a logic-free UI-override manifest: imports plus a single
20
+ `export default override({ Slot: AppComponent })` (from `akanjs/ui`), no `"use client"`. It re-skins framework
21
+ `akanjs/ui` components for its route subtree; nested manifests merge over ancestors slot-by-slot (closest wins).
22
+ The slot list is in `componentRule`.
23
+
24
+ ## The Route Chain
25
+
26
+ - **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
27
+ `_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting is
28
+ a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` / `.error()` on
29
+ a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` / `.layoutStyle()` /
30
+ `.viewport()` on the root layout; `.prompt()` on a page. A named export beside the chain, or `page()` in a
31
+ `_layout.tsx`, fails the build.
32
+ - **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
33
+ `.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
34
+ Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
35
+ search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
36
+ them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`.
37
+ - **`lang` is never declared**: every route sits under `/:lang`, and the locale segment reaches every stage as
38
+ `lang`.
39
+ - **On a web page's first render `st.use.searchParams()` is still the server's empty snapshot**, synced right after
40
+ hydration, so an effect that needs the query on its first run takes the page's `.search()` value as a prop.
41
+ - There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
42
+ `.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
43
+
44
+ ## Heads
45
+
46
+ - **`.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function of the route's args returning it.** One that
47
+ throws renders no head (warned about), so a layout's sign-in redirect still answers. There is no metadata object,
48
+ and analytics is the app's own script, not a stage.
49
+ - **Heads merge root first**: a deeper `.head()` replaces the `<title>`, a `<meta>` with the same `name` /
50
+ `property`, and a canonical / manifest / icon link, and keeps the rest — so a page states only what it changes.
51
+ - A canonical link and `og:url` each name one URL, so a page carries them only from its own `.head()`, never from a
52
+ layout's.
53
+
54
+ ## A Root Layout Inside A CSR Bundle
55
+
56
+ - **A CSR bundle (a native app, `?csr=true`) never shows a root layout's own markup.** Pages render into the frame,
57
+ and what the root layout draws around `{children}` stays in a hidden container, so its wrappers cannot stack a
58
+ second screen beside the frame. Its components still mount and their effects run (`<Auth.User />` works).
59
+ - Anything a person must see from a root layout — an overlay, a gate, a banner — renders through
60
+ `createPortal(…, document.body)`, as `<Agent.Chat />` and the dialogs already do. A non-root `_layout.tsx` has no
61
+ such limit.
62
+
63
+ ## Lib Pages — `syncPageLibs`
64
+
65
+ - `libs/<lib>/page` follows the same rules and ships routes to apps that opt in with `syncPageLibs` in
66
+ `akan.config.ts`: `true` takes every lib dep that has a `page` folder, an array takes the libs listed, `false` (the
67
+ default) syncs nothing.
68
+ - `akan sync` links those routes into `apps/<app>/page/(libs)/(<lib>)` — once per basePath when the app declares
69
+ subRoutes. The folder is generated and gitignored like `public/libs`; edit the lib source, never the link.
70
+ - Both path segments are route groups, so a lib route mounts at its own path (`libs/<lib>/page/login/_index.tsx`
71
+ serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
72
+
73
+ ## Build Scope And The Legacy Shape
74
+
75
+ - `.config({ devOnly: true })` keeps a route out of `akan build` while it keeps serving under `akan start` and keeps
76
+ being typechecked. On a `_layout.tsx` it excludes every route under that directory too. Write it as a literal
77
+ `true`/`false` — the build reads it off the source without evaluating the module.
78
+ - The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
79
+ loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
80
+ `page/` to the route chain"; new files are never written in it.
@@ -1,8 +1,9 @@
1
1
  # Query Rule — Filters, Slices, Hydration, Search, Cascade
2
2
 
3
- How a model's data is queried, exposed to a client store, searched, and removed. Filters and slices are the two
4
- query surfaces; `q.search()` and the `text` field roles are the search surface; `cascade` is what a removal takes
5
- with it. `conventions` carries the invariants — this is the full contract behind them.
3
+ How a model's data is queried, exposed to a client store, searched, written, and removed. Filters and slices are
4
+ the two query surfaces; the model facade carries the scoped reads and writes; `q.search()` and the `text` field
5
+ roles are the search surface; `cascade` is what a removal takes with it. `conventions` carries the invariants —
6
+ this is the full contract behind them.
6
7
 
7
8
  ## Slices, Queries, and Hydration
8
9
 
@@ -31,6 +32,9 @@ with it. `conventions` carries the invariants — this is the full contract behi
31
32
  A model with none renders ids and says so in the picker.
32
33
  - A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
33
34
  - Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
35
+ - **`limit: 0` means every row on the server, never on the client.** A server `list<Filter>(…, { limit: 0 })`
36
+ reads without a ceiling, like an omitted limit, and `null` pages at 20. A client's `0` (`initX(…, { limit: 0 })`)
37
+ is served `MAX_PAGE_SIZE` (500) with a one-time warn. Paginate (`loadMoreOf<Model>`) or name a page size.
34
38
  - **Every sort ends with `id`, in the direction of its last key, and a missing value sorts below every other** —
35
39
  first ascending, last descending, in every database mode and in the client placing a live row. Rows the declared
36
40
  keys tie on (a midpoint priority dragged into one gap until the float runs out, two rows saved in one millisecond)
@@ -51,8 +55,9 @@ with it. `conventions` carries the invariants — this is the full contract behi
51
55
  - **A projection is bare on the facade and nested under `select` on a filter accessor.** `pickById(id, { secretField:
52
56
  true })` / `find(query, { secretField: true })` take the projection as their second argument; a generated
53
57
  `findBy<Filter>(...args, { select: { secretField: true } })` takes it inside the option object. The shapes do not
54
- swap: `{ select: … }` handed to the facade projects a field named `select`, which no model has, and the read comes
55
- back empty with no error. This is the only way to read a `field.secret(...)` value, which is otherwise stripped.
58
+ swap: `{ select: … }` handed to the facade projects a field named `select`, which no model has, and throws
59
+ `Unknown document field path`. The facade's third argument is the scope (`{ withRemoved: true }`), never a field.
60
+ This is the only way to read a `field.secret(...)` value, which is otherwise stripped.
56
61
  - **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`), and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true** (`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape (functions stripped, `pkgs/akanjs/base/types.ts`) is what the `<ref>Obj` / `<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the value can cross the RSC boundary as a client prop.
57
62
  - Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
58
63
  `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
@@ -81,6 +86,30 @@ with it. `conventions` carries the invariants — this is the full contract behi
81
86
  matches a freshly inserted row and silently stops matching that same row after its first round-trip save. Reach
82
87
  for it only to find rows written before the field was declared.
83
88
 
89
+ ## Soft Removal And The Facade's Writes
90
+
91
+ - **Removal is always soft** — the framework has no hard delete for a model table, and `delete` is deliberately
92
+ left unused so it can mean one later. Every read and query-level write sees live rows only (`removedAt IS NULL`).
93
+ - **`{ withRemoved: true }` reaches removed rows**: the third argument of `find` / `findOne` / `findById`
94
+ (projection second, `null` when none), the last of `count` / `exists` / `updateMany`, or inside `updateOne` /
95
+ `updateById`'s options. Without it, a `removedAt` condition only a removed row can meet (`q.exists("removedAt")`,
96
+ a comparison) throws `can never match`.
97
+ - Removes, slices, filter methods, loaders and `pickById` stay live-only, a removed document is read-only
98
+ (`save()` throws), and `updateById(id, { removedAt: null }, { withRemoved: true })` revives one without hooks.
99
+ - **A `pick` that finds no live row throws `NoDocumentError`**, so the generated `get<Model>` of a missing or
100
+ removed id answers an HTTP or websocket caller 404 `base.error.documentNotFound`, and an agent `No <model> found
101
+ for the arguments given.` The message, which names the table and the id or query, stays on the server. Throw
102
+ your own `Err.NotFound` only where a nullable load decides that nothing found is an error.
103
+ - The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only
104
+ the count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`.
105
+ - **`updateById(id, update)` and `removeById(id)` are those same query-level writes narrowed to one id, not the
106
+ document path**: they fire no hooks, so a model whose removal cascades or carries a `_postRemove` still goes
107
+ through `remove<Model>(id)`.
108
+ - The write-through pair is `pickAndWrite(id, data)` / `pickOneAndWrite(query, data)` — pick the document, write
109
+ into it and save it, so the save hooks do run; reach for those when the write has to be seen by them.
110
+ - `upsertById(data, { preserveTimestamps })` writes a record whole under its own id, new or in place, with no
111
+ hook: the write a sync or a backfill repeats safely, keeping the record's `createdAt`/`updatedAt` when asked.
112
+
84
113
  ## Text Search In A Filter — `q.search()`
85
114
 
86
115
  - Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
@@ -108,10 +137,12 @@ with it. `conventions` carries the invariants — this is the full contract behi
108
137
 
109
138
  - A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
110
139
  `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
111
- - Pick the role by what the value *is*, because the rank weights them positionally (`title` 10, `tag` 3, `desc` 1,
112
- `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
113
- scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
140
+ - Pick the role by what the value *is*, because the rank (`bm25` on SQLite) weights them positionally (`title` 10,
141
+ `tag` 3, `desc` 1, `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword
142
+ list, `filter` is a scoping value (status, owner, role) that must be matchable but must never outrank a real title
143
+ hit.
114
144
  - `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
145
+ - `schema.index({ x: "text" })` (a Mongo text spec) builds nothing and warns at boot: full-text search is this role.
115
146
  - **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
116
147
  mirror is plaintext and an indexed secret would leak through search. The class build throws the same refusal as
117
148
  a backstop, for an option object the excess-property check cannot see through, and names the way out: drop the
@@ -203,7 +234,9 @@ shape, so `cascade` never means "related" — it means one of exactly these:
203
234
  generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
204
235
  at a time when it cascades.
205
236
  - Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write, and a remove never takes
206
- `{ withRemoved: true }`, so an already-removed row keeps its original `removedAt`. Cycles are cut by a visited set
207
- carried down the whole chain, with a depth cap of 16. `q.search()` cannot see removed rows (the mirror drops
208
- them), so search with the flag throws, as does `upsert` with it.
237
+ `{ withRemoved: true }`, so an already-removed row keeps its original `removedAt`. Every document a cascade
238
+ removes one at a time joins a visited set carried down the whole chain (a bulk target never does), so a target held
239
+ by two `removeRef` fields (or twice in one array) is removed once and cycles are cut; the depth cap is 16.
240
+ `q.search()` cannot see removed rows (the mirror drops them), so search with the flag throws, as does `upsert` with
241
+ it.
209
242