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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.build-stamp +1 -1
  2. package/{agent.command-964bf8qg.js → agent.command-xdvw642m.js} +5 -5
  3. package/{application.command-n45n18sc.js → application.command-g3f7xj25.js} +27 -4
  4. package/buildBatch.proc.js +1 -1
  5. package/{capacitorApp-vnvnvdhf.js → capacitorApp-b0ppke3m.js} +2 -2
  6. package/{cloud.command-kx1bh5yq.js → cloud.command-0hmsv44q.js} +7 -7
  7. package/commandManifest.json +1 -1
  8. package/{context.command-g1a65x6j.js → context.command-x92afrnf.js} +12 -12
  9. package/{guideline.command-xphyhvwh.js → guideline.command-hpq08q66.js} +2 -2
  10. package/guidelines/conventions/conventions.instruction.md +255 -436
  11. package/guidelines/framework/framework.generate.json +3 -2
  12. package/guidelines/framework/framework.instruction.md +0 -9
  13. package/guidelines/mcpRule/mcpRule.instruction.md +5 -8
  14. package/guidelines/queryRule/queryRule.generate.json +31 -0
  15. package/guidelines/queryRule/queryRule.instruction.md +143 -0
  16. package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
  17. package/guidelines/runtimeRule/runtimeRule.instruction.md +176 -0
  18. package/guidelines/transportRule/transportRule.generate.json +31 -0
  19. package/guidelines/transportRule/transportRule.instruction.md +60 -0
  20. package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +17 -392
  21. package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
  22. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +402 -0
  23. package/incrementalBuilder.proc.js +1 -1
  24. package/{index-nkwmnqwn.js → index-5f4gnt4c.js} +1 -0
  25. package/{index-va15r8h5.js → index-6d5r8q6k.js} +2 -2
  26. package/{index-4rw4qhe6.js → index-737aj4fm.js} +3 -3
  27. package/{index-ytaqtc04.js → index-8besc14m.js} +1 -1
  28. package/{index-hee7egtm.js → index-cbfp5wyp.js} +1 -1
  29. package/{index-mkn5qagt.js → index-cp00yns2.js} +70 -4
  30. package/{index-nj3w0r34.js → index-ety6takb.js} +3 -3
  31. package/{index-e31ghfp5.js → index-gvq17wqn.js} +1 -1
  32. package/{index-8gybxemv.js → index-hs70d8ed.js} +5 -5
  33. package/{index-0ngvvgry.js → index-jybbmwed.js} +2 -2
  34. package/{index-bqqts262.js → index-m5gfc86a.js} +1 -1
  35. package/{index-qt4nfr0n.js → index-nq3avsf6.js} +10 -10
  36. package/{index-yg3hxk2k.js → index-p27h7535.js} +4 -4
  37. package/{index-fahkq20v.js → index-pehn44hr.js} +3 -3
  38. package/{index-v5fdxdre.js → index-r691v756.js} +2 -2
  39. package/{index-p681w089.js → index-sqsfv8wg.js} +2 -2
  40. package/{index-rbnpe8cp.js → index-wbbswwqt.js} +17 -21
  41. package/{index-rb65xnw9.js → index-wybbcgxn.js} +4 -4
  42. package/index.js +18 -18
  43. package/{library.command-rfpd27yv.js → library.command-8vgrdtwy.js} +3 -3
  44. package/{localRegistry.command-pyxfe0fs.js → localRegistry.command-0gtqj0r0.js} +6 -6
  45. package/{module.command-qhaxad0p.js → module.command-rama4zya.js} +5 -5
  46. package/{package.command-2k22q9nt.js → package.command-6jxapdsg.js} +3 -3
  47. package/package.json +2 -2
  48. package/{page.command-ymk9dqh4.js → page.command-hxy7x525.js} +3 -3
  49. package/{primitive.command-9k45zpc1.js → primitive.command-4bf6xcc3.js} +6 -6
  50. package/{quality.command-bf6vkxe5.js → quality.command-h5edwegz.js} +2 -2
  51. package/{repair.command-egvvm6xf.js → repair.command-rgqf6px6.js} +5 -5
  52. package/{scalar.command-bt1facyj.js → scalar.command-6krkz6tw.js} +4 -4
  53. package/templates/workspaceRoot/AGENTS.md.template +4 -0
  54. package/{workflow.command-v1n3wg7v.js → workflow.command-dwhwrqhd.js} +9 -9
  55. package/{workspace.command-yvvqvhzz.js → workspace.command-f5h11gjn.js} +18 -18
@@ -20,11 +20,12 @@
20
20
  ],
21
21
  "update": {
22
22
  "filePath": "./framework.instruction.md",
23
- "contents": ["purpose", "file ownership", "current API patterns", "codegen rules", "validation checklist"],
23
+ "contents": ["file ownership", "current API patterns", "codegen rules", "theming and UI customization"],
24
24
  "rules": [
25
25
  "Use current Akan APIs only",
26
26
  "Keep output parseable for codegen",
27
- "Include ownership boundaries and review checklist"
27
+ "Include ownership boundaries",
28
+ "Carry no meta about this document itself — it renders into every workspace's always-loaded AGENTS.md"
28
29
  ]
29
30
  },
