@akanjs/cli 3.0.2 → 3.1.0-rc.1

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 (87) hide show
  1. package/.build-stamp +1 -1
  2. package/{CodeAgent-szbjv4gv.js → CodeAgent-kyprmy06.js} +2 -2
  3. package/{CodeTui-5nw86ab6.js → CodeTui-21mtze3j.js} +1 -1
  4. package/{TunnelShare-cwh9ezsm.js → TunnelShare-q4jq08qg.js} +2 -2
  5. package/{agent.command-np1dffkb.js → agent.command-f3gsc1gd.js} +5 -5
  6. package/akanCode.js +2 -2
  7. package/{application.command-gk69v1xs.js → application.command-5s780vnz.js} +37 -15
  8. package/{applicationBuildRunner-wx09n9g6.js → applicationBuildRunner-1yaqsrdq.js} +115 -103
  9. package/buildBatch.proc.js +16 -5
  10. package/cloud.command-yj6xb2a7.js +192 -0
  11. package/cloud.script-h6pvcpa2.js +20 -0
  12. package/{code.command-hmtx14b2.js → code.command-3wkn4ja5.js} +6 -6
  13. package/commandManifest.json +1 -1
  14. package/{context.command-6aqvdx43.js → context.command-9jjn3gcw.js} +13 -13
  15. package/context.runner-atcrzz5b.js +21 -0
  16. package/{guideline.command-axvf68gc.js → guideline.command-dtczrqpf.js} +2 -2
  17. package/guidelines/agentRule/agentRule.instruction.md +10 -2
  18. package/guidelines/conventions/conventions.instruction.md +70 -25
  19. package/guidelines/cssRule/cssRule.instruction.md +19 -0
  20. package/guidelines/mcpRule/mcpRule.instruction.md +72 -12
  21. package/guidelines/queryRule/queryRule.instruction.md +11 -1
  22. package/guidelines/runtimeRule/runtimeRule.instruction.md +97 -16
  23. package/guidelines/ssrRule/ssrRule.instruction.md +10 -7
  24. package/guidelines/transportRule/transportRule.instruction.md +19 -0
  25. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +6 -6
  26. package/incrementalBuilder.proc.js +24 -3
  27. package/{index-h9ht6s60.js → index-05g3jayg.js} +1 -1
  28. package/{index-jk9xnck6.js → index-1w2qaee2.js} +240 -133
  29. package/{index-g38nhv87.js → index-2etcv0sc.js} +1 -1
  30. package/{index-zyw622y1.js → index-2mx692qq.js} +3 -3
  31. package/{index-50wb2a26.js → index-55mynnqs.js} +354 -129
  32. package/{index-2wc41k5w.js → index-56d2g8ms.js} +2 -2
  33. package/{index-q6ft9fkc.js → index-6jjtnhkw.js} +8 -3
  34. package/index-6w0rbr9d.js +68 -0
  35. package/{index-hxnnfy57.js → index-8ggztp8k.js} +204 -8
  36. package/{index-zwd84m0w.js → index-8r4d7cmt.js} +4 -4
  37. package/{index-j38qkvkk.js → index-ae5pwhgs.js} +12 -10
  38. package/{index-gtjgnv36.js → index-aprt7pkz.js} +6 -9
  39. package/{index-1bjkfvm9.js → index-c1x0be5j.js} +2 -2
  40. package/{index-z750gk1j.js → index-c2yrsxdh.js} +13 -7
  41. package/{index-cb6yc5mb.js → index-c76k723e.js} +2 -2
  42. package/{index-6keyf4sg.js → index-ctanya41.js} +4 -4
  43. package/{index-f4e08ptp.js → index-e0cvze1s.js} +4 -4
  44. package/{index-hp3stk1m.js → index-fm46xrf1.js} +3 -3
  45. package/{index-3xmzr1p1.js → index-gnyf1fqy.js} +2 -2
  46. package/{index-nwd6qzvv.js → index-jx4bzvwg.js} +138 -55
  47. package/{index-jb0hmt3k.js → index-krq7btbt.js} +3 -3
  48. package/{index-94s080qm.js → index-pgj2rt78.js} +139 -1
  49. package/index-q958tqqg.js +77 -0
  50. package/{index-50jjwg8y.js → index-qt22xstj.js} +1 -1
  51. package/index-rc5rwyps.js +201 -0
  52. package/{index-xwprn9nr.js → index-rtct7d43.js} +1 -1
  53. package/{index-bsv901fd.js → index-rwq3ekqc.js} +7 -7
  54. package/{index-1gs9g12n.js → index-v29746vb.js} +4 -4
  55. package/index-ww9bhp67.js +62 -0
  56. package/index.js +22 -21
  57. package/{library.command-9m2rdwwz.js → library.command-gaga5bmq.js} +3 -3
  58. package/{localRegistry.command-kda81jyk.js → localRegistry.command-vsreyy42.js} +9 -9
  59. package/{module.command-q63dffya.js → module.command-7vpf6db4.js} +5 -5
  60. package/{package.command-y2jh0m99.js → package.command-twmvvf69.js} +3 -3
  61. package/package.json +2 -2
  62. package/{page.command-f3e4cwhh.js → page.command-5asq7fdf.js} +3 -3
  63. package/pipeline.command-d0v38572.js +1334 -0
  64. package/{primitive.command-r4kq75qj.js → primitive.command-dyrs2fm8.js} +6 -6
  65. package/projectTypecheck-cvtkrzp2.js +7 -0
  66. package/{quality.command-f7gzh26z.js → quality.command-6emsqpbg.js} +5 -4
  67. package/{repair.command-0qmyr6qa.js → repair.command-8t6aegv5.js} +5 -5
  68. package/{scalar.command-kddn7av4.js → scalar.command-bnggy1x7.js} +4 -4
  69. package/{subspace.command-ft65qvc4.js → subspace.command-md2teygq.js} +158 -91
  70. package/templates/app/env/env.client.type.ts +2 -3
  71. package/templates/app/page/styles.css.template +8 -0
  72. package/templates/appSample/page/task/[taskId]/_index.tsx +2 -2
  73. package/templates/appSample/page/task/[taskId]/edit.tsx +2 -2
  74. package/templates/appSample/page/task/_index.tsx +2 -2
  75. package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -2
  76. package/templates/crudPages/[__model__Id]/page.tsx +3 -3
  77. package/templates/crudPages/page.tsx +2 -2
  78. package/templates/crudSinglePage/page.tsx +3 -3
  79. package/templates/lib/cnst.ts +2 -2
  80. package/templates/lib/sig.ts +3 -3
  81. package/{tunnel.command-b3ymdk4f.js → tunnel.command-jx3tdm2d.js} +16 -13
  82. package/{workflow.command-z1srcdfw.js → workflow.command-jx0wag5r.js} +10 -10
  83. package/{workspace.command-vp6vmx3z.js → workspace.command-yxd7rr7g.js} +57 -27
  84. package/cloud.command-tw06613r.js +0 -95
  85. package/cloud.script-64gzba8r.js +0 -20
  86. package/context.runner-hnkvv3z3.js +0 -21
  87. package/index-t91a7339.js +0 -104
