@akanjs/cli 3.0.0-alpha.99 → 3.0.0-beta.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 (71) hide show
  1. package/.build-stamp +1 -1
  2. package/{abstractCompactor-byytxs22.js → abstractCompactor-mjh5ppsq.js} +1 -1
  3. package/{agent.command-v1a4746v.js → agent.command-d2gfxfck.js} +5 -5
  4. package/{application.command-j05fr5jc.js → application.command-pj2m7584.js} +5 -5
  5. package/{applicationBuildRunner-s4gsk728.js → applicationBuildRunner-kxwbssgn.js} +2 -2
  6. package/{applicationReleasePackager-28v9vbzk.js → applicationReleasePackager-4yasxwv9.js} +1 -1
  7. package/buildBatch.proc.js +2 -2
  8. package/{capacitorApp-8d6d4zkm.js → capacitorApp-36rbr858.js} +2 -2
  9. package/{cloud.command-r6wvq2rp.js → cloud.command-jshwdq22.js} +9 -9
  10. package/{context.command-4vdt1aqq.js → context.command-ez8a0zyz.js} +13 -13
  11. package/{devTui-tnj2hh4f.js → devTui-40ggmd99.js} +85 -84
  12. package/{devTuiView-xzed2pd6.js → devTuiView-c08ccbat.js} +1 -1
  13. package/{guideline.command-p92dq6ts.js → guideline.command-xmqwx8zm.js} +11 -7
  14. package/guidelines/agentRule/agentRule.instruction.md +112 -32
  15. package/guidelines/conventions/conventions.instruction.md +100 -40
  16. package/guidelines/mcpRule/mcpRule.generate.json +11 -1
  17. package/guidelines/mcpRule/mcpRule.instruction.md +296 -53
  18. package/guidelines/queryRule/queryRule.instruction.md +17 -9
  19. package/guidelines/ssrRule/ssrRule.instruction.md +15 -13
  20. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +10 -5
  21. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +68 -3
  22. package/incrementalBuilder.proc.js +1 -1
  23. package/{index-gq44bqjt.js → index-259dsnz3.js} +2 -2
  24. package/{index-b9rkcrx8.js → index-2hq52jq3.js} +9 -7
  25. package/{index-qsq3q8fy.js → index-2tv3fdx4.js} +21 -10
  26. package/{index-qkk27bk6.js → index-4edzf1gr.js} +4 -4
  27. package/{index-yza7zkpt.js → index-600zhnve.js} +10 -10
  28. package/{index-srdssqj7.js → index-7737gxcp.js} +3 -3
  29. package/{index-zmjppp25.js → index-8pm30m73.js} +5 -5
  30. package/{index-p5neav9b.js → index-a64esmtb.js} +54 -9
  31. package/{index-0amdxycf.js → index-fz5q3gd7.js} +3 -3
  32. package/{index-fc1bh0a7.js → index-gyd220p2.js} +1 -1
  33. package/{index-5svjba73.js → index-j9yhg404.js} +1 -1
  34. package/{index-c69j78kk.js → index-kn1h7p1x.js} +1 -1
  35. package/{index-gtmz74tj.js → index-ktb5njgy.js} +2 -2
  36. package/{index-znynzt5w.js → index-mak3cd28.js} +1 -1
  37. package/{index-29sxqfaw.js → index-ne2qy0nk.js} +3 -3
  38. package/{index-hyywke0f.js → index-pnqbkf5p.js} +1 -1
  39. package/{index-8p8p0cf8.js → index-rwg2aa4f.js} +3 -3
  40. package/{index-5wd728df.js → index-v0qh106m.js} +215 -12
  41. package/{index-7d6vny25.js → index-ws5e0j51.js} +2 -2
  42. package/{index-1yxtbmbj.js → index-wt7qrtd4.js} +1 -1
  43. package/{index-gkkdx9kp.js → index-zry5nz81.js} +6 -6
  44. package/index.js +19 -19
  45. package/{library.command-s4fszngs.js → library.command-nbsky672.js} +3 -3
  46. package/{localRegistry.command-fq2w7cgz.js → localRegistry.command-yeq57wjr.js} +8 -8
  47. package/{module.command-6n7dwvav.js → module.command-29vqzrh8.js} +6 -6
  48. package/{package.command-gr82ypnr.js → package.command-axv0abmn.js} +3 -3
  49. package/package.json +2 -2
  50. package/{page.command-7gvgb3jb.js → page.command-vs9s3zrh.js} +3 -3
  51. package/{primitive.command-tk5ckw38.js → primitive.command-cbxmqbtc.js} +7 -7
  52. package/{quality.command-38v81baf.js → quality.command-gmbs7nws.js} +2 -2
  53. package/{repair.command-5xbwvsxp.js → repair.command-bqsjptsj.js} +5 -5
  54. package/{routeSourceValidator-ze6x894c.js → routeSourceValidator-apyqw7wk.js} +90 -17
  55. package/{scalar.command-0epwcr5k.js → scalar.command-6k1vhe9s.js} +5 -5
  56. package/{subspace.command-e2t36xb2.js → subspace.command-fwjfcts7.js} +7 -5
  57. package/templates/app/page/_index.tsx +3 -3
  58. package/templates/app/page/_layout.tsx +31 -32
  59. package/templates/appSample/page/task/[taskId]/_index.tsx +22 -27
  60. package/templates/appSample/page/task/[taskId]/edit.tsx +25 -30
  61. package/templates/appSample/page/task/_index.tsx +3 -7
  62. package/templates/appSample/page/task/_layout.tsx +4 -15
  63. package/templates/appSample/page/task/new.tsx +4 -10
  64. package/templates/crudPages/[__model__Id]/edit/page.tsx +26 -28
  65. package/templates/crudPages/[__model__Id]/page.tsx +20 -25
  66. package/templates/crudPages/new/page.tsx +24 -23
  67. package/templates/crudPages/page.tsx +25 -24
  68. package/templates/crudSinglePage/page.tsx +25 -24
  69. package/templates/lib/sig.ts +4 -0
  70. package/{workflow.command-kphbr7eh.js → workflow.command-nwpcdc8p.js} +10 -10
  71. package/{workspace.command-ag5hy887.js → workspace.command-8s050m0h.js} +20 -20
@@ -1,7 +1,8 @@
1
1
  # Akan MCP Exposure Guideline
2
2
 
3
3
  ## Purpose