30
31
  "page": "apps/akan/page/(docs)/docs/intro/fundamentals.tsx"
@@ -1,8 +1,5 @@
1
1
  # Akan.js Framework Guide
2
2
 
3
- ## Purpose
4
- Use this as the compact framework context for AI codegen. It should explain how Akan turns convention-based files into a full-stack app without teaching every API in one place.
5
-
6
3
  ## Ownership
7
4
  - `apps/<app>` contains app code, pages, env files, module folders, UI, webkit, srvkit, and common utilities.
8
5
  - `libs/<lib>` contains reusable domain and utility libraries consumed by apps.
@@ -28,9 +25,3 @@ When a request implies a distinct look and feel, do not stop at colors — custo
28
25
  - **Theme (`apps/<app>/page/styles.css`).** The app imports Tailwind and `akanjs/ui/styles.css`, then overrides semantic token *values* per theme under `:root, [data-theme="dark"]` and `[data-theme="light"]` (`--background`, `--foreground`, `--primary`, `--muted`, `--border`, … each with a `-foreground` pair for text). The framework maps them to Tailwind color names, so `bg-primary` / `text-foreground` follow the `data-theme` attribute; corner rounding uses `--radius-box` / `--radius-field`. Fetch `get_guideline` with `cssRule` for the full token set before a deep theme pass.
29
26
  - **Lib tokens (`libs/<lib>/ui/tokens.css`).** Colors a lib's own components pin — a vendor brand color, a fixed surface — are declared once there as plain `:root` custom properties and compiled into every app that reaches the lib, ahead of the app's stylesheets. Reference them as `bg-[var(--kakao)]`; never copy the block into each app.
30
27
  - **Components (`page/**/_overrides.tsx`).** When a default `akanjs/ui` component (Button, Modal, Table, Input, Select, …) is too restrictive for the design, re-skin it per route instead of forking, wrapping, or fighting it with utility classes. Write a drop-in replacement in `apps/<app>/ui/` typed against the slot contract (`AkanModalComponent`, or `AkanUiOverrides["<Slot>"]`), composing the framework's headless parts, then bind it in a `page/**/_overrides.tsx` manifest with a single `export default override({ Slot: BrandComponent })`. Overrides cascade down the route tree like layouts (closest ancestor wins). Fetch `get_guideline` with `componentRule` and read the `references/ui/customize` docs page for the slot list and patterns.
31
-
32
- ## Review Checklist
33
- - The instruction points to current docs pages, not removed docs routes.
34
- - Generated examples use current Akan builder APIs and scanner-friendly filenames.
35
- - The output contract tells the model which file paths to return.
36
- - The guide avoids broad framework essays when a concrete file rule is better.
@@ -151,14 +151,11 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
151
151
  and are not configurable. Clients are told to distrust hints; they are never a gate.
152
152
  - **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
153
153
  whatever it declared, and reports each one in the boot log like any other refusal.
154
- - **RFC 9728 metadata is published only once `AKAN_MCP_AUTH_SERVERS` names an issuer.** An unprotected resource
155
- that still advertises `bearer_methods_supported` is treated by Cursor (and similar clients) as an OAuth
156
- mandate, so the well-known document is absent until there is somewhere to obtain a token. When it is served,
157
- it lives at `/.well-known/oauth-protected-resource` (and at that path plus the mount path, the spelling most
158
- clients try first). `AKAN_MCP_SCOPES` and `AKAN_MCP_RESOURCE` configure the document when it exists;
159
- `insufficient_scope` is enforced only once `AKAN_MCP_SCOPES` is set. A token carrying no `aud` at all is
160
- refused once an issuer is named — that issuer mints tokens for its other resources too — and accepted while
161
- none is named, because a first-party Akan token is bound by app and environment.
154
+ - 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.
162
159
  - **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
163
160
  description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
164
161
  the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