@@ -67,7 +67,9 @@ back.
67
67
  - **Never import a third-party package** from `page/**`, from any barrel, or from any
68
68
  `*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
69
69
  (`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
70
- `common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft.
70
+ `common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft. A module's
71
+ `*.signal.test.ts` / `*.service.test.ts` is held to the same rule but may import the runtime's `node:*` and
72
+ `bun:*` modules (a temp dir, a socket, sqlite), which module code leaves to `srvkit/`.
71
73
  - **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
72
74
  `*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
73
75
  `#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
@@ -202,6 +204,13 @@ back.
202
204
  - Prefer focused behavior tests for public contracts and edge cases over implementation-detail assertions.
203
205
  - 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.
204
206
  - 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()`.
207
+ - **One suite per module file, named after it.** A module test is `<model>.<role>.test.ts` beside the file it covers
208
+ (`chatMessage.signal.test.ts`, `chatMessage.service.test.ts`, `ChatMessage.Zone.test.tsx`). Never split one role
209
+ across topic files such as `chatMessage.trim.signal.test.ts` / `chatMessage.security.signal.test.ts` — add a
210
+ `describe` block to the existing suite. A suite that drives several modules (guards across models, an app's MCP
211
+ exposure list) belongs to none of them and goes in `srvkit/`, like `libs/shared/srvkit/guards.signal.test.ts`.
212
+ Never put a test at the `lib/` root. `akan sync` refuses all three shapes. Nothing imports a test, so a build never
213
+ typechecks one; `akan typecheck <app-or-lib>` does, `bun:test` types included.
205
214
  - `lib/user/user.signal.spec.ts` is the one place agent types are re-exported and re-typed; import `UserAgent` / `AdminAgent` from there rather than from the owning lib directly.
206
215
  - A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
207
216
 
@@ -310,11 +319,11 @@ are, because those are the ones the server could have done.
310
319
  consume them in a server component, never as a `Zone` prop.
311
320
  6. **Use named `ReactNode` slots, not just `children`** — a client shell that takes one slot per region lets a
312
321
  server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
313
- declares `title` / `left` / `right` alongside `back` and `children`, but renders only the last two today, so
314
- copy the shape rather than the component.
322
+ is the shape: `title` / `left` / `right` beside `back` and `children`, each filled by the server.
315
323
  7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
316
324
  lookups belong in a module-scope `as const` map.
317
- 8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any HTML is sent.
325
+ 8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any of the page's
326
+ markup renders. After its fetch the redirect is an in-page one (HTTP 200); `.config({ ssr: "block" })` makes it a 3xx.
318
327
  9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
319
328
  `<details>`/`<summary>`, keeps both branches server-rendered.
320
329
  10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
@@ -379,8 +388,8 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
379
388
  from `akan start`; `build-ios` / `build-android` / `build-desktop` write
380
389
  `dist/native/<app>/<target>/build/<platform>`, a desktop app only for the OS that builds it — `--arch` picks
381
390
  a Windows or Linux app's CPU, and a macOS app is Apple silicon only. `--installer true` adds a Windows setup, a
382
- macOS dmg or a Linux AppImage; `AKAN_NATIVE_MACOS_*` signs with a Developer ID and notarizes,
383
- `AKAN_NATIVE_WINDOWS_*` signs with Authenticode. With `updates: { url, publicKey }` an installed app takes the signed
391
+ macOS dmg or a Linux AppImage; `--sign` signs with the identity `AKAN_NATIVE_MACOS_*` / `AKAN_NATIVE_WINDOWS_*`
392
+ names and `--notarize` uploads the signed macOS app to Apple — the env alone does neither. With `updates: { url, publicKey }` an installed app takes the signed
384
393
  releases `akan publish-update` makes; `akan update-keygen` makes the key and prints its `publicKey`.
385
394
  - **A desktop app that carries its server (`desktop: { server: true }` in `native`, or in one target) gets nothing
386
395
  from `docker`** — and `server: { omit: [...] }` leaves out packages only the image needs. That server is API-only, runs in database mode `single`, and listens on a loopback port any
@@ -393,7 +402,9 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
393
402
  `NativeFile.resolve(grant, "read")` in `akanjs/server` asks the shell for it. What it carries is readable on the
394
403
  user's computer — `private/` (each lib's too, under `private/libs/<lib>`), the one `env.server.<env>.ts` of its
395
404
  `--env` and its libs' server env defaults (`env.server.testing.ts`) — and it has no `public/`: read runtime files
396
- from `AKAN_APP_DIR`, never `process.cwd()`.
405
+ from `AKAN_APP_DIR`, never `process.cwd()`. **`server: { profile: "edge" }` bundles `env.server.<env>.edge.ts` in
406
+ that file's place**, and the build refuses one holding a secret-named value; `server: { modules: ["@edge"] }`
407
+ mounts only the modules whose `serve()` lists `operationMode: ["edge"]`, and what they reach.
397
408
  - **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
398
409
  trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
399
410
  the `akan.config.ts` that owns the font, written against that scope's own `public/`.
@@ -481,7 +492,9 @@ count was shortened to `count(query)`, with `countDocuments` kept as `@deprecate
481
492
  `removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
482
493
  so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`. The