4
- Use this when writing a `*.signal.ts`, configuring `option.setMcp(...)`, or debugging a tool an agent cannot see.
4
+ Use this when writing a `*.signal.ts`, configuring `option.setMcp(...)`, debugging a tool an agent cannot see, or
5
+ opening an app's service to agents end to end — the rollout checklist and the exposure design rules are at the end.
5
6
  The always-loaded convention set carries the short version; this is the full contract.
6
7
 
7
8
  ## Exposure Rule
@@ -42,17 +43,21 @@ export class FileSlice extends slice(srv.file, {
42
43
  the guard rules keep out.
43
44
  - **Reach for it by cost, not by taste.** Every entry inlines the schema of every model it mentions, so a plain
44
45
  21-field model with one named slice costs 12.6KB across its eight entries. `mcp: { cru: false }` on that model
45
- takes it to 4.7KB — the write entries carry both the input *and* the full model, and are 63% of it.
46
+ takes it to 4.7KB — the write entries carry both the input *and* the full model, and are 63% of it. Those figures
47
+ are for `outputSchema: "full"`; the default names nested models instead of inlining them (below). Measured on a
48
+ fully open 347-tool catalogue: 1194KB before, 532KB under the default, 194KB under `"none"`, and 1046KB even under
49
+ `"full"` — the id and nullable savings apply in every mode — before any endpoint was curated.
46
50
 
47
51
  ## Configuration
48
52
  Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
49
- `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, and `auth`. **Not `main.ts`**: the gateway
53
+ `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`, and `auth`. **Not `main.ts`**: the gateway
50
54
  there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
51
55
  mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
52
56
  declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
53
57
  `AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
54
- `AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a
55
- deployment that configures what the source does not, which the option overrides.
58
+ `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
60
+ not, which the option overrides.
56
61
  The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
57
62
  has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
58
63
  `AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
@@ -68,7 +73,7 @@ export const option = new AkanOption<ModulesOptions>().setMcp({
68
73
  ```
69
74
 
70
75
  ```typescript
71
- // <model>.signal.ts — every one of these is an MCP tool or prompt, with no `mcp:` option anywhere
76
+ // <model>.signal.ts — every one of these is an MCP tool, with no `mcp:` option anywhere
72
77
  export class TaskSlice extends slice(
73
78
  srv.task,
74
79
  { guards: { root: Admin, get: SignedIn, cru: SignedIn } },
@@ -81,21 +86,26 @@ export class TaskSlice extends slice(
81
86
  }),
82
87
  ) {}
83
88
 
84
- export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => ({
89
+ export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({
85
90
  startTask: mutation(cnst.Task, { guards: [SignedIn] })
86
91
  .param("taskId", ID)
87
92
  .exec(async function (taskId) {
88
93
  return await this.taskService.startTask(taskId);
89
94
  }),
90
- reviewTask: prompt({ guards: [SignedIn] })
91
- .param("taskId", ID)
92
- .exec(async function (taskId) {
93
- const task = await this.taskService.getTask(taskId);
94
- return [Msg.user(`Review this task and suggest next steps.`), Msg.resource(`akan://task/${taskId}`, task)];
95
- }),
96
95
  })) {}
97
96
  ```
98
97
 
98
+ ```tsx
99
+ // page/task/[taskId]/_index.tsx — the screen, published to agents as the prompt `reviewTask`
100
+ export default page()
101
+ .param("taskId", ID, { desc: "The task to review." })
102
+ .prompt("reviewTask", "Review this task and suggest next steps.")
103
+ .render(async ({ taskId }) => {
104
+ const { taskView } = await fetch.viewTask(taskId);
105
+ return <Task.Zone.View view={taskView} />;
106
+ });
107
+ ```
108
+
99
109
  ## Refusals And Wire Behaviour
100
110
  - **`mcp: false` is read first**, because it is the one answer nobody had to derive — somebody stated it. Its
101
111
  reason says "HTTP still serves it", because that is the sentence that stops the next reader treating it as a lock.
@@ -106,16 +116,13 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
106
116
  **`light<Model>` read** — it returns the same document as `<Model>` under the same guards, in a shape trimmed
107
117
  for a page's payload, so publishing both put two entries per model in a listing an agent pays for every turn
108
118
  and gave it nothing but the word "light" to choose between them.
109
- A `prompt` refuses two more, because its `arguments` is one string per name with no schema beside it: a **list
110
- argument**, which could never carry a second value, and **any `Any` argument** — a tool leaves that out of its
111
- schema, and a prompt has no schema to leave it out of.
112
119
  - **The boot log sizes the catalogue and says which signals it went to** — `MCP catalogue: tools=48 … · listing