@@ -0,0 +1,31 @@
1
+ {
2
+ "title": "Query Rule",
3
+ "description": "Akan data access — filters, slices and hydration, full-text search, and cascade removal",
4
+ "group": "core",
5
+ "codegenPriority": 4,
6
+ "deprecated": false,
7
+ "scans": [
8
+ {
9
+ "type": "example",
10
+ "description": "Filter and model declarations that drive the generated query surface",
11
+ "path": "{apps,libs}/*/lib/*/*.document.ts",
12
+ "sample": 4
13
+ },
14
+ {
15
+ "type": "example",
16
+ "description": "Slice declarations that expose a query to a client store",
17
+ "path": "{apps,libs}/*/lib/*/*.signal.ts",
18
+ "sample": 4
19
+ }
20
+ ],
21
+ "update": {
22
+ "filePath": "./queryRule.instruction.md",
23
+ "contents": ["slices and hydration", "generated filter methods", "text search", "cascade removal"],
24
+ "rules": [
25
+ "Use current Akan APIs only",
26
+ "State the rule and the reason it exists, never restate a type signature",
27
+ "Say which generated methods fire hooks and which do not",
28
+ "Keep it workspace-neutral — no app or repo names"
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,143 @@
1
+ # Query Rule — Filters, Slices, Hydration, Search, Cascade
2
+
3
+ How a model's data is queried, exposed to a client store, searched, and removed. Filters and slices are the two
4
+ query surfaces; `q.search()` and the `text` field roles are the search surface; `cascade` is what a removal takes
5
+ with it. `conventions` carries the invariants — this is the full contract behind them.
6
+
7
+ ## Slices, Queries, and Hydration
8
+
9
+ - **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
10
+ [ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
11
+ filter every model carries. A raw query descriptor let a caller compose a query the model never declared, and
12
+ could not survive the query string at all — `String(value)` sends `[object Object]`. Args are parsed by the type
13
+ the filter declared, a missing required one is refused, and args past the declared ones are dropped
14
+ (`resolveFilterQuery`, `pkgs/akanjs/document/filterMeta.ts`). It is an admin API: `root:` is always `Admin`.
15
+ - **Give an id filter arg a `ref` and the admin panel stops asking for hex.** `filter().arg("ownerId", ID, { ref:
16
+ "user" })` names the model the id points at; the arg then renders a picker that runs the same query maker against
17
+ that model's own filters, so a ref of a ref keeps nesting. The picker holds its rows in local state rather than the
18
+ ref model's store — that store is a singleton, and loading into it would overwrite whatever listing of the same
19
+ model is already on screen. `ref` is the only UI hint a filter arg carries; there is no render callback, because a
20
+ function cannot cross to the client.
21
+ - **A summary counter names the listing it counts, on the field itself.** `field(Int).meta(getQueryMeta("user")
22
+ .query("byStatuses").args([["prepare"]]))` is what makes the dashboard tile clickable: clicking it applies that
23
+ filter to the listing in place — one request, no navigation — and fills the arg controls under the toolbar, so
24
+ the filter is visible and editable rather than opaque. `args` may be a thunk (`() => [dayjs().subtract(1,
25
+ "hour")]`), read at the click so a relative time is current. A meta naming a different model counts a different
26
+ listing, so its tile stays inert here. `AdminPanel`'s `queryMap` still overrides per column, and is now only for
27
+ a column whose field cannot declare one.
28
+ - **A picker row is labelled by `labelOf`, which reads `Light<Model>.label()` first.** Then the field carrying
29
+ `text: "title"`, then `title` / `name`, with the id as the floor. Write the method on the Light class — that is
30
+ where display logic belongs, and the same label is what `Load.Units` and the in-page agent's screen scope show.
31
+ A model with none renders ids and says so in the picker.
32
+ - A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
33
+ - Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
34
+ - 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`).
35
+ - **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`).
36
+ - Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
37
+ `count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
38
+ query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use them on a
39
+ model that carries no removal side effect; otherwise remove documents one at a time.
40
+ - **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
41
+ "archived" })`. The patch cannot trail the filter args — a filter's own args may be optional and no tuple type
42
+ puts a required element after those — so it lands on a terminal `.set()`, mirroring the `UPDATE … SET …` it
43
+ compiles to. Building the chain touches nothing; only `.set()` runs a query.
44
+ - `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
45
+ and there is no way to change that. They also report only counts, never which row they touched, so they are for
46
+ "there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
47
+ - **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
48
+ model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
49
+ throws at boot instead (`assertFilterFitsCrud`).
50
+ - **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
51
+ key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
52
+ or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
53
+ out, while a document read back and saved carries the explicit `null` that the read materialized. So `q.missing`
54
+ matches a freshly inserted row and silently stops matching that same row after its first round-trip save. Reach
55
+ for it only to find rows written before the field was declared.
56
+
57
+ ## Text Search In A Filter — `q.search()`
58
+
59
+ - Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
60
+ q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
61
+ `insightBySearch` come for free — you do **not** need a slice to make search usable.
62
+ - **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
63
+ client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk the
64
+ table. Leave that decision to the mounting app.
65
+ - `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
66
+ `q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery` — a query-level write
67
+ takes no join, so ignoring the node would silently widen the write to every other matching row.
68
+ - Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
69
+ search endpoint into a full listing.
70
+ - Order by relevance with the built-in `relevance` sort key. It is an empty sort map, which the store reads as
71
+ "unspecified": score order when a search join is present, `createdAt` descending otherwise. That fallback is the
72
+ compiler's own, not a model-defined default — redefining `latest` on the model does not change it.
73
+ - **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
74
+ client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
75
+ - Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
76
+ finite numbers positional over `["title", "desc", "tag", "filter"]`.
77
+
78
+
79
+ ## Text Search Fields — the `text` role
80
+
81
+ - A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
82
+ `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
83
+ - Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
84
+ `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
85
+ scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
86
+ - `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
87
+ - **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
88
+ mirror is plaintext and an indexed secret would leak through search. The class build throws the same refusal as
89
+ a backstop, for an option object the excess-property check cannot see through, and names the way out: drop the
90
+ role, or leave the field unmasked. Do not work around either. The throw also covers a `text` field *underneath*
91
+ one of those — a scalar's own field is reachable through its parent, so `f.secret(Noti)` where `Noti.label`
92
+ carries a role is rejected at the parent, not silently indexed; that one is only reachable at runtime, since the
93
+ pairing spans two files.
94
+ - The role works on a relation too (`image: field(File, { text: "thumb" })`) and on an array (`playing: field([String],
95
+ { text: "tag" })`); an array of objects indexes by leaf key, including an array leaf (`works[*].tags`). A field
96
+ inside a `Map` indexes nothing: there is no fixed path to extract it from.
97
+ - Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
98
+ because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
99
+ path.
100
+ - Search runs on sqlite/libsql only. `q.search()` against Postgres throws, loudly, rather than returning every row.
101
+ - `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
102
+ re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
103
+ it does not mount, so a mixed fleet leaves stale triggers behind.
104
+ - The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
105
+ `unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
106
+ tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
107
+ rebuild takes no cross-process claim, so a fleet restarted at once repeats it in every process; stagger the
108
+ restart when the mirror is large.
109
+
110
+ ## Cascade Remove — the `cascade` option
111
+
112
+ **The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
113
+ shape, so `cascade` never means "related" — it means one of exactly these:
114
+
115
+ - `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
116
+ `image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it; a primitive, a bare
117
+ `ID`, and a scalar each fail the class build.
118
+ - `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
119
+ its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three forms:
120
+ a relation (`field(AgentSession, { cascade: "removeWith" })`), an id with `ref`, or a polymorphic id with
121
+ `refPath`. An array, a Map, `ref` together with `refPath`, and a field naming no owner each fail the class build.
122
+ - **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time, so every
123
+ model's removal would have to sweep the polymorphic table on the chance it is the owner.
124
+ - **A cascade goes through the target's service, never its model** — that path is what runs the target's
125
+ `_postRemove`, where a module puts the side effect the removal has to carry.
126
+ - **Bulk is decided at boot, per target model, for both directions.** A target with no `remove` schema hook, no
127
+ `_pre`/`_postRemove`, no cascade of its own, and no children takes one `removeManyByQuery`. Adding a
128
+ `_postRemove` silently flips it back to one document at a time — the boot log is the only place that shows.
129
+ - **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` counts,
130
+ and an unmounted `removeRef` target or `removeWith` owner fails the boot rather than the first removal. An
131
+ unmounted `refPath` candidate only warns, since that list spans optional modules by design.
132
+ - **Nothing checks whether another document still references the same target.** `File` in particular is deduped
133
+ by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
134
+ - Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
135
+ restorable, and reviving the owner does not revive what went with it.
136
+ - A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
137
+ polymorphic); every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table.
138
+ - **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
139
+ generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
140
+ at a time when it cascades.
141
+ - Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write. Cycles are cut by a
142
+ visited set carried down the whole chain, with a depth cap of 16.
143
+
@@ -0,0 +1,25 @@
1
+ {
2
+ "title": "Runtime Rule",
3
+ "description": "Akan build and runtime surfaces — web/csr toggles, the gateway/solo process model, logging, the generated image, and shipped assets",
4
+ "group": "core",
5
+ "codegenPriority": 4,
6
+ "deprecated": false,
7
+ "scans": [
8
+ {
9
+ "type": "example",
10
+ "description": "App and lib runtime declarations",
11
+ "path": "{apps,libs}/*/akan.config.ts",
12
+ "sample": 4
13
+ }
14
+ ],
15
+ "update": {
16
+ "filePath": "./runtimeRule.instruction.md",
17
+ "contents": ["web surface toggles", "process model", "logging and live tail", "generated image", "shipped assets"],
18
+ "rules": [
19
+ "Use current Akan APIs only",
20
+ "State the rule and the reason it exists, never restate a type signature",
21
+ "Name the env that narrows a build-time declaration wherever one exists",
22
+ "Keep it workspace-neutral — no app or repo names"
23
+ ]
24
+ }
25
+ }
@@ -0,0 +1,176 @@
1
+ # Runtime Rule — Serving, Processes, Logging, Image, Assets
2
+
3
+ How an Akan app is built, what it serves, how many processes it runs, where its logs go, and what ends up in
4
+ its image. Everything here is declared in `akan.config.ts` or narrowed by an env at boot; none of it is reached
5
+ from domain code. `conventions` carries the invariants — this is the full contract behind them.
6
+
7
+ ## Web Surfaces — Building And Serving Without SSR/CSR
8
+
9
+ An app serves three things: the API, the SSR/RSC web renderer, and the CSR single-file bundle. The API is always
10
+ on; the other two are declared in `akan.config.ts` as **`web: true | false | { csr: boolean }`** and can be
11
+ narrowed again per deployment.
12
+
13
+ ```ts
14
+ const config: AppConfig = { web: { csr: false } }; // web without the mobile bundle
15
+ const config: AppConfig = { web: false }; // api only
16
+ ```
17
+
18
+ - **`web: { csr: false }`** drops the CSR build phase and the `/__csr` + `?csr=true` routes. The CSR bundle is
19
+ what the Capacitor mobile build ships, so a web-only deployment never needs it — and an app that declares a
20
+ `mobile` section is refused, because `akan build-ios` copies `dist/apps/<app>/csr/<target>.html` into the
21
+ native project.
22
+ - **`web: false`** is an API-only build: no base artifact, no pages or client bundles, no RSC worker
23
+ entrypoint, and no `public/` in the image (the web router's catch-all is its only reader). Nothing under
24
+ `page/` is served, including routes a lib contributed through `syncPageLibs`.
25
+ - **There is no CSR-without-SSR option, by type.** The CSR bundle inlines the stylesheet the SSR base artifact
26
+ compiles, so it would ship an unstyled app — the object form therefore carries only `csr`, and SSR goes off
27
+ only through the whole-surface `false`.
28
+ - **At runtime, `AKAN_SSR` and `AKAN_CSR` narrow further and never widen** — `false` or `0` turns one off, and
29
+ `AKAN_SSR=false` takes CSR with it for the same reason the option has no such pair. A surface the build left
30
+ out cannot be switched back on, and the boot log names what the process ended up serving. The generated
31
+ Dockerfile writes the build's own answer as the image default.
32
+ - **A build with no web artifact boots the API instead of crashing.** `WebRouter.create` returns `null` when
33
+ `.akan/artifact/base-artifact.json` is absent — an api-only build, or a workspace with no `page/` at all.
34
+ - `akan start` ignores `web` and keeps the whole dev surface: the incremental builder is also the file watcher,
35
+ so switching it off would take server-code HMR with it. It warns once when the config and the dev server
36
+ disagree.
37
+ - The saving is mostly the RSC worker, which is a **separate process per web-serving replica**. Measured on
38
+ `apps/akan` at boot plus one render: 350MB across 3 processes with SSR on, 120MB across 2 with
39
+ `AKAN_SSR=false`, and the built image goes 86MB → 6.2MB when the artifacts are left out of it too.
40
+ - **The generated image installs `ca-certificates` and `tzdata` and nothing else.** It used to carry the whole
41
+ Chromium runtime, ffmpeg, `build-essential`, `python3` and `redis` in every app's image whether or not the app
42
+ reached for any of them. An app that needs one declares it in `docker.preRuns` / `docker.postRuns`, which are
43
+ emitted around the `bun install` — that is the migration for a `puppeteer` or `ffmpeg` app.
44
+
45
+ ## The Process Model — Gateway And Solo
46
+
47
+ A container runs one process per replica, a gateway in front of them when there is more than one, and one RSC
48
+ worker per web-serving replica.
49
+
50
+ - **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default, and what
51
+ every environment in `infra/app/values` sets — means there is nothing to balance and nothing to fan pubsub out
52
+ to, so `AkanApp` starts that replica in-process rather than spawning it. Measured on `apps/akan`: 28MB less RSS
53
+ and twice the requests per second, because every request used to cross a unix-socket proxy hop. Declare two or
54
+ more and the gateway is back, spawning and proxying them.
55
+ - **`AKAN_SOLO=false` forces the gateway** for a single replica. Like `AKAN_SSR`, the env only narrows — it
56
+ cannot fold a real gateway's replicas into one process. Passing `replica` to `new AkanApp(...)` also keeps the
57
+ gateway: code that states a topology is asking for the thing that serves it.
58
+ - **`akan start` always runs the gateway**, whatever the replica count. It is also the dev host's builder relay,
59
+ its crash page, and what holds the port across a child restart.
60
+ - **A batch-only replica (`0,1,0`) keeps the gateway too**, because a batch server never listens and the gateway
61
+ is then the only thing bound to answer `/_akan/app/health`.
62
+ - **The RSC worker is never folded in.** It runs under `--conditions react-server`, which resolves the same
63
+ module graph differently, so it cannot share a process with the server that renders client components.
64
+ - **A solo process answers `/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` itself**, in the
65
+ gateway's own shape — a `children` array with one entry — so a probe reads one contract either way. It owns the
66
+ rotating log file the gateway would otherwise write, in the same `runtime/logs` directory.
67
+ - **Nothing supervises a solo process but the orchestrator**, since the gateway's crash-restart-with-backoff went
68
+ with it. `infra/app/templates/app.yaml` carries the liveness, readiness and startup probes that replace it.
69
+ - **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel.** The barrel re-exports
70
+ `AkanServer`, whose graph the gateway never runs; through it the process evaluated 35MB of SSR renderer and
71
+ SQLite driver to spawn children and relay bytes. Keep entrypoint imports at the leaf.
72
+
73
+ ## Logging — Records, Request Context And Live Tail
74
+
75
+ Every `Logger` call builds a `LogRecord` before any text exists: `at`, `level`, OTel `sev`, `name`, `message`,
76
+ `pid`, `replicaIdx`, `role` (`gateway` / `all` / `batch` / `rsc-worker`) and — inside a call — `traceId`,
77
+ `endpoint` (`mutation:signScContract`, `internal:cleanupJob`, `page:/org/[orgId]`) and `origin` (`http`,
78
+ `websocket`, `mcp`, `internal`, `page`). The console line is rendered from the record and is byte-identical to
79
+ what it was; a sink reads `entry.record` and renders only if it touches `entry.message`.
80
+
81
+ The level ladder is `trace verbose debug info warn error`. `log` was a seventh tier *below* `info` that
82
+ `AKAN_PUBLIC_LOG_LEVEL=info` silently dropped; the method is kept and emits at `info`, so
83
+ `no-deprecated-log-level.grit` refuses a call to it — write `.info()`. `AKAN_PUBLIC_LOG_LEVEL=log` still boots,
84
+ normalized to `info` with one warning.
85
+
86
+ - **Request context is on by default, production included.** `SignalContext.try` / `SignalContext.run` own the
87
+ `AsyncLocalStorage` scope for the whole call, including the 500 log, so every line of one request shares a
88
+ `traceId`. Internal triggers get `internal:<key>`, the RSC worker gets `page:<route>`, MCP calls are the
89
+ endpoint's own type with `origin: "mcp"`. `AKAN_LOG_CONTEXT=0` is the escape hatch; `AKAN_TRACE=1` is a
90
+ different switch that adds span and query aggregation. Measured cost: ~25ns per call.
91
+ - **Not everything carries context, by design.** The primitive query fast path (an unauthenticated GET of a
92
+ primitive with no args, guards or middlewares) skips it; the schedule adaptor's own `started/finished/error`
93
+ lines wrap the traced handler from outside; gateway-internal lines have none. `akan logs --endpoint` says so.
94
+ - **`Logger.addSink(sink, { minLevel })` — give a sink its floor.** A sink with none follows `AKAN_LOG_FILE_LEVEL`
95
+ (default `trace`), which is why a registered sink used to make every `verbose` call render (1,294ns against an
96
+ 11ns reject). The IPC forwarder and any sink that never reads the text pass a floor and cost a record literal.
97
+ - **The hub lives with whoever owns the surface**: the gateway when there is one, the replica itself under solo
98
+ (`AKAN_REPLICA=0,0,1`), the same rule as the rotating log file and `/_akan/app/*`. It keeps a ring
99
+ (`AKAN_LOG_BUFFER` records / `AKAN_LOG_BUFFER_MB`, default 2000 / 4), suppresses a line repeating more than 20
100
+ times a second into one counted line, and serves `<runtimeDir>/akan-control.sock` (`0600`, NDJSON). A child
101
+ forwards records only while a subscriber wants that level (`log.level` IPC), so an unwatched process sends
102
+ nothing; `AKAN_LOG_STREAM=1` keeps it on. The RSC worker forwards to its replica the same way.
103
+ - **`akan logs <app>`** is the client: `--level --grep --endpoint --trace --child --role --origin --since`
104
+ AND together, comma lists OR, `*` is the only glob; `--replay N` first, `--follow false` for history only,
105
+ `--json` for NDJSON, `--runtime-dir` for a built app. Inside `akan console`, `.tail level=warn grep=payment`,
106
+ `.tail off`, `.trace <id>` — the console is its own `listen: false` process, so these attach to the running
107
+ server's socket rather than reading their own logs.
108
+ - **The generated Dockerfile sets `AKAN_LOG_TO_FILE=0`.** A container's writable layer is ephemeral and nothing
109
+ collects a file from it; stdout is the collection path. A deployment that wants the files back sets
110
+ `AKAN_LOG_TO_FILE=1`.
111
+ - **Never log per delivered record in anything that delivers records.** The control socket and the hub do not,
112
+ and a subscriber asking for everything is the test.
113
+
114
+ ## The Generated Image — `docker` In `akan.config.ts`
115
+
116
+ **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
117
+ assembles one from. There is no `content` field; the string *is* the content.
118
+
119
+ ```ts
120
+ const config: AppConfig = {
121
+ docker: { preRuns: ["apt-get update && apt-get install -y --no-install-recommends ffmpeg"] },
122
+ };
123
+ const config: AppConfig = { docker: "FROM oven/bun:1-slim\n…" }; // verbatim, nothing merged in
124
+ ```
125
+
126
+ - `image` and each run entry take `string | { amd64?, arm64? }`; the object form compiles to a
127
+ `RUN if [ "$TARGETARCH" = "<arch>" ]` guard, so a multi-arch build runs it on one leg only. `preRuns` land
128
+ before `bun install --production` (where a native dependency's build tools have to be), `postRuns` after it
129
+ and before the app files are copied.
130
+ - **A lib declares the steps its own runtime needs**, and every app that mounts it inherits them:
131
+ `libs/<lib>/akan.config.ts` takes `docker: { preRuns, postRuns }` and nothing else — the base image and the
132
+ command belong to the app. Lib steps are emitted before the app's own, and an identical step declared twice
133
+ becomes one layer.
134
+ - **The string form takes no contributions.** A Dockerfile handed over whole is used exactly as written, so a
135
+ lib's `preRuns` are dropped rather than spliced into a file Akan does not own. An app that needs both writes
136
+ the parts instead.
137
+ - Like `externalLibs`, lib steps are collected from **every lib in the workspace**, not just this app's
138
+ dependency closure — narrowing that set needs the dependency scan, and this config is re-read on every file
139
+ change in dev. Keep a lib's steps to what its runtime genuinely requires.
140
+ - `AkanAppConfig.docker` is the resolved declaration; `AkanAppConfig.dockerfile` is the text `akan build` writes
141
+ to `dist/apps/<app>/Dockerfile`.
142
+
143
+ ## Shipped Assets — `assets` In `akan.config.ts`
144
+
145
+ `akan build` copies the whole `public/` tree into `dist`, lib assets dereferenced, and that copy is the image.
146
+ **`assets: { pruneFonts, keepFonts }`** trims the fonts out of it that nothing reads. **Source trees are never
147
+ touched** — an app's and a lib's own `public/` keep every file, because one shared font folder is picked over
148
+ differently by every app that mounts it and by other repos.
149
+
150
+ ```ts
151
+ const config: LibConfig = { assets: { keepFonts: ["fonts/Assistant-*.woff2"] } }; // libs/<lib>/akan.config.ts
152
+ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve, not the normal escape hatch
153
+ ```
154
+
155
+ - **A font with `optimize` on is a build input, not a runtime asset.** `FontOptimizer` subsets it into
156
+ `.akan/artifact/fonts` and the emitted CSS points at `/_akan/fonts/*`, which `WebRouter` serves from the
157
+ artifact — so the source in `public/` is never opened again. That is the bulk of what this drops, and it
158
+ applies to the fonts the app *does* use, not only the ones it does not.
159
+ - **The keep-set is derived per app, per build; there is no list of fonts to drop.** An exclude list would rot
160
+ the moment another app or repo started using one of them, and nobody would know to update it. A font survives
161
+ because ① its font declares `optimize: false`, so `FontCss.getRuntimeCss` emits the raw src; ② something
162
+ references its filename — anything under `public/`, the compiled stylesheet, a client/server bundle, the CSR
163
+ shell; or ③ a `keepFonts` glob names it.
164
+ - **`keepFonts` belongs to the `akan.config.ts` that owns the font**, written against that lib's or app's own
165
+ `public/` (`"fonts/X.woff2"`, resolved to `libs/<lib>/fonts/X.woff2`). A lib shared across repos carries the
166
+ reason its own CSS needs a font, instead of every mounting app rediscovering it in production.
167
+ - **A reference from a build bundle is ignored for an already-subset font.** Every route file's `fonts`
168
+ declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared source is in
169
+ all three whether or not anything loads it. `public/` and the compiled CSS are read as authoritative.
170
+ - **A generated manifest that lists every public asset keeps every font it names** — a service-worker precache
171
+ file is the usual one. That is a real reference, not a false positive, so the build reports it at `info`:
172
+ read the `[font-prune] kept …` line before concluding the prune does nothing.
173
+ - Matching is by filename, including the percent-encoded spelling, so a `url()` survives however it is written.
174
+ - The phase is `assets`, between `csr` and `compress`. An api-only build (`web: false`) copies no `public/` and
175
+ the phase finds nothing to do.
176
+
@@ -0,0 +1,31 @@
1
+ {
2
+ "title": "Transport Rule",
3
+ "description": "How a call reaches an Akan signal — guards across HTTP and websocket, socket identity and cleanup, binary pubsub frames, and mutation HTTP verbs",
4
+ "group": "core",
5
+ "codegenPriority": 4,
6
+ "deprecated": false,
7
+ "scans": [
8
+ {
9
+ "type": "example",
10
+ "description": "Guard classes and their scope markers",
11
+ "path": "{apps,libs}/*/srvkit/guards.ts",
12
+ "sample": 2
13
+ },
14
+ {
15
+ "type": "example",
16
+ "description": "Signal endpoints declaring guards, pubsub, and message handlers",
17
+ "path": "{apps,libs}/*/lib/*/*.signal.ts",
18
+ "sample": 4
19
+ }
20
+ ],
21
+ "update": {
22
+ "filePath": "./transportRule.instruction.md",
23
+ "contents": ["guards across transports", "signal body types", "binary pubsub", "mutation HTTP verbs"],
24
+ "rules": [
25
+ "Use current Akan APIs only",
26
+ "State the rule and the reason it exists, never restate a type signature",
27
+ "Say what leaks or breaks when a rule is ignored",
28
+ "Keep it workspace-neutral — no app or repo names"
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,60 @@
1
+ # Transport Rule — Guards, Sockets, Binary Frames, HTTP Verbs
2
+
3
+ How a call reaches a signal: what a guard may read on either transport, how a websocket carries its credential
4
+ and its per-connection state, when a pubsub payload leaves JSON for a binary frame, and which HTTP verb a
5
+ mutation is mounted under. `conventions` carries the invariants — this is the full contract behind them.
6
+
7
+ ## Guards And Transports
8
+
9
+ - Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` (`pkgs/akanjs/signal/signalContext.ts`) instead of branching on `getHttpContext()` / `getWebSocketContext()`.
10
+ - Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is unguarded unless it declares its own `guards` in its signal option.
11
+ - A pubsub room is authorized once, at subscribe. When a socket's credential changes the framework re-runs each room's guards and unsubscribes the ones that now fail (`SignalResolver.revalidateWsRooms`), so guards must stay side-effect free and safe to re-run.
12
+ - A websocket carries its credential in the handshake snapshot on `ws.data` (`AppWsData`); clients that hold the token in memory send it with `fetch.setJwt(...)`, which forwards an auth frame over the socket.
13
+ - **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`.** Whenever a federation
14
+ gateway is in front, `ws.remoteAddress` and the child's own peer are the *gateway* (`127.0.0.1`) for every
15
+ caller and for the whole life of every socket. `Ip` reads what a proxy recorded (`x-real-ip`, then the first
16
+ `x-forwarded-for` entry) and falls back to the peer only when nothing proxied the call — which is the answer
17
+ for a solo process behind an ingress that sets neither; `context.getClientIp()` is the same answer inside a
18
+ guard or middleware. It arrives unwrapped from
19
+ its `::ffff:` IPv4-mapped form, so it can address a `udp4` socket as well as identify a caller, and it is
20
+ `null` rather than a placeholder when genuinely unknown — a loopback-looking address for an unknown caller
21
+ is the failure this replaced. The gateway also forwards the client port (`x-forwarded-port`, read with
22
+ `context.getClientPort()`) along with host and protocol.
23
+ - **Every socket carries a `socketId`, and only the framework mints one.** `AppWsData` assigns it at the handshake, and a `message` / `pubsub` handler reads it off the `Ws` internal arg — `.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`. Never mint your own from `ws.data`: the room bookkeeping keys on the framework's id, and a second one fails to match it silently. It identifies a **connection**, not a caller — a reconnect gets a new id and the federation gateway's own socket is a different one — so per-user state keys on the account, never on this.
24
+
25
+ - **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it.**
26
+ From a `pubsub` subscribe it belongs to that room — `unsubscribe` runs when the client leaves it or a credential
27
+ change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
28
+ neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
29
+ the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
30
+
31
+ ## Binary Pubsub
32
+
33
+ - **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON `{ type: "pub" }`
34
+ envelope and the base64 a JSON wire would need. The client's subscribe callback receives a `Uint8Array`.
35
+ Nothing else is declared: the return type is the whole switch, and text and binary frames coexist on one
36
+ socket, so every JSON endpoint is untouched. The optimization applies only when the **whole** return is
37
+ `Binary` — `[Binary]`, or a `Binary` inside a model, falls back to base64.
38
+ - **A declared `Binary` room coalesces under backpressure**: while a socket cannot keep up the room keeps only
39
+ its newest frame and drops the rest, which is what a telemetry or video stream wants. Declare
40
+ `pubsub(Binary, { backpressure: "queue" })` when the frames are a sequence a subscriber must see in full, such
41
+ as deltas against a base it already holds; the send buffer then grows with the slowest subscriber. Coalescing
42
+ is keyed by the endpoint, not carried with the frame, so this process, an IPC deliver and a Redis fan-out all
43
+ reach the same answer. Coalesced frames are counted in `pubsubCoalesceCount` on the replica's metrics report.
44
+ - **A `pubsub(Any)` that happens to carry bytes is framed too, and warns once.** `Any` passes a `Buffer` through
45
+ untouched, so the transport sees bytes and sends them whole rather than as a number array — an undeclared
46
+ publisher is fixed rather than left corrupt. It is still not the contract: it queues rather than coalescing
47
+ (nobody declared lossy delivery for that room), and a Redis fan-out still JSON-stringifies it. Declare
48
+ `Binary`.
49
+ - The federation gateway relays a binary frame unchanged, and Bun IPC carries bytes as bytes, so the only hop
50
+ that needed teaching was the socket itself. A cross-server (Redis) fan-out encodes it as protobuf `bytes`.
51
+
52
+ ## Mutation HTTP Verb
53
+
54
+ - A `mutation` is `POST`. `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several verbs
55
+ — a `query` GET and a `mutation` POST on the same custom `path` are mounted side by side. Two endpoints claiming
56
+ the same path **and** verb fail the boot rather than silently shadowing one another.
57
+ - Reach for it only when a foreign wire protocol forces the verb (a client you cannot change that sends
58
+ `PATCH /rest/v1/<table>`). Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
59
+ whatever is declared, so nothing needs restating per caller.
60
+