483
494
  write-through pair is `pickAndWrite(id, data)` / `pickOneAndWrite(query, data)` — pick the document, write into it
484
- and save it, so the save hooks do run; reach for those when the write has to be seen by them.
495
+ and save it, so the save hooks do run; reach for those when the write has to be seen by them. `upsertById(data, {
496
+ preserveTimestamps })` writes a record whole under its own id, new or in place, with no hook: the write a sync or a
497
+ backfill repeats safely, keeping the record's `createdAt`/`updatedAt` when asked.
485
498
 
486
499
  **`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
487
500
  explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
@@ -517,7 +530,9 @@ workflow changes.
517
530
 
518
531
  - Keep domain business operations in `.service.ts` classes built with `serve(...)`.
519
532
  - Keep execution contracts and triggers in `.signal.ts` classes built with `internal(...)`, `slice(...)`, and `endpoint(...)`.
520
- - Use `Internal` for internal triggers such as init, interval, cron, or queue jobs.
533
+ - Use `Internal` for internal triggers such as init, interval, cron, or queue jobs. A `process` job's
534
+ `backoff: { type: "exponential", delay, maxDelay }` caps the wait between attempts, and the internal's
535
+ `onFailed(job, error)` runs once a job has failed its last attempt — the dead letter, on either queue.
521
536
  - Use `Slice` for typed data views that feed client stores and zones; keep each slice focused on one purpose.
522
537
  - Use `Endpoint` for query and mutation contracts exposed to callers.
523
538
  - Connect external APIs or infrastructure through adapters, usually under `srvkit/`, and inject them into services instead of importing vendor clients directly into domain logic.
@@ -541,6 +556,11 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
541
556
  inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
542
557
  *gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
543
558
  recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
559
+ - **A device or another server proves itself with a machine token, never a staff session.**
560
+ `MachineToken.issue({ kind, id, version })` (`akanjs/server`) signs one with `AKAN_MACHINE_PRIVATE_KEY` (made by
561
+ `akan machine-keygen <file>`), with no expiry; a server holding that key or `AKAN_MACHINE_PUBLIC_KEY` sets
562
+ `account.machine` from a verified one on HTTP and websocket alike, and only from one. Read it with `.with(Machine)`
563
+ or in a guard, and revoke by bumping the version the guard compares to the record's own.
544
564
  - **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
545
565
  (`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