113
120
  214KB` and `MCP catalogue cost: user 27/71KB · file 6/22KB · …`, heaviest first. Read it before narrowing
114
121
  anything: the number is nobody's intuition, because MCP has no shared component section and forbids a `$ref`
115
122
  across entries, so **every entry inlines the full schema of every model it mentions** and a listing is re-sent
116
123
  whole to every agent that connects, before its first turn. Past 100KB it says so as a `warn`.
117
- - **Every refusal is named in the boot log**: one `warn` per endpoint plus a `MCP catalogue: tools=… prompts=…`
118
- count. Read that line first when a tool you expected is missing — and it is the *only* place the answer exists,
124
+ - **Every refusal is named in the boot log**: one `warn` per endpoint plus a `MCP catalogue: tools=…
125
+ resourceTemplates=…` count, and a `MCP page prompts: N (…)` line once the RSC worker has listed the pages. Read that line first when a tool you expected is missing — and it is the *only* place the answer exists,
119
126
  because there is no absent opt-in to notice. The API explorer badges the same rule per endpoint (`MCP` /
120
127
  `MCP refused`), from the same shared implementation the catalogue runs.
121
128
  - **An `Any` argument is left out of the published schema** rather than described as `{}` — it tells a model
@@ -141,21 +148,52 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
141
148
  doubling of what every model-returning tool costs a model. `option.setMcp({ legacyTextBlock: false })` — or
142
149
  `AKAN_MCP_LEGACY_TEXT=false` — leaves a one-line pointer in the text block instead, for a deployment whose
143
150
  clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
144
- ships as the value itself either way.
151
+ ships as the value itself either way. `resources/read` is unaffected too: `ReadResourceResult` has no
152
+ `structuredContent`, so a resource's text is its only channel and always carries the serialized value.
153
+ - **A request schema asks for a relation's id, and a response schema names a nested model.** `serialize` sends a
154
+ relation field as its id and the server never converts an object back, so publishing the related model's shape
155
+ asked an agent for what the document layer cannot take. On the response side every entry inlines the transitive
156
+ closure of every model it mentions (a `$ref` may not cross entries), and on an open catalogue 83% of the bytes
157
+ were the same schemas repeated — so a nested database model is published as `{ type: "object", description:
158
+ "<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
159
+ that took a 347-tool listing from 1194KB to 532KB, and one user's paged `tools/list` to 320KB.
160
+ `option.setMcp({ outputSchema: "full" })` (`AKAN_MCP_OUTPUT_SCHEMA`) restores the closure; `"none"` publishes no
161
+ `outputSchema` at all — results still ship as `structuredContent`, and the text block stays on whatever
162
+ `legacyTextBlock` said, because a client validates structured content only against a declared schema. Inside a
163
+ `$defs` an id carries no `pattern` and nullability rides in `type: [T, "null"]` (an enum lists `null` too); a
164
+ top-level argument keeps the spellings the OpenAPI document uses. **Know which bytes reach the model.** The
165
+ provider tool definition a client builds from a listing carries the name, the description and the input schema —
166
+ the Anthropic and OpenAI tool formats have no field for an output schema — so `outputSchema` is a wire and
167
+ client-memory cost, and the model's context is spent on names, descriptions, input schemas and the number of
168
+ tools. Curate those (`mcp: false`, `mcp: { cru: false }`, `readOnly`) to cut context; pick `outputSchema` for the
169
+ transfer.
170
+ Measured on a 219-tool user listing: the wire carried 366KB, the client reported ≈41.6k tokens for it, and the
171
+ server's own count of names + descriptions + input schemas was 95KB — the same 41k at 2.4 characters per token.
172
+ So `outputSchema` (`full` / `shallow` / `none`) moves wire bytes and client memory and **not one model token**;
173
+ do not tune it expecting a context win. What does cost context: the tool count, and the input model inlined
174
+ behind every `create*` / `update*` (22 such tools were 8.7k of those 41k; the heaviest single tool was
175
+ `updateUser` at ~900 tokens) — `mcp: { cru: false }` on a model whose writes an agent has no business with is
176
+ the lever that pays. A custom mutation whose arguments are plain ids is cheap (nine contract tools, 1.2k). In
177
+ one line: `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost
178
+ the model tokens, and `outputSchema` for nothing that reaches the model at all.
145
179
  - A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
146
- message more helpful — the difference is what enumerates your private surface. A guard's refusal is generalized
147
- the same way: the caller reads `You are not permitted to perform this action.`, never `Access denied by guard:
148
- Admin`, which names your authorization structure to the one caller barred from it. A domain `Err` resolves
149
- through the dictionary first and keeps its own words.
180
+ message more helpful — the difference is what enumerates your private surface. Every 401 and 403 is generalized
181
+ the same way, the framework's `Access denied by guard: Admin` and an app's `No authentication with roles: admin`
182
+ alike: the caller reads `You are not permitted to perform this action.`, because either sentence names your
183
+ authorization structure to the one caller barred from it. A domain `Err` at any other status resolves through
184
+ the dictionary and keeps its own words, placeholders filled.
150
185
  - The `readOnly` / `destructive` / `idempotent` hints a client renders are derived from the endpoint type and key
151
186
  and are not configurable. Clients are told to distrust hints; they are never a gate.
152
187
  - **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
153
188
  whatever it declared, and reports each one in the boot log like any other refusal.
154
189
  - OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
155
- path, the spelling most clients try first). `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, and `AKAN_MCP_RESOURCE`
156
- configure it; `insufficient_scope` is enforced only once `AKAN_MCP_SCOPES` is set. A token carrying no `aud` at
157
- all is refused once `AKAN_MCP_AUTH_SERVERS` names an issuer — that issuer mints tokens for its other resources
158
- too — and accepted while none is named, because a first-party Akan token is bound by app and environment.
190
+ path, the spelling most clients try first). `auth.authorizationServers` / `AKAN_MCP_AUTH_SERVERS`,
191
+ `auth.scopes` / `AKAN_MCP_SCOPES`, `auth.resource` / `AKAN_MCP_RESOURCE` and `auth.verify` configure it;
192
+ `insufficient_scope` is enforced only once scopes are declared. Naming an issuer changes three things at once: a
193
+ request with no credential is answered 401 from `initialize` on (an MCP client starts its OAuth flow on nothing
194
+ else), a token carrying no `aud` is refused (that issuer mints for its other resources too), and the challenge
195
+ carries no `error` code (RFC 6750 §3.1 reserves it for a credential that was presented). A server naming no
196
+ issuer keeps anonymous access and accepts a first-party token, which is bound by app and environment instead.
159
197
  - **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
160
198
  description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
161
199
  the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