546
566
  bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
@@ -603,7 +623,7 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
603
623
 
604
624
  - `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
605
625
  - Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
606
- - `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a model field type.
626
+ - `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a model field type. The other body args of such a mutation arrive typed as in a JSON body. A file's `type` on the server is what its name implies, not its bytes — check the bytes when the type matters.
607
627
  - Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference. A `Binary` return travels as base64 in an
608
628
  HTTP body, and a return declared `Any` that turns out to be bytes fails instead of reaching the caller as `{}`.
609
629
 
@@ -669,7 +689,8 @@ Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `la
669
689
  "archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
670
690
  touches nothing.
671
691
  - `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
672
- most one of these", not for claiming the next item off a queue.
692
+ most one of these", not for claiming the next item off a queue. A condition in the query is a compare-and-set in
693
+ every mode: of two concurrent calls that both saw the row match, one applies, and `modifiedCount` says which.
673
694
  - **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
674
695
  single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
675
696
  - **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
@@ -735,7 +756,12 @@ Conventions that hold for both shapes:
735
756
  - `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.
736
757
  - 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.
737
758
  - **A server-side `fetch.clone({ origin })` opens no websocket unless `connect: true`**: it makes one-off calls to
738
- another server, and a socket it opened would reconnect forever with nobody to close it.
759
+ another server, and a socket it opened would reconnect forever with nobody to close it. It never carries the
760
+ visitor's session either: a call to another origin sends only the `jwt` its caller gave the clone.
761
+ - **Retry a call with `retry`, never with a loop of your own.** `fetch.clone({ origin, retry: {} })` (or a call's
762
+ own `{ retry }`) tries again on an unreachable server, a 502/503/504 and a 429 after its Retry-After, backing off
763
+ with jitter; a mutation retries under one `Idempotency-Key` it mints, or the `idempotencyKey` you name, and the
764
+ server runs a mutation once per key and caller, answering a repeat with the first result. A 4xx is never retried.
739
765
  - `String(err)` and `${err}` read `Err: <key> {…data}`, the data as one line of JSON with secret-named keys masked,
740
766
  so a ``logger.warn(`…: ${String(error)}`)`` keeps the exit code or status the `Err` carried.
741
767
  - **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })` restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a `new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to `Internal Server Error` on the way out.
@@ -768,9 +794,10 @@ it.
768
794
  `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
769
795
  tokens, `outputSchema` for nothing that reaches the model.
770
796
  - Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
771
- allowedOrigins, pageSize, language, outputSchema, rateLimit, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
797
+ allowedOrigins, pageSize, language, outputSchema, rateLimit, apps, modern, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
772
798
  `main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
773
- valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off. Every call that executes an
799
+ valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off, and an edge server
800
+ (`AKAN_PUBLIC_OPERATION_MODE=edge`) starts with it off unless `AKAN_MCP=true` or `setMcp(true)`. Every call that executes an
774
801
  endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
775
802
  / `AKAN_MCP_RATE_LIMIT` tunes or disables it.
776
803
  - **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
@@ -811,7 +838,15 @@ it.
811
838
  from, minus what is already attached; lists — only lists — are cut to `promptBudget`. A guard refusing a query
812
839
  inside the body reads as the screen refusing the account, the same way a redirect does. A
813
840
  required argument left out answers a pointer to the `<model>List…` tool, never a guess. The description is the
814
- only instruction, in English — `<Agent.Guide>` never reaches MCP, and the in-page chat lists no page prompts.
841
+ only instruction, in English — `<Agent.Guide>` never reaches a prompt, and the in-page chat lists no page prompts.
842
+ - **The same page is a tool too, and an MCP Apps host renders it.** A `.prompt()` page answers `tools/call` with
843
+ that briefing plus its web URL, and a host that declares the `io.modelcontextprotocol/ui` extension (claude.ai,
844
+ ChatGPT, VS Code) shows the screen itself in the chat — the CSR bundle in a sandboxed iframe whose every call the
845
+ host relays under the agent's token. It shows the page alone: every layout above it still runs (guards, providers,
846
+ `<Agent.Guide>`) and draws nothing, unless it draws no page, as a gate does. Nothing is written for the view. An
847
+ endpoint kept off the shelf only by `mcp: false` stays callable by that view and is listed to no model; a `Person`
848
+ act is still refused there. `option.setMcp({ apps: false })` keeps pages prompts only, and a page named like an
849
+ endpoint stays one (boot warns).
815
850
 
816
851
  Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
817
852
  `McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
@@ -905,7 +940,7 @@ Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH
905
940
  backstop, including for a `text` field *underneath* one of those. Do not work around either.
906
941
  - The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
907
942
  indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
908
- - Search runs in every database mode — fts5 on SQLite/libSQL, a weighted `tsvector` (`pg_trgm` for `trigram`) on
943
+ - Search runs in every database mode — fts5 on SQLite, a weighted `tsvector` (`pg_trgm` for `trigram`) on
909
944
  Postgres. The same text matches the same documents; only the order may differ on Postgres.
910
945
 
911
946
  ### Image & File Fields
@@ -957,9 +992,12 @@ shape, so `cascade` never means "related" — it means one of exactly these:
957
992
  `_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
958
993
  is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` /
959
994
  `.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` /
960
- `.layoutStyle()` on the root layout; `.prompt()` on a page. A named export beside the chain, or `page()` in a
961
- `_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function of the
962
- route's args returning it; there is no metadata object, and analytics is the app's own script, not a stage.
995
+ `.layoutStyle()` / `.viewport()` on the root layout; `.prompt()` on a page. A named export beside the chain, or
996
+ `page()` in a `_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function
997
+ of the route's args returning it, and one that throws renders no head (warned about) so a layout's sign-in redirect
998
+ still answers; there is no metadata object, and analytics is the app's own script, not a stage.
999
+ Heads merge root first: a deeper `.head()` replaces the `<title>`, a `<meta>` with the same `name` / `property`,
1000
+ and a canonical / manifest / icon link, and keeps the rest — so a page states only what it changes.
963
1001
  - **A CSR bundle (a native app, `?csr=true`) never shows a root layout's own markup.** Pages render into the
964
1002
  frame, and what the root layout draws around `{children}` stays in a hidden container, so its wrappers cannot
965
1003
  stack a second screen beside the frame. Its components still mount and their effects run (`<Auth.User />` works).
@@ -971,7 +1009,9 @@ shape, so `cascade` never means "related" — it means one of exactly these:
971
1009
  Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
972
1010
  search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
973
1011
  them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`. `lang` is
974
- never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`.
1012
+ never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`. On a web
1013
+ page's first render `st.use.searchParams()` is still the server's empty snapshot, synced right after hydration, so
1014
+ an effect that needs the query on its first run takes the page's `.search()` value as a prop.
975
1015
  - `_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.
976
1016
  - The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
977
1017
  loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
@@ -988,10 +1028,10 @@ shape, so `cascade` never means "related" — it means one of exactly these:
988
1028
  export default page()
989
1029
  .param("orgId", ID)
990
1030
  .config({ transition: "stack" })
991
- .render(async ({ orgId }) => {
1031
+ .render(({ orgId }) => {
992
1032
  const { l } = usePage();
993
1033
  getSelf({ unauthorize: "/signin" });
994
- const [{ org }, { taskInitInOrg }] = await Promise.all([fetch.viewOrg(orgId), fetch.initTaskInOrg(orgId)]);
1034
+ const { taskInitInOrg } = fetch.initTaskInOrg(orgId);
995
1035
  return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
996
1036
  });
997
1037
  ```
@@ -999,7 +1039,8 @@ export default page()
999
1039
  - There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
1000
1040
  `.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
1001
1041
  - Body order inside `.render()`: `usePage()`, auth, fetch, return.
1002
- - Run independent fetches through `Promise.all`, even when there is only one.
1042
+ - Hand each `fetch.init*` / `view*` / `edit*` field to its section unawaited. Await only what the shell itself
1043
+ reads — a title, a redirect, an id for a link — and run those awaits through one `Promise.all`.
1003
1044
  - Gate auth at `_layout.tsx`; repeating `getSelf({ unauthorize: "/signin" })` in the page is fine and common.
1004
1045
  - `async` on the render callback only when the body awaits; the callback is not a React component, so it takes no
1005
1046
  name and no return type.
@@ -1020,12 +1061,12 @@ export default page()
1020
1061
  content outside the markers is yours. `akan lint` fails when the generated section is stale.
1021
1062
  - 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.
1022
1063
  - Do not add `apps/*/base` or `libs/*/base`; place shared utilities under that app or lib's own `common/`.
1023
- - `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` — plus a `<model>.signal.test.ts` / `.spec.ts`, the one hand-written file that belongs there because the suite boots the whole barrel.
1064
+ - `apps/*/lib` and `libs/*/lib` root files are limited to generated/support files: `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`, `useServer.ts`. Nothing hand-written sits beside them, tests included: a test goes in the module folder it covers (`lib/<model>/<model>.signal.test.ts`), or in `srvkit/` when it drives several modules.
1024
1065
  - Domain module folders are `lib/<model>` for database modules, `lib/_<service>` for service modules, and `lib/__scalar/<scalar>` for scalar modules.
1025
1066
  - Database module UI files are limited to `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.Util.tsx`, `<Model>.View.tsx`, and `<Model>.Zone.tsx`.
1026
1067
  - Service module UI files are limited to `<Service>.Util.tsx` and `<Service>.Zone.tsx`.
1027
1068
  - Scalar module UI files are limited to `<Scalar>.Template.tsx` and `<Scalar>.Unit.tsx`.
1028
- - Module `*.test.ts`, `*.test.tsx`, `*.spec.ts`, and `*.spec.tsx` files are allowed.
1069
+ - A module test is named after the one module file it covers — `<model>.<role>.test.ts` / `.spec.ts`, `<Model>.<Role>.test.tsx` — so `<model>.<topic>.signal.test.ts` is refused, just like `<model>.<topic>.signal.ts`.
1029
1070
  - `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.