@@ -163,14 +201,24 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
163
201
  `akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
164
202
  with exposure derived from the guards the resolved catalogue is the only place that can answer.
165
203
  - A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
166
- Every other MCP client sends no `Origin` at all, and the one that does is matched against the forwarded host so
167
- a proxy does not turn each call into a 403 — which is only as trustworthy as an edge that *overwrites* that
168
- header. `AKAN_MCP_RESOURCE` pins the resource identifier where you cannot guarantee it.
204
+ Every other MCP client sends no `Origin` at all, and the one that does is matched against the server's public
205
+ origin: the configured `auth.resource`'s when there is one (`libs/shared` derives it from the issuer, so a mounted
206
+ app is always pinned), and only otherwise the forwarded host — which is as trustworthy as an edge that
207
+ *overwrites* that header. The same origin names the `resource_metadata` URL in every 401 challenge.
208
+ `AKAN_MCP_RESOURCE` is the pin for an app that mounts no authorization server.
209
+ - **Every call that executes an endpoint is rate-limited per caller** — `tools/call`, `resources/read` and
210
+ `prompts/get`, never a listing. A caller is the bearer's session (`sid`), so one connector counts as one however
211
+ many connections it opens; a credential-less caller is the address the proxy recorded, and callers with none share
212
+ one bucket. Defaults are 120 calls a minute and 8 in flight, counted per process (N replicas grant N budgets); past
213
+ either the answer is HTTP 429 with JSON-RPC `-32010` and a `Retry-After`, and an unknown tool name is counted like
214
+ any other call. `option.setMcp({ rateLimit: { calls, windowMs, concurrent } })`, `rateLimit: false` to turn it off;
215
+ `AKAN_MCP_RATE_LIMIT=<calls>` or `<calls>/<seconds>` (`off` disables) and `AKAN_MCP_CONCURRENT`. The boot log says
216
+ which is in force, and warns when it is off.
169
217
  - **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
170
218
  - **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
171
- unparseable or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
219
+ unparseable, outside its `enumOf`, or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
172
220
  published schema and nothing on the wire enforces it — and so does a document that is not there, as
173
- `No <model> found for the arguments given.` A `prompt`, having no `isError` to carry a refusal, answers `-32602`.
221
+ `No <model> found for the arguments given.` A prompt, having no `isError` to carry a refusal, answers `-32602`.
174
222
  Only a real failure logs a stack; an agent can drive the rest at will.
175
223
  - **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
176
224
  wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
@@ -180,10 +228,14 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
180
228
  - **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
181
229
  body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
182
230
  rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
183
- are derived from the catalogue, so a server with no prompts does not advertise `prompts`.
184
- - **An expired or wrongly-audienced bearer token is refused up front**, so an agent is told to authenticate rather
185
- than that the tool does not exist. Its **signature is not checked** — that needs your app's own secret — so a
186
- token signed wrong, like an opaque one, still degrades to an anonymous caller.
231
+ are derived from the catalogue; `prompts` is advertised when the build serves pages, since prompts are theirs.
232
+ - **An expired, wrongly-audienced or unverifiable bearer token is refused up front**, so an agent is told to
233
+ authenticate rather than that the tool does not exist. The signature is checked through `auth.verify`, which the
234
+ module that mints the tokens supplies (`libs/shared` does, from the app's signing secret); without a verifier the
235
+ claims are read unverified and a token signed wrong, like an opaque one, degrades to an anonymous caller.
236
+ - **`/mcp` reads the `Authorization` header and nothing else.** The `cookie` header is deleted from the request
237
+ before the account middleware runs, so a same-site page cannot drive `tools/call` on the visitor's ambient session
238
+ — the request class `CrossSiteGuard` shields every mutation from, which this route never passes through.
187
239
  - **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/list` for the model's own list, and