1030
1071
  - Generated facet indexes export only 1-depth files/folders with `export * from "./name";`. A facet folder with
1031
1072
  nothing to export syncs to `export {};` (a missing folder gets no barrel), so a stale barrel heals on the next
@@ -1131,6 +1172,10 @@ actions and state (see `<model>.store.ts` above).
1131
1172
  exist yet and the whole `libs/` container is watched, which is the safe direction.
1132
1173
  - Test production build generation with `bun run akan build <appName>`.
1133
1174
  - To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
1175
+ - A server a test boots in-process takes `start({ listen: false, schedules: false })` to run no internal (init,
1176
+ interval, cron, queue worker); `listen: false` alone still runs them, as a batch replica does. A server process a
1177
+ test spawns from source resolves the workspace from its cwd and gets the root `.env` from Bun, so spawn it inside
1178
+ the repo with `bun --no-env-file`, or spawn the built `dist/apps/<app>` output, which needs neither.
1134
1179
 
1135
1180
  ```bash
1136
1181
  cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
@@ -62,6 +62,25 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
62
62
  - Theme-following colors still belong to the app: a lib token is for a color that must **not** change with `data-theme`.
63
63
  - An `@import` the CSS pipeline cannot resolve fails the build; it is never dropped. A stylesheet under `page/` that no route imports is compiled by nothing and warns at build time.
64
64
 
65
+ ## Frame Variables (the page's chrome and the device's insets)
66
+
67
+ The frame writes these on `#frameRoot` and `<html>`, so any class can read them, web and native alike:
68
+
69
+ - `--akan-top-safe-area` / `--akan-bottom-safe-area` — the notch and the home indicator. On the web they are
70
+ `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.
75
+ - `--akan-top-inset` / `--akan-bottom-inset` — the height of the page's top bar and bottom tab slot, per breakpoint
76
+ when the page declares a responsive inset. A slot whose inset is 0 at a width clips its chrome there and hides it
77
+ from the keyboard and screen readers, so a phone-only bar needs no `md:hidden`.
78
+ - `--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).
80
+ - `--akan-content-height` — the room between the insets. iOS pans under the keyboard instead of shrinking the page,
81
+ 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
+ of its column, or in the keyboard slot.
83
+
65
84
  ## Codegen Rules
66
85
  - Do not hardcode hex or raw-palette colors (`bg-red-500`, `#ff0000`); use semantic tokens.
67
86
  - Do not put inline color literals in `style={{ }}`; use token classes.
@@ -10,7 +10,8 @@ The always-loaded convention set carries the short version; this is the full con
10
10
  Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
11
11
  follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
12
12
  that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
13
- `Public`. `AKAN_MCP=false` takes the whole surface off. The reasoning is that the guards are already the
13
+ `Public`. `AKAN_MCP=false` takes the whole surface off, and an edge server (`AKAN_PUBLIC_OPERATION_MODE=edge`) starts
14
+ with it off — `AKAN_MCP=true` or `setMcp(true)` puts it back. The reasoning is that the guards are already the
14
15
  authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
15
16
  switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
16
17
  until somebody remembers it.