188
240
  `akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any token
189
241
  there is one a slice could also be named. **Those three are the whole set**, so only the generated reads that
@@ -192,26 +244,217 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
192
244
  - **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
193
245
  clients, so there is no `Accept-Language` negotiation.
194
246
 
195
- ## `prompt()`
196
- **`prompt()`** is invoked by the *user* — a client renders it as a slash command — not chosen by the model. `exec`
197
- returns `PromptMessage[]`, or a bare string that is wrapped into one user message; build them with `Msg.user` /
198
- `Msg.assistant` / `Msg.link` / `Msg.resource` / `Msg.image` / `Msg.imageOf`. It takes `.param()` and `.search()`
199
- only, because `prompts/get` sends a flat string map. **An embedded payload is masked by the model you name** —
200
- `Msg.resource(uri, task, { model: cnst.LightTask })`, or `Msg.mask(cnst.LightTask, task)` for one piece of an
201
- assembly. Taking the model as an argument is what makes a `{ ...doc }` spread maskable, since that and `toJSON()`
202
- arrive with the class already gone; a value with no model named whose `hidden`/`secret` fields are populated is
203
- **refused**, one level into a plain object too. **A `prompt` is also mounted as a
204
- plain HTTP `GET` whether or not you enabled MCP**, because that route is what lets a web UI preview one — and it
205
- is in your OpenAPI document like any other `GET`, answering the one fixed `PromptMessage[]` shape. MCP exposure
206
- gates the catalogue, not the surface, so guard it
207
- like any other read — and a prompt declaring no
208
- `guards` at all is named in the boot log, while an explicit `[Public]` is a decision and stays quiet. Every `Msg` builder takes
209
- optional `annotations` last (`audience`, `priority` 0..1, `lastModified`) — give the instruction a high `priority`
210
- and its attachments a low one, or a client with a full window drops blocks by position and keeps the attachment
211
- over the ask.
247
+ ## Authentication — The Authorization Server Is The Same Process
248
+
249
+ `libs/shared/lib/_oauth` is an OAuth 2.1 authorization server (RFC 8414 metadata, PKCE `S256` only, RFC 7591
250
+ registration, Client ID Metadata Documents, RFC 9207 `iss`) served by the app that serves `/mcp`. Its `option.ts`
251
+ names the issuer and hands `McpAuth` a verifier, so mounting the lib is all an app does; the protocol engine it
252
+ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthRegistration`, `OAuthClientIdMetadata`,
253
+ `OAuthPkce`, `OAuthRedirect`, `OAuthMetadata`, `OAuthErrors`) and is unit-tested without a container.
254
+
255
+ - **Endpoints, at the origin's root:** `GET /.well-known/oauth-authorization-server`, `GET /oauth/authorize`,
256
+ `POST /oauth/token` (form-encoded), `POST /oauth/register`, `POST /oauth/revoke` (RFC 7009, form-encoded, the
257
+ same client authentication as the token endpoint). The consent step is `libs/shared/page/oauth/consent`
258
+ — a server-rendered page whose two forms post to `/api/approveOAuthConsent/<requestId>` and
259
+ `/api/denyOAuthConsent/<requestId>` and follow the 302 back to the client. The account's own controls are
260
+ `fetch.listOAuthConnections()` (one entry per live grant: `sessionId`, `clientId`, `clientName`, `userAgent`,
261
+ `createdAt`, `expiresAt`, `isCurrent`) and `fetch.revokeOAuthConnection(sessionId)`; the app renders the
262
+ connected-apps page, the lib supplies the two endpoints and the `oauth.connectedApps` / `disconnect` /
263
+ `noConnectedApps` / `currentConnection` translations. All of it is `mcp: false`.
264
+ - **Revocation closes a grant, not a token.** A refresh token or an access token both name one grant (the access
265
+ token carries the lineage as `sid`); revoking either closes the refresh lineage and denylists the lineage id for
266
+ an access token's lifetime, so a stateless token dies early — `/mcp` answers 401 `invalid_token` and
267
+ `AccountMiddleware` treats it as anonymous, at the next call. `/oauth/revoke` answers an empty 200 whether the token
268
+ was live, unknown, or another client's (RFC 7009 §2.2 — it must not double as a token oracle); only a failed client
269
+ authentication is 401. Changing a password still revokes every session, browser ones included; disconnecting an
270
+ app revokes that one.
271
+ - **A person may, a model may not.** The one honest marker of "a model is driving this call" is on the token: an
272
+ access token minted here names its `client_id` and the MCP resource as `aud`, and a browser session carries
273
+ neither; `SerAccount` declares those claims, so nothing casts to read them. Read it three ways, all in
274
+ `@libs/shared/srvkit`: `.with(AgentCall)` hands the endpoint a boolean (drop `sendEmails` on an agent's say-so);
275
+ `guards: [Every, Person]` refuses a model outright and, being `account`-scoped, hides the entry from every MCP
276
+ listing (signing, paying, anything the page treats as a deliberate act); `isAgentCall(context)` inside a guard or
277
+ service. All three read `context.origin === "mcp"` first — a field on `SignalContext`, never the trace, which can
278
+ be absent — and the token second, so an agent's token used over plain HTTP is still an agent. `Person` also
279
+ declares **`static agents = false`**, the guard-level marker the catalogue reads: an endpoint it guards is refused
280
+ from the document itself (named in the boot log like `mcp: false`, with the guards' `static name`s — the explorer's
281
+ filter labels, which need not be the class names), not merely
282
+ hidden per caller at listing time — so the boot count, the size line and every agent's listing agree, and no
283
+ second flag is needed. Any guard that admits no model may declare the marker; a listing still evaluates
284
+ `account`-scoped guards per caller, so the two mechanisms compose.
285
+ - **The flow:** `POST /mcp` with no token → 401 with `resource_metadata` → the client reads the PRM, then the AS
286
+ metadata, registers (or uses a pre-registered / URL `client_id`), opens `/oauth/authorize` in a browser → the
287
+ server parks the request and redirects to the consent page (via `signinPath?redirect=` when anonymous) → the user
288
+ allows → `redirect_uri?code&state&iss` → the client posts the code and its PKCE verifier to `/oauth/token` → an
289
+ access token (1h, `aud` = the MCP resource, `iss`, `client_id`, `sub`, and the account's `self`/`me`) plus a
290
+ rotating refresh token (30d; reuse revokes the family). The token is the app's own access JWT, so
291
+ `AccountMiddleware` and every guard read it exactly as a browser session; nothing is scoped.
292
+ - **Invariants:** an authorization request lives ten minutes, binds to the first signed-in account that opens it,
293
+ and is decided once; a code lives sixty seconds and is consumed on first exchange even if that exchange fails; a
294
+ redirect URI must be registered and match exactly, loopback ports excepted (RFC 8252 §7.3); a refresh token is
295
+ bound to the client it was issued to. A `client_id` that is an HTTPS URL with a path is fetched as a metadata
296
+ document: the name is judged first (no address literal, no reserved suffix, no port), then resolved, and refused
297
+ when any address is private, loopback, link-local, CGNAT or multicast — the rebinding window between lookup and
298
+ fetch is what the deployment's egress policy still owns.
299
+ - **Per-app configuration** goes in `env.server.*` under `oauth`: `consentPath` and `signinPath` (with the basePath
300
+ when the app has one — `/office/oauth/consent`), `clients` (pre-registered, with an optional `clientSecret`),
301
+ `dynamicRegistration` (on by default: Claude Code and claude.ai register themselves), `allowedRedirectSchemes`
302
+ (`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600, also how long a revoked
303
+ access token can outlive its grant), `clientIdMetadata: { enabled, refusePrivateAddresses }` (both on; `enabled:
304
+ false` turns Client ID Metadata Documents off and the metadata stops advertising them — Claude Code then registers
305
+ dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong),
306
+ `enabled: false` to take the server and the `/mcp` credential requirement away together. The signin page must
307
+ honour `?redirect=` to return the user to the consent page.
308
+ - **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
309
+ opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
310
+ (needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
311
+ `https://claude.ai/api/mcp/auth_callback`). Cursor — the URL alone (DCR with a `cursor://` redirect), or a
312
+ pre-registered client in `mcp.json` `auth` with `http://localhost:8787/callback` /
313
+ `https://www.cursor.com/agents/mcp/oauth/callback` registered. A first-party browser token no longer opens `/mcp`
314
+ once the issuer is named; `--header "Authorization: Bearer …"` needs a token minted with `aud`.
315
+ - **`JWT_SECRET`** (or `security.jwtSecret`) is required outside `local`; the boot refuses without it, because the
316
+ derived fallback is seeded from the app, environment and repo names and forges an admin token for anyone who
317
+ knows them. `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly.
318
+
319
+ ## Page Prompts — `page().prompt()`
320
+ **A prompt is a screen.** There is no `prompt()` on `endpoint()`: the page file declares it —
321
+ `page().param("projectId", ID, { desc: "The project to brief." }).prompt("briefProjectTickets", "Brief the ticket
322
+ board of one project.").render(…)` — and that is the whole surface. `prompts/list` is every page carrying
323
+ `.prompt()`; its `.param()` stages are the required arguments and its `.search()` stages the optional ones, a list
324
+ argument is comma-separated on the wire and its description says so, and an `ID`/`Int`/`enumOf` value is checked by
325
+ the page's own declaration. **`prompts/get` runs the page's body** — root layouts, layouts, then the page's
326
+ `.render()` — in the RSC worker under the caller's bearer token, the way a tab would open it. Nothing is rendered:
327
+ the JSX comes back as an element tree nobody walks, no client component runs, and the screen's data footprint is
328
+ exactly the `fetch.*` queries its body made, with the arguments it made them with. The answer is the description as
329
+ the first message (priority 1), one embedded `resource` per query masked by that endpoint's return model and
330
+ addressed by the `akan://` uri the tool answers to (`akan://<toolKey>?args` for a custom read) — one document read
331
+ in two shapes, a layout's `project` beside the page's `lightProject`, travels once as the larger — and a closing
332
+ `Tools for this screen: …` line naming the published tools of the modules the screen fetched from, minus the reads
333
+ whose answers are attached above and filtered to what this caller may see — which is how a model reading a ticket
334
+ board is handed the ticket tools and not three hundred. Lists are cut to `promptBudget` (60,000 characters;
335
+ `option.setMcp({ promptBudget })`, `AKAN_MCP_PROMPT_BUDGET`), largest first, and the cut is said: `Attached the
336
+ first 20 of 341 rows of \`ticketListInProject\`; call it for the rest.` Only lists are cut — a single document rides
337
+ whole, so the budget is what the lists are trimmed to fit under, not a ceiling a screen of large documents cannot
338
+ pass. A layout's own queries (a project selector's `projectListInOrg`) are part of every screen under it and are
339
+ attached too. **No argument fallback**: a required argument left out answers one message — `No projectId was named for
340
+ "briefProjectTickets". Find it with \`projectListBySearch\`, then run this prompt again with projectId=<id>.` —
341
+ and runs nothing, because a prompt cannot re-run itself and context filled in on a guess would never be used. A
342
+ page that redirects (`getSelf({ unauthorize })`), or whose `fetch.*` a guard refuses with 401/403, answers the 401
343
+ credential challenge when the call carried no token and `This screen is not available to the signed-in account.`
344
+ when it did — the same sentence for both, so a refused id is never confirmed to exist; `router.notFound()` and a
345
+ 404 from a query are `No screen exists for these arguments.`; anything else the body throws is `The page failed to
346
+ load.`, with the cause in the server log only. **The description is the only instruction.** `<Agent.Guide>` is for the in-page agent, whose
347
+ levers are the screen's controls, and is never read here, where the levers are the API — so write the description
348
+ in English, in the API's vocabulary, and put a computed rule (the legal status transitions) into it as a static
349
+ table the model combines with the attached state. Names match `^[A-Za-z0-9_-]{1,64}$` and are unique across pages;
350
+ two pages claiming one name fail the listing. An API-only build (`web: false`) publishes no prompts, and the
351
+ in-page chat lists none of them: its `/` menu is its six built-in commands. Pick which screens to publish by this
352
+ rule: a page taking no arguments is the most useful prompt, since MCP has no completion — one taking an id is for
353
+ when the id is already in hand.
212
354
 
213
355
  ## Progress Reporting
214
356
  **`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
215
357
  frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
216
358
  websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
217
359
  costs something to assemble.
360
+
361
+ ## Rolling MCP Out To An App
362
+
363
+ The goal of exposing a service over MCP is that a person uses it through an agent instead of through the page:
364
+ one browser consent (the app's own sign-in included), then an hour-long access token and a thirty-day rotating
365
+ refresh token stand in for every later sign-in. The token is the app's own access JWT, so what an agent may do is
366
+ exactly what that person may do over HTTP — nothing is widened and nothing is scoped. Do these in order; steps 2
367
+ and 3 are the ones whose omission shows up as a 404 or a doubled basePath on the consent page.
368
+
369
+ 1. **Mount the lib that carries the authorization server** (`libs/shared`) and confirm `lib/_oauth`,
370
+ `page/oauth/consent` and `srvkit/oauthServer.ts` are present. Its `option.ts` declares `auth` for `setMcp`, and
371
+ `setMcp` merges field by field in mount order with the app last — the app never restates `auth`.
372
+ 2. **`akan.config.ts`: `syncPageLibs: true`** (or the lib's name in an array). `akan sync <app>` links the consent
373
+ route under every basePath the app declares; the link folder is generated and never committed.
374
+ 3. **`env.server.*` → `oauth`.** `consentPath` / `signinPath` carry the basePath the browser sees
375
+ (`/office/oauth/consent`, `/office/signin`) and are omitted for an app without one. The server hands the
376
+ sign-in page `?redirect=` as the app's router names the route — basePath removed — because `router.push` puts
377
+ the locale and basePath back; the app adds nothing. `issuer` (`AKAN_MCP_AUTH_SERVERS` for the resource side)
378
+ only behind a tunnel or an edge that renames the host, `resource` (`AKAN_MCP_RESOURCE`) only when `path` moved
379
+ off `/mcp`, `clients` + `dynamicRegistration: false` only for a deployment that closes registration.
380
+ 4. **The sign-in page honours `?redirect=`** by passing it unchanged to the password and SSO controls — a
381
+ router-space path, never prefixed by hand. Accepting it needs only `startsWith("/") && !startsWith("//")`.
382
+ 5. **`lib/option.ts`: `option.setMcp({ instructions, legacyTextBlock: false })`** (`AKAN_MCP_INSTRUCTIONS`,
383
+ `AKAN_MCP_LEGACY_TEXT`). Leave `readOnly` (`AKAN_MCP_READONLY`) off when the point is to let people *use* the
384
+ service; it is the valve for a deployment that must not write, not the way to curate.
385
+ 6. **`JWT_SECRET`** (or `security.jwtSecret`) in every deployment outside `local`; the boot refuses without it.
386
+ 7. **Read the boot log** — `MCP catalogue: tools=… · listing …KB`, the `MCP catalogue cost:` line, one `warn` per
387
+ refusal, and the list of published entries with no `.desc()`. A tool missing from it is missing everywhere.
388
+ 8. **Connect once per app** — `claude mcp add --transport http <name> <issuer>/mcp`; a second entry for the same
389
+ server with a pre-registered client tests a registration mode, it does not add anything a user needs.
390
+
391
+ ## Choosing What To Open
392
+
393
+ One rule: **an endpoint is open to an agent when the same person could do the same thing from the page, under
394
+ the same guards.** What stays closed is closed because no agent has a reason to call it, not because of who may
395
+ — and that is the only thing `mcp: false` says.
396
+
397
+ | Endpoint | Decision |
398
+ |---|---|
399
+ | Domain read slices (`inOrg`, `inProject`, `inSelf`, `byStatuses`) | Open. A named slice publishes only when it declares its **own** `guards`; the `slice()` map reaches the root slice and the generated CRUD, never a named slice. A search slice only on data that may be enumerated. |
400
+ | The root slice and generated CRUD | Published by default; `root:` is `Admin`, so a user token never lists it. Take the cost line first: `mcp: { cru: false }` keeps the reads and drops the writes of a model whose write entries are most of its bytes; `mcp: false` for an internal or sensitive model. |
401
+ | Domain mutations (`startTask`, `completeTicket`) | Open when a person would ask an agent to do it for them; a resource guard is mandatory. An act that cannot be undone — signing, paying, sending, removing — either stays off or says so in its `.desc()`: MCP has no approval card. |
402
+ | Steps of a UI state machine (`requestPhoneCodeForSignin`, `setPasswordInPrepareUser`, a prepare-user flow) | `mcp: false`. Perfectly guarded, and only ever called by mistake. |
403
+ | Authentication, SSO, and the OAuth protocol endpoints | Already `mcp: false` where they are declared. |
404
+ | File upload, an `Any` / `Upload` return, a required `Any` argument, `pubsub` / `message`, `light<Model>` | Refused by the framework; nothing to write. |
405
+ | Admin-only endpoints | Leave the `Admin` guard; an account-scope guard hides the entry from every caller it refuses. |
406
+ | An act only a person may take (signing, paying, sending to a customer) | `guards: [Every, Person]` refuses every agent and, because `Person` declares `static agents = false`, takes the entry out of the catalogue document — no `mcp: false` needed, the HTTP route untouched. When a person and a model may both call but not to the same effect, keep the guards and take `.with(AgentCall)` to narrow what the call sets in motion. |
407
+
408
+ - **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
409
+ and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
410
+ listed, and stops the call. Marking a resource guard `"account"` would make the listing lie — the marker has no
411
+ default for that reason.
412
+ - **Identity comes from the internal args** (`.with(Self)`, `.with(Account)`, `.with(Me)`), never from an id the
413
+ client sent, and the service re-checks ownership behind the guard.
414
+ - **An admin who signs in on the consent page mints an admin token.** A connector created under an admin account
415
+ passes `Admin` guards; treat that as an operational decision, and read the request's subject type when in doubt.
416
+ - **Return what answers the question.** A count-or-summary endpoint taking an id beats a document; a nullable model
417
+ return publishes no `outputSchema`, so prefer a non-null return plus an `Err`. Bulky page-only fields are
418
+ `field.visual`. Narrow a list with a named filter slice, never with the root list's raw `query`.
419
+
420
+ ## Writing `instructions`
421
+
422
+ `*.abstract.md` and `<Agent.Guide>` never reach an MCP client. Exactly three texts do: `instructions`, every
423
+ published entry's `.desc()`, and a page's `.prompt()` description. All three are English, whatever the users speak
424
+ — the reader is a model, and the catalogue is built once in one `language` and cached by clients.
425
+
426
+ - **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
427
+ mean; **which tool to call first** and which ids the rest take; how a name becomes an id; what a write confirms
428
+ before it runs and which writes cannot be undone; what this server does *not* do and where that is done instead.
429
+
430
+ ```ts
431
+ instructions: [
432
+ "Operations tools for a back-office that runs consulting projects.",
433
+ "An org is one company; a project belongs to an org; a ticket is one unit of work on a project.",
434
+ "Start from orgInSelf, then projectInOrg and ticketInProject; every other tool takes the ids those return.",
435
+ "Prefer ticketStatusInProject for 'how is it going' and ticketInProject only when the user asks for detail.",
436
+ "createTicket, startTicket and completeTicket write on the caller's behalf: confirm the project and title first.",
437
+ "Nothing here signs, invoices or uploads a file; those are deliberate acts on the record's own page.",
438
+ ].join(" "),
439
+ ```
440
+ - **`.desc()` is the reason to pick one tool** — what it returns and when to use it, two clauses. An id argument's
441
+ `.desc()` names the tool that returns it (`From projectInOrg`). The generated CRUD borrows the model's `.of()`
442
+ label and `.desc()`, which is the only text those entries can carry.
443
+ - **A page prompt is a workflow the user invokes** as a slash command, not one the model chooses — a weekly
444
+ report, an onboarding check. Its instruction is the `.prompt()` description alone and its attachments are what
445
+ the screen fetched, masked by their return models — so to change what a prompt hands the model, change what the
446
+ screen loads, and keep the description to the ask.
447
+
448
+ ## Verifying An App
449
+
450
+ - `akan lint <app>`, `akan typecheck <app>`, `akan sync <app>`, then the boot log above.
451
+ - The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
452
+ redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
453
+ rotation) and the refresh-session and option unit tests.
454
+ - **One exposure test per app**, in a `lib/<model>.signal.test.ts`: list `tools/list` with a user token and pin the
455
+ names with `toEqual`, and assert the curated and admin-only ones with `not.toContain`. Exposure is derived from
456
+ the guards, so a guard change fails this test first — which is the point. The framework's own pattern is
457
+ `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
458
+ - Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
459
+ prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
460
+ both answering 200 is the grace window working, not a fault.
@@ -43,7 +43,12 @@ with it. `conventions` carries the invariants — this is the full contract behi
43
43
  the server just returned, so it survives `{ insight: false }` — which makes no count query at all — and cannot
44
44
  drift from the count a live event moved. `loadMoreOf<Model>()` is a no-op once it is false, and `Load.Units`
45
45
  draws no infinite-scroll sentinel.
46
- - Generated list accessors like `listBy(...)` return `Promise<Doc[]>`. For a chainable builder (`.sort().skip().limit().select()`) use the model facade's `findMany`/`findOne` (`FindManyChain`, `pkgs/akanjs/document/into.ts`).
46
+ - Generated list accessors like `listBy(...)` return `Promise<Doc[]>`. For a chainable builder (`.sort().skip().limit().select()`) use the model facade's `find`/`findOne` (`FindManyChain`, `pkgs/akanjs/document/into.ts`).
47
+ - **A projection is bare on the facade and nested under `select` on a filter accessor.** `pickById(id, { secretField:
48
+ true })` / `find(query, { secretField: true })` take the projection as their second argument; a generated
49
+ `findBy<Filter>(...args, { select: { secretField: true } })` takes it inside the option object. The shapes do not
50
+ swap: `{ select: … }` handed to the facade projects a field named `select`, which no model has, and the read comes
51
+ back empty with no error. This is the only way to read a `field.secret(...)` value, which is otherwise stripped.
47
52
  - **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`); client fetch results are raw `GetStateObject` plain data (functions stripped, `pkgs/akanjs/base/types.ts`).
48
53
  - Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
49
54
  `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
@@ -133,11 +138,13 @@ shape, so `cascade` never means "related" — it means one of exactly these:
133
138
  `refPath`. An array, a Map, `ref` together with `refPath`, and a field naming no owner each fail the class build.
134
139
  - **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time, so every
135
140
  model's removal would have to sweep the polymorphic table on the chance it is the owner.
136
- - **`polymorphic: "any"` buys that sweep on purpose**, for the one shape an enum cannot express: a child whose
137
- owner is any model in the app, named by a free-form `String` holding the owner's refName.
141
+ - **`cascade: "removeWithAny"` buys that sweep on purpose**, for the one shape an enum cannot express: a child
142
+ whose owner is any model in the app, named by a free-form `String` holding the owner's refName. It is a third
143
+ action rather than a flag beside `removeWith`, because the widening and the direction are one decision — the
144
+ option type pairs the action with the `refPath` it reads the owner off, so neither can be declared alone.
138
145
 
139
146
  ```ts
140
- parent: field(ID, { refPath: "parentType", cascade: "removeWith", polymorphic: "any" }),
147
+ parent: field(ID, { refPath: "parentType", cascade: "removeWithAny" }),
141
148
  parentType: field(String), // holds the owner's refName
142
149
  ```
143
150
 
@@ -148,11 +155,12 @@ shape, so `cascade` never means "related" — it means one of exactly these:
148
155
  `info` line naming the edges — that line is the answer to "why does this app remove everything per document".
149
156
  The opt-in is what keeps an app that declared none on the fast path.
150
157
 
151
- Refused at the class build: `polymorphic: "any"` on a `refPath` that already names an `enumOf` (the enum names
152
- the candidates and indexes better — keep one), a `typeKey` that is not a `String` (the sweep matches a refName
153
- against that column, and a column that cannot hold one silently finds nothing), and the option anywhere other
154
- than a `cascade: "removeWith"` field with a `refPath`. A wildcard owner is also exempt from the mount check
155
- that a monomorphic owner fails at boot — it names no module to mount.
158
+ Refused at the class build: `removeWithAny` on a `refPath` that already names an `enumOf` (the enum names the
159
+ candidates and indexes better — use `removeWith`), a `typeKey` that is not a `String` (the sweep matches a
160
+ refName against that column, and a column that cannot hold one silently finds nothing), and the action with no
161
+ `refPath` at all — that last one is a typecheck error at the call site, and the class build keeps the same
162
+ refusal for the macro and bundled paths that reach it untyped. A wildcard owner is also exempt from the mount
163
+ check that a monomorphic owner fails at boot — it names no module to mount.
156
164
  - **A cascade goes through the target's service, never its model** — that path is what runs the target's
157
165
  `_postRemove`, where a module puts the side effect the removal has to carry.
158
166
  - **Bulk is decided at boot, per target model, for both directions.** A target with no `remove` schema hook, no
@@ -84,19 +84,21 @@ whole detail surface renders server-side wherever a route uses the `View` direct
84
84
  sections actually need:
85
85
 
86
86
  ```tsx
87
- export default async function Page({ params: { orgId } }: PageProps) {
88
- const { taskInitInOrg, taskListInOrg } = fetch.initTaskInOrg(orgId);
89
- const { orgView } = fetch.viewOrg(orgId);
90
- return (
91
- <>
92
- <Org.Zone.Header view={orgView} />
93
- <Task.Zone.Card init={taskInitInOrg} />
94
- <Load.Stream of={taskListInOrg} fallback={<Loading.Skeleton active />}>
95
- {(taskList) => taskList.map((task) => <Task.Unit.Row key={task.id} task={task} />)}
96
- </Load.Stream>
97
- </>
98
- );
99
- }
87
+ export default page()
88
+ .param("orgId", ID)
89
+ .render(async ({ orgId }) => {
90
+ const { taskInitInOrg, taskListInOrg } = fetch.initTaskInOrg(orgId);
91
+ const { orgView } = fetch.viewOrg(orgId);
92
+ return (
93
+ <>
94
+ <Org.Zone.Header view={orgView} />
95
+ <Task.Zone.Card init={taskInitInOrg} />
96
+ <Load.Stream of={taskListInOrg} fallback={<Loading.Skeleton active />}>
97
+ {(taskList) => taskList.map((task) => <Task.Unit.Row key={task.id} task={task} />)}
98
+ </Load.Stream>
99
+ </>
100
+ );
101
+ });
100
102
  ```
101
103
 
102
104
  Both queries leave immediately — splitting the result never serializes them — and each section renders behind its
@@ -5,7 +5,8 @@
5
5
  - `apps/<app>/lib/<model>` holds database-backed domain modules, `lib/_<service>` service modules, and
6
6
  `lib/__scalar/<scalar>` reusable value types. Module abstracts sit beside the code as `<name>.abstract.md`.
7
7
  - `apps/<app>/page` holds server-side file-routed pages: `<routeName>.tsx` serves `/routeName`, a directory's
8
- `_index.tsx` serves that directory, `_layout.tsx` nests a layout, and `[modelId]` is a dynamic segment.
8
+ `_index.tsx` serves that directory, `_layout.tsx` nests a layout, and `[modelId]` is a dynamic segment. Each route
9
+ file is one chain — `export default page()…render()`, or `layout()` / `rootLayout()` in a `_layout.tsx`.
9
10
 
10
11
  The file roles inside a module — which file owns persistence, business logic, state, and each UI shape — are in
11
12
  the convention set above under **Domain Module Conventions**.
@@ -232,8 +233,12 @@ query-level writes are in the convention set above; these are the shapes that bu
232
233
  already exist. A collision can build green and fail only at runtime — pick a distinct verb.
233
234
  - **Numbers are `Int` or `Float`, never `Number`.** `field(Number)` / `.body("x", Number)` fail to typecheck.
234
235
  - **Array fields use `field([T])`** — `tags: field([String])`, `images: field([File])`.
235
- - **Reading a secret field needs an explicit select.** `field(...).secret()` values are stripped from query results
236
- by default: `this.userModel.pickById(id, { select: { passwordHash: true } })`.
236
+ - **Reading a secret field needs an explicit select, and the two call sites spell it differently.**
237
+ `field(...).secret()` values are stripped from query results by default. The model facade takes the projection
238
+ **bare** — `this.userModel.pickById(id, { passwordHash: true })`, likewise `pickOne` / `find` / `findOne` /
239
+ `findById`. A generated filter accessor takes it **inside the option object** —
240
+ `this.userModel.findByAccountId(accountId, statuses, { select: { passwordHash: true } })`. Handing `{ select: … }`
241
+ to the facade selects a field named `select`, which no model has: the value arrives empty, with no error.
237
242
  - **`cascade` names a direction, and the wrong one is a data loss** — `removeRef` on the relation the owner holds,
238
243
  `removeWith` on the child's reference to its owner. A query-level removal fires no hooks and therefore no cascade.
239
244
  - **`q.search()` is a filter node, not a slice requirement.** `bySearch: filter().arg("text", String).query((text,
@@ -258,6 +263,6 @@ core framework. The core framework gives you the composition points below; wire
258
263
  - **Auto-generated CRUD and `serve()` service methods / lifecycle hooks do not receive session context.** If an
259
264
  operation needs the acting user, expose a custom endpoint that takes it via `.with(CurrentUserId)` — never trust a
260
265
  client-supplied user id.
261
- - **SSR auth-gated pages: guard at the layout.** Check the session in the `_layout.tsx` loader and redirect when it is
262
- absent, so nested pages never render for signed-out users.
266
+ - **SSR auth-gated pages: guard at the layout.** Check the session inside the `_layout.tsx` chain's `.render()` and
267
+ redirect when it is absent, so nested pages never render for signed-out users.
263
268