@@ -38,6 +39,9 @@ export class FileSlice extends slice(srv.file, {
38
39
  `update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
39
40
  the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
40
41
  their own `mcp` beside their own `guards`. The bare `mcp: false` is every key at once, still within that scope.
42
+ - **A screen keeps reaching it.** To a host that renders MCP Apps views, an endpoint refused for `mcp: false` alone
43
+ is published with `visibility: ["app"]`: the view the page tool opens may call it, the model is never shown it
44
+ (below). Every other refusal still holds there.
41
45
  - **`mcp: true` is the default and is a no-op**; write it only to say out loud that an entry is deliberately on
42
46
  the shelf. It does not override a shape refusal (an `Any` return stays refused) and it cannot publish something
43
47
  the guard rules keep out.
@@ -50,13 +54,14 @@ export class FileSlice extends slice(srv.file, {
50
54
 
51
55
  ## Configuration
52
56
  Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
53
- `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`, and `auth`. **Not `main.ts`**: the gateway
57
+ `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
58
+ `promptBudget`, `apps`, `modern`, and `auth`. **Not `main.ts`**: the gateway
54
59
  there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
55
60
  mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
56
61
  declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
57
62
  `AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
58
63
  `AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_OUTPUT_SCHEMA`, `AKAN_MCP_RATE_LIMIT`, `AKAN_MCP_CONCURRENT`,
59
- `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
64
+ `AKAN_MCP_PROMPT_BUDGET`, `AKAN_MCP_APPS`, `AKAN_MCP_MODERN`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
60
65
  not, which the option overrides.
61
66
  The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
62
67
  has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
@@ -100,8 +105,8 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({
100
105
  export default page()
101
106
  .param("taskId", ID, { desc: "The task to review." })
102
107
  .prompt("reviewTask", "Review this task and suggest next steps.")
103
- .render(async ({ taskId }) => {
104
- const { taskView } = await fetch.viewTask(taskId);
108
+ .render(({ taskId }) => {
109
+ const { taskView } = fetch.viewTask(taskId);
105
110
  return <Task.Zone.View view={taskView} />;
106
111
  });
107
112
  ```
@@ -226,7 +231,9 @@ export default page()
226
231
  wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
227
232
  disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
228
233
  method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
229
- gone".
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.
230
237
  - **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
231
238
  body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
232
239
  rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
@@ -354,6 +361,58 @@ in-page chat lists none of them: its `/` menu is its six built-in commands. Pick
354
361
  rule: a page taking no arguments is the most useful prompt, since MCP has no completion — one taking an id is for
355
362
  when the id is already in hand.
356
363
 
364
+ ## 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
366
+ prompt's briefing plus `Open this screen in a browser: <url>` — what a client without the
367
+ `io.modelcontextprotocol/ui` extension (Claude Code, Codex) reads. A host that declares the extension (claude.ai,
368
+ Claude Desktop, ChatGPT, VS Code, Goose) is also given `_meta.ui.resourceUri` (`ui://<app>/app`, or
369
+ `ui://<app>/app/<basePath>`) and renders the screen in place of the result: the app's CSR bundle, one HTML document,
370
+ in a sandboxed iframe started on the page the tool ran. Its first queries are answered from that run — carried in
371
+ the result's `_meta`, the one part no host shows the model — so the first paint costs no round trip; every later
372
+ call is relayed by the host as `tools/call` under the host's token. Nothing is declared for any of it.
373
+
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.
379
+ - **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.
382
+ - **The view is the page, not the app around it.** Each layout above it still runs — guards, providers,
383
+ `<Agent.Guide>` — but what it draws around `{children}` is hidden, with its frame chrome (`Layout.Navbar`,
384
+ `TopInset`, `BottomInset`, `BottomTab`), the `topInset` / `bottomInset` it reserves and the tools its controls
385
+ publish. A layout that draws no page, like a gate, is the screen then and is shown. The screen the view opened on
386
+ has no back button: there is nothing behind it.
387
+ - **The view has no network.** `connect-src` stays closed; the app's own origin is opened for what a page loads by
388
+ URL — fonts (served with `Access-Control-Allow-Origin: *`) and stored images — and every root-relative URL the
389
+ page writes is pointed at it, since a host may block the document's `<base>` (Claude does).
390
+ Cookies and Web Storage are in-memory, history is in-memory, and the websocket, live sync, pubsub, the reconnect
391
+ overlay and `<Agent.Chat />` are off. A path link routes inside the view; any other link opens through the host.
392
+ - **The view looks like the host.** Its neutrals (`--background`, `--foreground`, `--muted`, `--border`, `--ring`) and
393
+ `--font-sans` / `--font-mono` follow the host's theme variables, its theme follows the host's, and so does its
394
+ language when the app speaks it; the app's brand colors and radii stay its own.
395
+ - **The host's model drives the screen too.** The view offers the host the screen's own tools — the surface
396
+ `<Agent.Chat />` reads: `st.tool`, the form setters, `fill<Model>Form`, `navigate` / `readScreen` / `readState` /
397
+ `highlight` — and, to a host that takes it, the screen's context (route, live state, `<Agent.Guide>`) through
398
+ `ui/update-model-context` whenever it moves. A tool that declares `confirm` asks the person in the view through
399
+ the `AgentApproval` slot, whatever the host asks on its own; a card tool, which a person answers in a chat, is not
400
+ offered.
401
+ - **A person tapping in the view acts with the agent's token**, so `isAgentCall` is true and a `Person` act is
402
+ refused there. That act belongs on the web page the result links to. The view holds no token: it is handed the
403
+ caller's claims, so `getSelf({ unauthorize })` in a layout renders the screen instead of a sign-in page, and each
404
+ call it makes is relayed and judged by the guards.
405
+ - **A view's call answers what a browser would get**, in `_meta` beside the masked result: the value with its
406
+ `visual` fields, or the domain `Err` with its key and data, toasted in the user's language. A refusal and a crash
407
+ stay generalized.
408
+ - **What a document load would do, the view does in place.** `router.refresh()` renders the route afresh, a
409
+ download (`<a download>`, a data table's export) goes to a host that saves files, and a full navigation off the
410
+ app shows a notice instead of a blank frame.
411
+ - **A page named like an endpoint stays a prompt only**, published or not, and the boot log says so: the endpoint
412
+ keeps its name. A refused screen answers as a tool error (`isError`), which the model reads.
413
+ - `option.setMcp({ apps: false })` / `AKAN_MCP_APPS=false` keeps every page a prompt only. `web: { csr: false }`
414
+ publishes the tool with no view, and an API-only build (`web: false`) has no pages to publish.
415
+
357
416
  ## Progress Reporting
358
417
  **`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
359
418
  frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
@@ -421,8 +480,9 @@ the same guards.** What stays closed is closed because no agent has a reason to
421
480
 
422
481
  ## Writing `instructions`
423
482
 
424
- `*.abstract.md` and `<Agent.Guide>` never reach an MCP client. Exactly three texts do: `instructions`, every
425
- published entry's `.desc()`, and a page's `.prompt()` description. All three are English, whatever the users speak
483
+ `*.abstract.md` never reaches an MCP client, and `<Agent.Guide>` only through an MCP Apps view, beside the screen's own
484
+ tools it explains. Exactly three texts reach every client: `instructions`, every published entry's `.desc()`, and a
485
+ page's `.prompt()` description. All three are English, whatever the users speak
426
486
  — the reader is a model, and the catalogue is built once in one `language` and cached by clients.
427
487
 
428
488
  - **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
@@ -453,10 +513,10 @@ published entry's `.desc()`, and a page's `.prompt()` description. All three are
453
513
  - The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
454
514
  redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
455
515
  rotation) and the refresh-session and option unit tests.
456
- - **One exposure test per app**, in a `lib/<model>.signal.test.ts`: list `tools/list` with a user token and pin the
457
- names with `toEqual`, and assert the curated and admin-only ones with `not.toContain`. Exposure is derived from
458
- the guards, so a guard change fails this test first — which is the point. The framework's own pattern is
459
- `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
516
+ - **One exposure test per app**, in `srvkit/mcpExposure.signal.test.ts`, since it spans every module: list
517
+ `tools/list` with a user token and pin the names with `toEqual`, and assert the curated and admin-only ones with
518
+ `not.toContain`. Exposure is derived from the guards, so a guard change fails this test first — which is the point.
519
+ The framework's own pattern is `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
460
520
  - Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
461
521
  prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
462
522
  both answering 200 is the grace window working, not a fault.
@@ -65,9 +65,15 @@ with it. `conventions` carries the invariants — this is the full contract behi
65
65
  - `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
66
66
  and there is no way to change that. They also report only counts, never which row they touched, so they are for
67
67
  "there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
68
+ The query is a compare-and-set in every mode: of two concurrent `updateOne({ id, balance: { $gte: n } }, …)` that
69
+ both read the row as matching, only one applies, so `modifiedCount` is the verdict.
68
70
  - **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
69
71
  model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
70
72
  throws at boot instead (`assertFilterFitsCrud`).
73
+ - **An insight field counts or sums.** `accumulate: {}` counts the rows the query matches and `accumulate: { status:
74
+ "active" }` those that also match the condition; `accumulate: { $sum: "totalValue", status: "active" }` adds the
75
+ field up over the same rows (`field(Float, { default: 0, accumulate: … })`), a stored value that is not a number
76
+ counting as nothing — the same answer on SQLite and Postgres.
71
77
  - **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
72
78
  key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
73
79
  or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
@@ -119,7 +125,7 @@ with it. `conventions` carries the invariants — this is the full contract behi
119
125
  - Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
120
126
  because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
121
127
  path.
122
- - Search runs in every database mode: fts5 on SQLite/libSQL, a weighted `tsvector` under a GIN index on Postgres.
128
+ - Search runs in every database mode: fts5 on SQLite, a weighted `tsvector` under a GIN index on Postgres.
123
129
  For the same text the same documents match — punctuation splits words the way unicode61 does, and a phrase never
124
130
  runs from one column into the next — but the order may differ, since Postgres ranks without document frequency.
125
131
  Assert on the match set, never on a Postgres order the weights alone do not decide.
@@ -185,6 +191,10 @@ shape, so `cascade` never means "related" — it means one of exactly these:
185
191
  unmounted `refPath` candidate only warns, since that list spans optional modules by design.
186
192
  - **Nothing checks whether another document still references the same target.** `File` in particular is deduped
187
193
  by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
194
+ - **A reference no cascade removes keeps pointing at the removed row, and a response reads it live-only**: a list
195
+ leaves the target out, an optional field reads `null`, and a required one answers 404
196
+ `base.error.relationNotFound` naming the model and the field path. Make a reference whose target can go first
197
+ `.optional()`, or let it go with the target through `removeWith`.
188
198
  - Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
189
199
  restorable, and reviving the owner does not revive what went with it.
190
200
  - A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when