@owlmeans/viable-common 0.0.33 → 0.0.34

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 (80) hide show
  1. package/README.md +3 -3
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-presentation/SKILL.md +1 -1
  4. package/agent-meta/skills/viable-common/SKILL.md +93 -62
  5. package/build/ba/index.d.ts +0 -1
  6. package/build/ba/index.d.ts.map +1 -1
  7. package/build/ba/index.js +0 -1
  8. package/build/ba/index.js.map +1 -1
  9. package/build/blueprint/consts.d.ts +38 -0
  10. package/build/blueprint/consts.d.ts.map +1 -1
  11. package/build/blueprint/consts.js +40 -0
  12. package/build/blueprint/consts.js.map +1 -1
  13. package/build/blueprint/index.d.ts +0 -3
  14. package/build/blueprint/index.d.ts.map +1 -1
  15. package/build/blueprint/index.js +0 -3
  16. package/build/blueprint/index.js.map +1 -1
  17. package/build/blueprint/types.d.ts +5 -5
  18. package/build/intent/consts.d.ts +57 -0
  19. package/build/intent/consts.d.ts.map +1 -0
  20. package/build/intent/consts.js +58 -0
  21. package/build/intent/consts.js.map +1 -0
  22. package/build/intent/entrypoints.d.ts +27 -0
  23. package/build/intent/entrypoints.d.ts.map +1 -0
  24. package/build/intent/entrypoints.js +28 -0
  25. package/build/intent/entrypoints.js.map +1 -0
  26. package/build/intent/errors.d.ts +20 -0
  27. package/build/intent/errors.d.ts.map +1 -0
  28. package/build/intent/errors.js +28 -0
  29. package/build/intent/errors.js.map +1 -0
  30. package/build/intent/flow.d.ts +17 -0
  31. package/build/intent/flow.d.ts.map +1 -0
  32. package/build/intent/flow.js +65 -0
  33. package/build/intent/flow.js.map +1 -0
  34. package/build/intent/index.d.ts +7 -0
  35. package/build/intent/index.d.ts.map +1 -0
  36. package/build/intent/index.js +6 -0
  37. package/build/intent/index.js.map +1 -0
  38. package/build/intent/schemas.d.ts +10 -0
  39. package/build/intent/schemas.d.ts.map +1 -0
  40. package/build/intent/schemas.js +24 -0
  41. package/build/intent/schemas.js.map +1 -0
  42. package/build/intent/types.d.ts +40 -0
  43. package/build/intent/types.d.ts.map +1 -0
  44. package/build/intent/types.js +2 -0
  45. package/build/intent/types.js.map +1 -0
  46. package/build/skills/consts.d.ts +0 -34
  47. package/build/skills/consts.d.ts.map +1 -1
  48. package/build/skills/consts.js +0 -106
  49. package/build/skills/consts.js.map +1 -1
  50. package/build/skills/index.d.ts +0 -1
  51. package/build/skills/index.d.ts.map +1 -1
  52. package/build/skills/index.js +0 -1
  53. package/build/skills/index.js.map +1 -1
  54. package/build/skills/roles.d.ts +0 -9
  55. package/build/skills/roles.d.ts.map +1 -1
  56. package/build/skills/roles.js +0 -226
  57. package/build/skills/roles.js.map +1 -1
  58. package/package.json +17 -7
  59. package/src/ba/index.ts +0 -1
  60. package/src/blueprint/consts.ts +40 -0
  61. package/src/blueprint/index.ts +0 -3
  62. package/src/blueprint/types.ts +5 -5
  63. package/src/intent/consts.ts +66 -0
  64. package/src/intent/entrypoints.ts +43 -0
  65. package/src/intent/errors.ts +33 -0
  66. package/src/intent/flow.ts +67 -0
  67. package/src/intent/index.ts +6 -0
  68. package/src/intent/schemas.ts +27 -0
  69. package/src/intent/types.ts +45 -0
  70. package/src/skills/consts.ts +0 -112
  71. package/src/skills/index.ts +0 -1
  72. package/src/skills/roles.ts +0 -267
  73. package/tests/blueprint.spec.ts +2 -66
  74. package/tests/intent.spec.ts +121 -0
  75. package/src/ba/schemas.ts +0 -196
  76. package/src/blueprint/case.ts +0 -320
  77. package/src/blueprint/resolve.ts +0 -136
  78. package/src/blueprint/schemas.ts +0 -44
  79. package/src/skills/catalogue.ts +0 -1864
  80. package/tests/skills.spec.ts +0 -125
@@ -1,1864 +0,0 @@
1
- import type { SkillDefinition } from '@owlmeans/llm-common'
2
- import { SKILL_ORDER, ViableSkill } from './consts.js'
3
-
4
- /**
5
- * Every skill body is written for a WEAK model, which means three things throughout:
6
- * state the rule as an imperative, show the wrong form next to the right one, and name
7
- * the exact error the wrong form produces. A model that has seen the error text
8
- * recognises its own output; one that has only seen an abstract rule does not.
9
- *
10
- * Bodies are pure constants — they land in the cached region of the system prompt, and
11
- * one interpolated byte would invalidate the prefix for every call that shares them.
12
- */
13
-
14
- const skill = (alias: ViableSkill, title: string, body: string): SkillDefinition => ({
15
- alias, title, body: body.trim(), order: SKILL_ORDER[alias],
16
- })
17
-
18
- /** The one rendering of a library allow-list — an override must match it byte for byte. */
19
- export const renderLibraries = (list: string[]): string =>
20
- `Use these libraries; do not introduce others:\n${list.map(l => `- ${l}`).join('\n')}`
21
-
22
- const libraries = (alias: ViableSkill, title: string, list: string[]): SkillDefinition =>
23
- skill(alias, title, renderLibraries(list))
24
-
25
- /**
26
- * What a file in the shared package imports. Deliberately just `ajv`: this list is carried by
27
- * `DomainTypesArchitect`/`AccessArchitect` too, and a domain model type file imports nothing
28
- * else. `@owlmeans/entrypoint` and `@owlmeans/route` are named by `OwlMeansEntrypoints`
29
- * instead — the personas that actually author `entrypoints.ts` carry that skill, and naming a
30
- * package pulls its whole published documentation into the prompt.
31
- */
32
- export const commonLibraryList = ['ajv']
33
- export const uiStateLibraryList = [
34
- 'react', '@owlmeans/client', '@owlmeans/state', '@owlmeans/web-client',
35
- ]
36
- export const uiComponentLibraryList = [
37
- 'react', 'tailwindcss', 'shadcn', '@owlmeans/client', '@owlmeans/web-client', 'lucide-react',
38
- // `toast` comes from here, and this list is the last thing a UI persona reads: a package
39
- // missing from it reads as a package that is not installed.
40
- 'sonner',
41
- ]
42
- /**
43
- * Deliberately WITHOUT `drizzle-orm`. This list renders last of all the skills (order 90), so a
44
- * package named here is the final instruction a backend persona reads — and it used to say
45
- * "use drizzle" directly under `ResourceLayer` saying "never import it". `postgres-resource`
46
- * owns drizzle internally (its own dependency; the peers are `pg` and `ajv`), and a target
47
- * reaches the builder through the resource, never through an import. Do not re-add it.
48
- */
49
- export const backendLibraryList = [
50
- '@owlmeans/server-app', '@owlmeans/postgres-resource', 'ajv',
51
- ]
52
- export const fullUiLibraryList = [...new Set([...uiStateLibraryList, ...uiComponentLibraryList])]
53
-
54
- /**
55
- * The backend list plus the two packages that make a model call possible.
56
- *
57
- * Naming them is what pulls their own published documentation into the prompt, so this list
58
- * carries the API contract without this catalogue restating a line of it. It is reachable only
59
- * through a blueprint case that also installs them — a coder told about a package the manifest
60
- * does not declare writes `TS2307`.
61
- */
62
- export const aiLibraryList = [
63
- ...backendLibraryList, '@owlmeans/llm', '@owlmeans/agent',
64
- ]
65
-
66
- /**
67
- * The UI list plus three.js. Scene code and interface code are the same package and the same
68
- * bundle here; what separates them is the component, not the dependency list.
69
- */
70
- export const gameLibraryList = [...new Set([...fullUiLibraryList, 'three'])]
71
-
72
- /**
73
- * How an entrypoint alias is spelled. Shared verbatim by the entrypoints skill and by every
74
- * helper prompt that asks a model to invent one — two renderings would drift, and the pipeline
75
- * splits screen from API access rules on the prefix alone.
76
- */
77
- export const ALIAS_CONVENTION = `
78
- - A screen alias is \`web:<screen-definition>\` — \`web:task-list\`, \`web:task-details\`.
79
- - An AREA alias is \`web:area:<area>\`, and there are exactly four, all already declared:
80
- \`web:area:guest\`, \`web:area:user\`, \`web:area:admin\`, \`web:area:operator\`, referenced as
81
- \`app.web.area.<area>\`. Never invent a fifth, never respell one. There is no
82
- \`web:layout:\` alias and no \`app.web.layout\` — writing either addresses nothing.
83
- - An endpoint alias is \`api:<entity>:<action>\` — \`api:task:list\`, \`api:task:create\`. The
84
- group alias that carries the shared path and guard is \`api:<entity>\` — \`api:task\`.
85
- - Lowercase kebab-case in every segment. The prefix is NOT optional and NOT decorative: access
86
- rules are applied per side by it.
87
- `.trim()
88
-
89
- export const VIABLE_SKILLS: SkillDefinition[] = [
90
-
91
- skill(ViableSkill.ProjectLayout, 'Where files live', `
92
- The generated project is a \`@owlmeans/create-app\` monorepo with five workspace packages under
93
- \`sources/\`. Always prefix a relative path with the package it belongs to:
94
-
95
- - \`sources/common\` — types, entrypoint declarations and the alias tree, shared by every side.
96
- - \`sources/backend\` — the shared backend LIBRARY: the context factory, the config, the
97
- resources, the services and the domain models. It builds with \`tsc -b\` and serves nothing.
98
- - \`sources/api\` — the HTTP server. It builds its context from \`sources/backend\` and holds the
99
- endpoint handlers.
100
- - \`sources/web\` — the react application.
101
- - \`sources/worker\` — the queue consumer. Same context, no HTTP routes, one processor per job.
102
-
103
- These files are the CONTRACT between the public packages. Every screen and endpoint passes through
104
- them; technical worker protocols live in the backend package instead:
105
-
106
- - \`sources/common/src/consts.ts\` — the \`app\` alias tree.
107
- - \`sources/common/src/entrypoints.ts\` — the shared immutable protocol tree (\`appEntrypoints\`).
108
- - \`sources/backend/src/jobs/index.ts\` — backend-only queue protocols and declarations.
109
- - \`sources/api/src/entrypoints.ts\` — server bindings: \`bind\` + one \`handlers<Context>()\` wrap
110
- per handler.
111
- - \`sources/web/src/entrypoints.ts\` — client bindings (\`bindAll\` + \`bindScreen\`).
112
- - \`sources/worker/src/entrypoints.ts\` — job bindings (\`bind\`).
113
- - \`sources/web/src/nav.ts\` — the navigation registry: one line per screen, which
114
- is the only thing that puts it in the menus.
115
-
116
- A file's KIND is its directory, never its name: \`models/task/task.ts\` and \`state/task.ts\` are
117
- both plain \`.ts\` files and differ only in where they sit. The one exception is a view model,
118
- \`<component>.vm.ts\`, because a view and its view model share a directory and a base name.
119
-
120
- The web chrome is the FOUR area layouts in \`sources/web/src/layout/area.tsx\` —
121
- the project's only layouts, already written, never added to. Screens are components under
122
- \`sources/web/src/screens/\`.
123
-
124
- An entity owns exactly ONE directory segment, shared by its shared types
125
- (\`common/src/models/<entity>/\`), its resources (\`backend/src/resources/<entity>/\`), its
126
- backend models (\`backend/src/models/<entity>/\`) and its endpoint handlers
127
- (\`api/src/app/<entity>/\`). Spell it in lowercase kebab-case and spell it
128
- the same way everywhere — the resource alias, and therefore the physical table name, is derived
129
- from that directory. When the task carries a \`NAME REGISTRY\` block it is the authority on
130
- every such name; use its paths verbatim rather than inventing a variant.
131
- `),
132
-
133
- skill(ViableSkill.AgentTooling, 'Working with the project', `
134
- - Start by LISTING the files you need. Never assume the project structure — ask for it.
135
- - Reading a source file and getting empty content means the file does not exist; the path
136
- is almost certainly wrong. Do not create it blindly, look for the real one.
137
- - Writing a file is a separate step. Generating code does not persist it — call the write
138
- tool explicitly for every file you created or changed.
139
- - Resolve the working directory before globbing: a source-list pattern has to be prefixed
140
- with the project root.
141
- - A clean validation is not a working application. \`validate\` type-checks and
142
- \`validate_with_renderer\` builds — neither one loads the page, and the faults that matter most
143
- here are the ones that only happen while a module is being evaluated. Never report a change as
144
- verified because a build came back clean.
145
- `),
146
-
147
- skill(ViableSkill.OutputSourceOnly, 'Output format — source code only', `
148
- Return working TypeScript source and nothing else. No prose before or after it, no
149
- explanation, no markdown code fences.
150
- `),
151
-
152
- skill(ViableSkill.OutputTextOnly, 'Output format — text only', `
153
- Return only the requested text. No commentary, no preamble, no explanation of what you
154
- did, no markdown code fences.
155
- `),
156
-
157
- skill(ViableSkill.MainFlowFocus, 'Main flow focus', `
158
- An application delivers value through ONE straight path, described at its natural length — long
159
- enough to cover every step the flow really has, and no longer. That path is the main flow,
160
- and it is the only thing you describe.
161
-
162
- - The path has one BENEFICIARY — the kind of user who ends up holding the value — and however
163
- many PARTICIPANTS the delivery cannot happen without. A social network needs a creator AND a
164
- reader; a marketplace needs a seller AND a buyer; most applications need nobody besides the
165
- beneficiary. Name each as a kind of user ("operator", "author", "reader"), never a market
166
- segment or an ICP label, and never add a role the value can be delivered without.
167
- - The main flow is 3 to 12 ordered steps, each performed by one named kind of user, running from
168
- the enabling first action to the moment the beneficiary holds the value. The FIRST step is what
169
- someone declares, creates or brings in before the flow can run at all.
170
- - WRONG first step: "the user sees a dashboard of their results". RIGHT first step: "the user
171
- declares the process their leads will pass through". A screen that shows, lists or reports on
172
- something the flow has not produced yet is never the beginning of the flow.
173
- - Cover the WHOLE path, not its opening. Every participant acts in at least one step, at most one
174
- step is about setting things up, and the last step delivers the value. A description that stops
175
- after the setup — a social network where someone publishes but nobody ever reads — has
176
- described no value at all, however detailed its first steps are.
177
- - Never describe branches, alternatives, optional steps, error paths, or anything about signing
178
- up, signing in, roles, permissions, settings, configuration, onboarding, notifications, billing
179
- or administration. They are not the flow. This bans them as STEPS — it does not ban thinking
180
- about them: whether an actor needs an account is always judged, because it is what decides the
181
- area their story belongs to.
182
- - If a step can be dropped and the beneficiary still gets the value, drop it, and spend it on the
183
- part of the path that is still missing. Breadth is one failure mode; a truncated path is the
184
- other, and it is the more common one — a path that never shows the public half of the product,
185
- or stops before anyone consumes what was produced, is truncated however tidy it looks.
186
-
187
- Both bans above are about the FLOW ITSELF — about what may be one of its numbered steps. They are
188
- not a ban on the screens that CONNECT the steps to each other: the list of records waiting for
189
- someone, the directory that finds one of them, the page of a single record, the list of what an
190
- actor submitted. Those are never steps, and you never volunteer one while you are describing the
191
- flow. When a task asks for them IN SO MANY WORDS — handing you the flow steps as finished context
192
- and asking what has to exist BETWEEN them — that task is not asking about the flow, and answering
193
- it is not breadth. Describe them only then.
194
-
195
- The application serves exactly FOUR audiences, called AREAS, and every actor in a story belongs
196
- to one of them:
197
-
198
- - \`guest\` — the actor is NOT signed in: public visitors, prospects, anonymous readers.
199
- - \`user\` — a signed-in end user consuming the product's value; the front office.
200
- - \`operator\` — staff running the product's business process from the inside; the back office.
201
- - \`admin\` — the owner configuring or managing the application itself.
202
-
203
- Decide an actor's area by asking, IN ORDER, and stopping at the first yes: can they do this step
204
- with NO account (\`guest\`) — does the step manage the application itself (\`admin\`) — do they act
205
- on the business process from the inside (\`operator\`) — otherwise \`user\`.
206
-
207
- The authentication question comes first and it outranks who benefits: an actor who needs no
208
- account is \`guest\` even when the product exists for them. A public product therefore normally has
209
- both \`guest\` and \`user\` steps, and a flow whose stories all landed in one area is usually a
210
- misread of that first question.
211
-
212
- A product's own role names — a seller, an author, a reviewer, a reader — are not areas. The same
213
- name can sit in different areas in different steps, so judge the STEP, never the title. An area is
214
- never a step of its own: classifying an actor is not licence to add one, so an \`admin\` or a
215
- moderator still appears only when the flow genuinely needs their action.
216
- `),
217
-
218
- skill(ViableSkill.ScopeDiscipline, 'Scope', `
219
- Implement only what the task describes. Do not add features, files, abstractions, or
220
- error handling for situations the task does not mention.
221
- `),
222
-
223
- skill(ViableSkill.NonTypescriptOutput, 'This file is not TypeScript', `
224
- The file you are asked for is NOT TypeScript — it is CSS, JSON, Markdown or another
225
- format. The TypeScript style and import rules above do not apply to it. Produce the file
226
- in its own language, with no TypeScript syntax and no import statements it does not need.
227
- `),
228
-
229
- skill(ViableSkill.TsStyle, 'TypeScript style', `
230
- - No semicolons at the end of statements.
231
- - Named exports only — never \`export default\`.
232
- - Declare every function before it is used.
233
- - Add an explicit props type to every React component.
234
- - When a value is a free-form dictionary (for example collected Zod errors), type it
235
- explicitly as \`Record<string, any>\`.
236
- - Never import a runtime value with \`import type\` or \`import { type X }\`. Those are for
237
- types and interfaces ONLY. Importing a value that way compiles and then fails at
238
- runtime with "X is not defined" or "does not provide an export named X", because the
239
- import is erased.
240
- - Keep comments short and only for what the code cannot show. Never restate parameter or
241
- return types in a comment — TypeScript already carries them.
242
- - View-model files end in \`.vm.ts\`, not \`.vm.tsx\`: they contain no JSX.
243
- `),
244
-
245
- skill(ViableSkill.TsImports, 'Imports and path aliases', `
246
- - Import shared code as \`project-common/<path>\`, never as \`sources/common/src/<path>\`,
247
- and never with a \`.js\` extension.
248
- - From \`api\` and \`worker\`, reach the shared BACKEND library the same way — as the package, by
249
- its path under \`src\`, with NO extension:
250
- \`import { taskResource } from 'project-backend/resources/task/task'\`.
251
- Both libraries publish \`"./*": "./build/*.js"\`, so \`project-backend/resources/task/task.js\`
252
- looks for \`task.js.js\` and is \`TS2307: Cannot find module\`; \`project-backend/build/…\` doubles
253
- the \`build/\`; and a relative \`../../resources/task/task.js\` stays inside YOUR package, where
254
- no \`resources/\` exists. The \`.js\` suffix belongs ONLY to relative imports inside
255
- \`sources/backend/\` (below) — never to a package specifier.
256
- - Inside a BUNDLED package — \`api\`, \`web\`, \`worker\` — \`@/\` is an alias for that package's own
257
- \`src\`; use it instead of chains of \`../\`.
258
- - **The shared backend library has NO \`@/\` alias.** It is built by \`tsc -b\`, not bundled, and
259
- declares no tsconfig \`paths\`. Inside \`sources/backend/\` every internal import is RELATIVE and
260
- keeps its \`.js\` suffix:
261
- \`import { taskResource } from '../../resources/task/task.js'\`.
262
- \`@/resources/task/task.js\` there is \`TS2307: Cannot find module\`, and changing the suffix does
263
- not help — the alias is what is missing, not the extension.
264
- - \`@/\` is the ONLY alias that exists. If you find yourself writing any other \`@\`-prefixed
265
- import that is not a real npm package, you are inventing a module that is not there;
266
- use a relative path or an existing package instead.
267
- `),
268
-
269
- skill(ViableSkill.ReactComponents, 'React components', `
270
- - Icons come from \`lucide-react\`. If a named icon does not exist in that package, do not
271
- guess another name — inline a small SVG instead.
272
- - Every component declares a props type.
273
- - Do not fetch data directly in a component. A component reads its data and its handlers
274
- from its view-model hook.
275
- - Never use a dynamic import for a component or an icon — in particular never
276
- \`lucide-react/dynamicIconImports\`. Import what you need statically by name.
277
- `),
278
-
279
- skill(ViableSkill.OwlMeansEntrypoints, 'Entrypoints — screens and endpoints', `
280
- Everything this app addresses — a screen the browser renders, an endpoint the server answers
281
- — is an OwlMeans **entrypoint**: declared with \`@owlmeans/entrypoint\` and \`@owlmeans/route\`
282
- in the shared package, bound with \`@owlmeans/client-entrypoint\` in the browser and
283
- \`@owlmeans/server-entrypoint\` on the backend. \`@owlmeans/web-client\` builds the route table from the
284
- entrypoints and renders it over the History API, so there is no router file, no \`<Routes>\` and
285
- no \`app.get(...)\`. No third-party routing or HTTP-server package is installed — importing one
286
- fails to resolve.
287
-
288
- An entrypoint protocol is DECLARED once in the shared package, then BOUND on each side that uses it.
289
-
290
- **1. The alias.** Aliases live in ONE place, the \`app\` tree in
291
- \`sources/common/src/consts.ts\`. NEVER write an alias string inline — always reference
292
- \`app.web.*\` / \`app.api.*\`. An inline string compiles and then silently addresses nothing.
293
-
294
- ${ALIAS_CONVENTION}
295
-
296
- **2. The declaration** — \`sources/common/src/entrypoints.ts\`, one property in the immutable
297
- \`appEntrypoints\` protocol tree:
298
-
299
- import { openProtocol, protocol, contract, typed } from '@owlmeans/entrypoint'
300
- import { route, frontend, RouteMethod } from '@owlmeans/route'
301
-
302
- task: {
303
- list: protocol(route(app.api.task.list, '/list', { parent: app.api.task }),
304
- contract(typed<Task[]>()), { guards: DEFAULT_GUARD }),
305
- }
306
-
307
- - \`route(alias, path)\` is a BACKEND route by default. \`route(alias, path, frontend())\` makes
308
- it a screen; \`frontend({ default: true, parent: app.web.base })\` makes it the index screen.
309
- - Nest with \`{ parent: <group alias> }\` — the child path is APPENDED to the parent's, so the
310
- child path is the tail only (\`'/list'\`, not \`'/tasks/list'\`).
311
- - \`{ method: RouteMethod.POST }\` (from \`@owlmeans/route\`) for anything that is not a GET.
312
- - \`contract.request({ body: typed<T>(TSchema) }, typed<Response>())\` whenever the body's type has a schema beside it in the shared
313
- package — every generated type exports one, named after the type with a \`Schema\` suffix
314
- (\`Task\` → \`TaskSchema\`). Import both from the shared package and use them; that is what makes
315
- the framework reject a malformed request before your handler runs, so the handler never has to
316
- check whether a field arrived. Still never INVENT a schema inline: if the type has none, leave
317
- the endpoint unfiltered rather than writing a literal here that nothing else agrees with.
318
- - Access is declarative: no \`guards\` option = public, \`{ guards: DEFAULT_GUARD }\` = any signed-in user,
319
- \`{ guards: DEFAULT_GUARD, gate: { alias: OIDC_GATE, params: ['<permission>'] } }\` = a permission is required.
320
- \`<permission>\` is a SHAPE, never a value: build the real name from the domain being
321
- implemented — the resource, TWO hyphens, the action, lowercase kebab-case, singular resource.
322
- Do not write a bracketed word into an application, and do not copy a name out of this
323
- document; both produce a gate nobody can pass.
324
- Add \`@<routeParam>\` to scope the check to one record — the name after \`@\` MUST be a \`:\`
325
- segment of that same entrypoint's own path, since one the path does not carry resolves to
326
- nothing and the gate then refuses every request. Guards and gates are INHERITED by children and
327
- enforced by the framework — a handler or a screen never re-checks them.
328
-
329
- **3. The binding.** The protocol declaration alone renders and answers nothing.
330
-
331
- - Server, \`sources/api/src/entrypoints.ts\`:
332
- \`bind(protocols.api.task.list, api.request(protocols.api.task.list, handleTaskList))\`
333
- - Client screen, \`sources/web/src/entrypoints.ts\`:
334
- \`bindScreen(protocols.web.taskList, handler(TaskListScreen))\`
335
- - Client CALL, same file: \`bindAll(protocols.api.task)\`. This is what makes the protocol callable
336
- from the browser. Passing a component to a backend protocol is a hard error, not a warning.
337
-
338
- **A missing client binding is invisible until the app runs.** The whole shared tree is
339
- registered on the browser context, so \`ctx.entrypoint(protocol)\` finds the unbound declaration
340
- and returns it; the call site assumes the protocol is bound, so the build can still be clean. The
341
- only symptom is in the browser:
342
-
343
- TypeError: entrypoint.call is not a function
344
-
345
- That message means EXACTLY one thing — the protocol in that \`ctx.entrypoint(...)\` has no
346
- \`bindAll(protocols.api)\` or specific \`bind(protocols.api.<name>)\` line in
347
- \`sources/web/src/entrypoints.ts\`. Add the binding above the API binding sentinel. It is
348
- never a broken component, never a bad import, and never a reason to rewrite the view model or to
349
- replace the call with \`fetch\`. If the alias is not declared in
350
- \`sources/common/src/entrypoints.ts\` either, declare it there FIRST: binding a protocol the
351
- shared tree does not carry throws while the module is
352
- still loading, which blanks the whole app instead of failing one call.
353
-
354
- **4. An AREA is the PARENT entrypoint.** The chrome of the app — header, navigation, footer —
355
- is never imported by a screen. It comes from an entrypoint of its own, and the screens that use
356
- it are declared as its CHILDREN. There are exactly FOUR areas, they already exist in the
357
- project, and an area contributes chrome AND a URL prefix:
358
-
359
- - \`app.web.area.guest\` at \`/\` — visitors who are not signed in.
360
- - \`app.web.area.user\` at \`/frontoffice\` — signed-in end users; the front office.
361
- - \`app.web.area.admin\` at \`/admin\` — the owner of the application.
362
- - \`app.web.area.operator\` at \`/backoffice\` — staff running the business process; the back office.
363
-
364
- Those four declarations and their bindings are FIXED. NEVER add an area, NEVER change one's
365
- path, NEVER touch its \`guards\`/\`gate\` options, NEVER re-declare one — put the screen in the area
366
- whose access and audience it needs instead. A product's own roles are not areas: they are users
367
- or operators holding different permissions.
368
-
369
- // sources/common/src/entrypoints.ts — a screen: TAIL path, area as parent, no access
370
- taskList: openProtocol(route(app.web.taskList, '/tasks', frontend({ parent: app.web.area.user }))),
371
-
372
- // sources/web/src/entrypoints.ts
373
- bindScreen(protocols.web.taskList, handler(TaskListScreen))
374
-
375
- That screen answers at \`/frontoffice/tasks\`. The path you write is the TAIL ONLY — repeating the
376
- area prefix (\`'/frontoffice/tasks'\`) publishes it at \`/frontoffice/frontoffice/tasks\`, which
377
- nothing in the menu links to and no navigation call reaches.
378
-
379
- The framework passes the matched child to the area as \`children\`, so an area layout is
380
- \`FC<PropsWithChildren>\` and renders \`{children}\` in its content region. A screen therefore
381
- renders ONLY its own content: importing a layout inside a screen renders the header, the menu
382
- and the footer a second time, nested inside the first.
383
-
384
- - \`app.web.base\` stays BOUND with \`stab\` — \`bindScreen(protocols.web.base, stab)\`, no \`handler()\`. It is the
385
- pass-through shell that hosts the four areas and contributes no chrome.
386
- - Every area needs a child declared \`frontend({ default: true, parent: app.web.area.<area> })\`.
387
- An entrypoint with children but no default child matches nothing and renders a BLANK PAGE.
388
- Each area already ships one; never remove it and never add a second.
389
-
390
- **Access is INHERITED from the area.** Guards and gates cascade to children, so choosing the area
391
- IS the access decision and a screen declares none of its own:
392
-
393
- - guest area — no guards option; its screens add nothing.
394
- - user area — \`{ guards: DEFAULT_GUARD }\`; its screens add nothing.
395
- - admin area — \`{ guards: DEFAULT_GUARD, gate: { alias: OIDC_GATE, params: ['project--admin'] } }\`; its screens add
396
- nothing, the marker is the whole rule.
397
- - operator area — \`{ guards: DEFAULT_GUARD, gate: { alias: OIDC_GATE, params: ['project--operator'] } }\`, and EVERY screen under it additionally declares
398
- a \`gate\` option naming the permission THAT screen needs, built from the
399
- screen's own resource and action (add \`@<routeParam>\` when the screen shows one specific
400
- record). This is the ONLY screen that declares access.
401
-
402
- An ENDPOINT has no area to inherit from, so it states its level itself: no \`guards\` option for guest,
403
- \`{ guards: DEFAULT_GUARD }\` for any signed-in user,
404
- \`{ guards: DEFAULT_GUARD, gate: { alias: OIDC_GATE, params: ['<permission>'] } }\` when a permission is required — built
405
- from ITS OWN resource and action — and
406
- \`{ guards: DEFAULT_GUARD, gate: { alias: OIDC_GATE, params: ['project--admin'] } }\` for owner-only.
407
-
408
- \`project--admin\` is the OWNER's marker — holding it passes EVERY gate, so the owner is not a
409
- role inside the app. It is never one of the permissions this app declares for itself, and it is
410
- never granted to an ordinary user. Never hand-write an admin bypass in a handler, never weaken an
411
- existing guard to make something reachable, and never model a "limited admin" with this marker:
412
- that is an ordinary user holding some of the app's own permissions.
413
-
414
- Insert every new line ABOVE the matching \`// owlmeans: add new ... above this line\` sentinel,
415
- one declaration or binding per line. Adding an endpoint touches five files (alias,
416
- protocol, handler, server binding, client binding); a screen touches five too (alias,
417
- protocol, screen component, client binding, navigation entry). Skipping one leaves it
418
- unreachable.
419
-
420
- **Calling an endpoint** from the frontend — \`call()\` resolves to the VALUE, and a non-2xx THROWS.
421
- Use the shared protocol reference; request and response types come from its contract:
422
-
423
- const tasks = await owlCtx.entrypoint(protocols.api.task.list).call()
424
- const task = await owlCtx.entrypoint(protocols.api.task.create)
425
- .call({ body: input })
426
- const task = await owlCtx.entrypoint(protocols.api.task.get)
427
- .call({ params: { taskId } })
428
-
429
- An entrypoint carries three verbs and each answers a different question:
430
-
431
- - \`call(req?)\` — performs the call and resolves to the VALUE. This is the one nearly every
432
- view model wants.
433
- - \`invoke(req?)\` — the same round trip, resolving to \`{ value, outcome }\`, for the rare place
434
- where the OUTCOME decides what happens next.
435
- - \`url(req?, opts?)\` — builds the URL this entrypoint addresses; \`url(req, { absolute: true })\`
436
- for a fully qualified one. A SCREEN entrypoint answers only this verb — calling \`call()\` or
437
- \`invoke()\` on one THROWS.
438
-
439
- Do not import or cast a legacy client-entrypoint type, and do not put a value type argument on
440
- \`entrypoint\`. The protocol reference is the only source of request and response types; a call
441
- site that needs a different shape must fix the shared contract instead of overriding it locally.
442
-
443
- Inside a component or a hook — which is where nearly every call belongs — read the context with
444
- \`useContext()\` from \`@owlmeans/web-client\`. \`owlCtx\` is for module-level code that has no
445
- hook to run in: it is a MODULE EXPORT — \`import { owlCtx } from '@/owlmeans'\` — never a global;
446
- \`window.owlCtx\` and \`(window as any).__owlCtx\` do not exist. NEVER set an
447
- \`Authorization\` header and never read a token — the framework attaches it to every guarded
448
- call. There is no \`fetchApi\` helper and no \`fetch\` call to the backend.
449
- `),
450
-
451
- skill(ViableSkill.OwlMeansNav, 'Navigating between screens', `
452
- Navigation addresses an ALIAS, never a URL. The path an alias resolves to is owned by
453
- \`sources/common/src/entrypoints.ts\`; a hard-coded \`/tasks/\${id}\` desynchronizes silently the
454
- moment the declaration changes.
455
-
456
- import { useNavigate } from '@owlmeans/client'
457
-
458
- const nav = useNavigate()
459
- nav.go(app.web.taskDetails, { params: { taskId } }) // navigate now
460
- <button onClick={nav.press(app.web.taskList)}> // press() RETURNS the handler
461
- nav.back()
462
-
463
- - \`useNavigate\` comes from \`@owlmeans/client\`. It is NOT exported by
464
- \`@owlmeans/web-client\` — importing it from there fails to resolve.
465
- - \`nav.press(alias)\` returns a click handler. Write \`onClick={nav.press(alias)}\`, never
466
- \`onClick={() => nav.press(alias)}\` — the second form navigates nowhere because it only
467
- builds a handler and drops it.
468
- - There is no \`<Link>\` and no \`<Navigate>\` component. Use an \`<a>\` (or a button) with
469
- \`onClick\`.
470
-
471
- Route params are PROPS, not a hook. The framework renders a screen with
472
- \`{ context, params, alias, path }\`, so a screen that needs \`:taskId\` declares it:
473
-
474
- interface TaskDetailsScreenProps { params: { taskId: string } }
475
-
476
- export const TaskDetailsScreen: FC<TaskDetailsScreenProps> = ({ params }) => { ... }
477
-
478
- Never import \`useParams\` — no package the app depends on exports it, and the import fails to
479
- resolve.
480
-
481
- **A screen reaches the MENUS only through the navigation registry** — one line per screen in
482
- \`sources/web/src/nav.ts\`, above the
483
- \`// owlmeans: add new navigation entries above this line\` sentinel:
484
-
485
- { area: 'user', section: 'Tasks', alias: app.web.taskList, label: 'All tasks' },
486
-
487
- - \`area\` is \`'guest' | 'user' | 'admin' | 'operator'\` and must be the SAME area the screen is
488
- declared under in \`sources/common/src/entrypoints.ts\`.
489
- - \`section\` groups screens into the TOP menu (the first level). The screens of the active
490
- section are the SIDE menu (the second level), and a section holding a single screen shows no
491
- side menu at all.
492
- - A section is a LABEL, never a URL segment. Regrouping screens changes the menus and never an
493
- address.
494
- - NEVER hand-write a menu, a \`<nav>\`, a sidebar, a breadcrumb or a list of links in a screen or
495
- a layout. The chrome comes from \`NavLayout\` (\`@owlmeans/web-panel\`), which the four area
496
- layouts already render off this registry.
497
-
498
- A screen with no line here compiles, binds and renders — it is simply reachable by direct URL
499
- only, and nothing reports it.
500
- `),
501
-
502
- skill(ViableSkill.PermissionModel, 'Permissions — the name, the gate and the grant', `
503
- \`enquiry\` below belongs to a DIFFERENT application, used here only to show the shape. Every
504
- permission you write is built from the resource and action of the app you are working on; a name
505
- copied out of this document is a gate nobody can ever pass.
506
-
507
- A permission is ONE string that has to line up in three places:
508
-
509
- 1. the GATE on the protocol — \`gate: { alias: OIDC_GATE, params: ['enquiry--view@enquiryId'] }\`
510
- 2. the permission DEFINITION registered for the project — name \`enquiry--view\`,
511
- resource \`enquiry\`, action \`view\`, resource-scoped \`true\`
512
- 3. the GRANT an administrator makes, which addresses that definition by name.
513
-
514
- **The \`@\` suffix belongs to the GATE ONLY. It is never part of a permission's name.**
515
-
516
- // right — the gate scopes the check; the definition and the grant use the bare name
517
- protocol(
518
- route(app.api.enquiry.get, '/:enquiryId', { parent: app.api.enquiry }),
519
- contract(typed<Enquiry>()),
520
- { guards: DEFAULT_GUARD, gate: { alias: OIDC_GATE, params: ['enquiry--view@enquiryId'] } },
521
- )
522
- // definition: enquiry--view grant: enquiry--view
523
-
524
- // wrong — the suffix carried into the registered or granted NAME
525
- // definition: enquiry--view@enquiryId
526
- // grant: enquiry--view@enquiryId
527
-
528
- The gate splits its parameter at the FIRST \`@\`: what comes before is the permission it looks up,
529
- what comes after says where to read the resource id. So a definition or a grant whose name
530
- contains an \`@\` is a key nothing ever looks up. The symptom is a user who HAS been granted the
531
- permission and still gets 403, with nothing logged. The cause is the \`@\` in the stored name —
532
- never the gate line, which is usually already correct. Repair the definition and the grant; do
533
- NOT delete the \`@\` from the gate to make the two strings match.
534
-
535
- **Repair order matters.** Normalize FIRST, then judge what is left. Normalizing renames a
536
- malformed definition and carries its grants across in the same write; deleting first throws those
537
- grants away, and nothing can reconstruct who held them.
538
-
539
- **The name.** \`<resource>--<action>\`:
540
- - Both halves are names from THIS application's own domain. \`<resource>\`, \`<action>\` and
541
- \`<permission>\` are placeholders describing the shape — writing one of them, or any other
542
- bracketed word, produces a permission no screen can grant and no user can ever hold.
543
- - \`--\` is TWO hyphens. One hyphen is not a separator: \`enquiry-view\` is a resource named
544
- \`enquiry-view\` with no action at all.
545
- - lowercase kebab-case in both segments, singular resource.
546
- - Actions are collapsed: create, update, edit and delete are all \`modify\`; reading one record is
547
- \`view\`; reading many is \`list\`.
548
- - \`project--admin\` is the OWNER's marker. No story ever registers it, and holding it passes every
549
- gate. It is enforced by a decoration wrapped around the gate in \`sources/backend/src/context.ts\`,
550
- NOT by any gate parameter — so its absence from the entrypoint declarations is not evidence that
551
- it is unused, and it must never be deleted as "a definition nothing names".
552
-
553
- **The \`@\` selector.** \`@<routeParam>\`: the name after \`@\` must be a \`:\` segment of THAT
554
- entrypoint's own declared path. \`'enquiry--view@enquiryId'\` on a route declared \`'/enquiries/:id'\`
555
- resolves to nothing, so the gate refuses every request and no error explains why. A permission used
556
- with a selector is resource-scoped: a grant may carry the specific ids it covers, and a grant
557
- carrying no ids at all covers every resource.
558
-
559
- **Every way this goes wrong, and what each one costs.** The application is a clinic in these
560
- examples; yours is not, so read the right-hand column as a shape and build the name from your own
561
- domain.
562
-
563
- // WRONG — a bracketed word from an instruction, written as if it were a name
564
- gate: { alias: OIDC_GATE, params: ['<permission>'] }
565
- gate: { alias: OIDC_GATE, params: ['<resource>--<action>'] }
566
- // RIGHT — this application's own resource and action
567
- gate: { alias: OIDC_GATE, params: ['appointment--modify'] }
568
- // Costs: nothing registers a bracketed name, so the gate refuses every request forever.
569
-
570
- // WRONG — a name copied out of a skill, a comment or another app's example
571
- gate: { alias: OIDC_GATE, params: ['article--modify'] } // in an application that has no articles
572
- // RIGHT — a name that exists in THIS domain
573
- gate: { alias: OIDC_GATE, params: ['appointment--modify'] }
574
- // Costs: the permission is asserted but never declared, so no administrator can grant it.
575
-
576
- // WRONG — the selector carried into the stored name
577
- ensurePermission(entity, client, 'appointment', { permission: 'modify@appointmentId' })
578
- // RIGHT — the selector stays in the gate; the stored name is bare
579
- ensurePermission(entity, client, 'appointment', { permission: 'modify' })
580
- // Costs: a grant lands on a key no gate reads — the screen says granted, the request says 403.
581
-
582
- // WRONG — a selector naming a param this route does not declare
583
- route(app.api.appointment.get, '/:appointmentId')
584
- gate: { alias: OIDC_GATE, params: ['appointment--view@id'] }
585
- // RIGHT — the name after @ is a ":" segment of this very route
586
- gate: { alias: OIDC_GATE, params: ['appointment--view@appointmentId'] }
587
- // Costs: the id resolves to nothing, so the endpoint refuses every request with nothing logged.
588
-
589
- // WRONG — one hyphen
590
- gate: { alias: OIDC_GATE, params: ['appointment-modify'] }
591
- // RIGHT — two
592
- gate: { alias: OIDC_GATE, params: ['appointment--modify'] }
593
- // Costs: it registers as a resource with no action, and never lines up with the real one.
594
-
595
- **Never spell one permission two ways.** Reuse the exact string an existing declaration already
596
- carries. Introducing a second spelling orphans every grant made against the first.
597
- `),
598
-
599
- skill(ViableSkill.ShadcnUi, 'shadcn/ui and Tailwind', `
600
- - The \`cn\` helper is a NAMED export of \`@/lib/utils\` — not \`@/lib/utils/cn\`, which is not a
601
- file and fails to resolve.
602
- - The shadcn primitives are VENDORED into this project at \`@/components/ui/*\`, and
603
- \`@owlmeans/web-panel\`'s own components resolve theirs through that same path — against these
604
- files, not a copy of their own. They are already there: import them, never delete, rename or
605
- move one, and never re-add a primitive the directory already carries.
606
- - Styling is Tailwind utility classes. There are no CSS modules and no styled-components.
607
- - The GROUND is white in light mode and black in dark mode, and it stays that way: every page,
608
- section, band and header sits on \`bg-background\`. Colour comes from ONE product accent —
609
- \`bg-primary\` / \`text-primary\` — and only on the primary button, a section's small label, the rule
610
- over the first step and a check mark. It never comes from a background, a tint or a band.
611
-
612
- <section className="bg-primary/10 py-20">...</section> // WRONG
613
- <section className="bg-gradient-to-b from-amber-50 to-white py-20">...</section> // WRONG
614
- <section className="border-t py-20">...</section> // right
615
-
616
- - Depth comes from exactly three things: a 1px HAIRLINE (\`border\`, the \`--border\` token), the
617
- NEUTRAL TILE (\`bg-muted\` — the only tinted surface: feature tiles, testimonial tiles, result
618
- rows), and ONE shadow, \`shadow-floating\`, which belongs to the one card that floats (the landing
619
- gate). An ordinary card is \`rounded-xl border bg-card\` and carries no shadow. There are no
620
- gradients, no glass, no backdrop blur, no glow and no coloured shadows anywhere in the interface.
621
-
622
- <div className="rounded-xl bg-white/60 shadow-2xl backdrop-blur-md p-6" /> // WRONG
623
- <div className="rounded-xl border bg-card p-6" /> // right
624
- <li className="rounded-[32px] bg-muted p-10" /> // right — a tile
625
-
626
- - TYPE carries the brand. Headings are the theme's heavy grotesk — \`font-extrabold\` with tight
627
- negative tracking (\`tracking-tight\`, or \`tracking-[-0.045em]\` on a section heading) and
628
- \`text-balance\`; paragraphs are \`text-muted-foreground\` with \`text-pretty\` and a readable
629
- measure (\`max-w-[52ch]\`); whitespace is generous. Never set a different font family.
630
- - A section opens with a small LABEL — a plain \`<p className="text-[15px] font-bold text-primary">\`
631
- — then its \`<h2>\`. A label is text: never a chip, a pill or a badge.
632
- - Buttons are pills. The primary one is \`cta-btn\` (on an \`<a>\` or a bare \`<button>\`, never on a
633
- shadcn \`Button\`, whose own \`bg-\` utility would win); the secondary one is
634
- \`rounded-full border-[1.5px] border-foreground text-foreground hover:bg-muted\`. Labels are verbs.
635
- - Motion is small and belongs to the picture: \`reveal\` fades a block in once as it enters, and
636
- \`float-slow\` / \`drift-slow\` lift PARTS OF AN ILLUSTRATION by a few pixels. Nothing spins,
637
- pulses, orbits or scales, and nothing moves for someone who asked for reduced motion.
638
- - The stylesheet still defines the older classes \`gradient-heading\`, \`glass-card\`, \`eyebrow\`,
639
- \`glow-orb\`, \`shadow-soft\`, \`shadow-raised\`, \`shadow-glow\`, \`pulse-soft\` and \`spin-slow\`,
640
- because screens written earlier use them. They are REPAINTED flat — \`gradient-heading\` is solid
641
- ink, \`glass-card\` a neutral tile, \`eyebrow\` a plain accent label, \`glow-orb\` hidden, the three
642
- shadows and the two animations nothing at all. Never reach for one of them as decoration: it
643
- paints nothing, or it paints the flat thing you could have written directly. Do not invent other
644
- classes in the same family.
645
- - Overrides: change any of the look rules above — the ground, the accent, depth, type, motion —
646
- ONLY when the customer's specification explicitly asks: it names a background colour, a
647
- dark-only or coloured theme, a brand palette, or gradients. The product's subject matter is not
648
- a request — a bakery is not a warm background and a bank is not a blue one.
649
- - Colour comes in PAIRS. Every surface token in this theme has a foreground partner —
650
- \`--background\`/\`--foreground\`, \`--card\`/\`--card-foreground\`, \`--primary\`/\`--primary-foreground\`,
651
- and the same for \`secondary\`, \`muted\`, \`accent\`, \`popover\`, \`destructive\` and \`sidebar\`. Painting a
652
- surface is therefore never a single decision: give the element its partner in the SAME
653
- \`className\`, or the text keeps the colour meant for the surface underneath and vanishes wherever
654
- that surface is dark.
655
-
656
- <a className="rounded-full bg-primary px-7 py-3">Get started</a> // WRONG
657
- <a className="rounded-full bg-primary text-primary-foreground px-7 py-3">Get started</a> // right
658
-
659
- Nothing catches the wrong form — it compiles and it renders. It is unreadable only where the
660
- accent happens to be dark, which is why it survives to the finished screen.
661
- - Never paint a surface with a raw or palette colour — \`bg-slate-900\`, \`bg-[#101820]\`,
662
- \`style={{ background: '#101820' }}\`. Those have no foreground partner, so nothing keeps the text
663
- on them legible, and they ignore the theme and its dark mode. Every surface is one of the tokens
664
- above.
665
- - Every interactive element keeps its visible \`focus-visible\` ring, is at least 44px tall to touch,
666
- and never signals anything by hover alone.
667
- - Text that is NOT on a coloured surface takes NO colour class: it already inherits the readable
668
- one. Never add \`text-white\`, \`text-black\` or a \`-foreground\` class "to be safe" — on an ordinary
669
- panel that is the same fault inverted. \`text-muted-foreground\` for secondary text is the
670
- exception, and it is the partner of the standard background.
671
- - Tailwind is version 4: every styling configuration — theme variables, custom utilities,
672
- layers — lives in the css files. There is NO \`tailwind.config.js\` in the project and it is
673
- never required: do not create it, do not look for it, do not reference it.
674
- - A \`SelectItem\` value must never be the empty string. Radix throws
675
- "A <Select.Item /> must have a value prop that is not an empty string" at render time,
676
- because the empty string is reserved for clearing the selection. Model an
677
- empty/unset/"all" option with a sentinel value of \`"__empty__"\` and translate it back to
678
- an empty value in the change handler:
679
-
680
- <SelectItem value="__empty__">Any</SelectItem>
681
- ...
682
- onValueChange={value => onChange(value === '__empty__' ? undefined : value)}
683
- `),
684
-
685
- skill(ViableSkill.FormFeedback, 'Telling the user what happened', `
686
- Every action a user takes says what happened, and a successful one leaves them somewhere sensible.
687
- An action that silently succeeds and an action that silently fails look identical from the other
688
- side of the screen, and the user's only recourse is to do it again.
689
-
690
- import { toast } from 'sonner'
691
-
692
- toast.success('Request submitted')
693
- toast.error('Could not submit the request — please try again')
694
-
695
- - The \`<Toaster />\` is ALREADY MOUNTED by the area layouts. Never import it, never render one in
696
- a screen or a component: a second one renders every message twice.
697
- - ON SUCCESS of a submit, a create, an update or a delete: raise \`toast.success\` with what
698
- happened in a few words, then navigate. Where to is decided in this order:
699
- 1. the destination the user story itself names;
700
- 2. otherwise the SECTION DASHBOARD of the section this screen belongs to — its alias is the
701
- first entry of that section in \`sources/web/src/nav.ts\`.
702
- Leaving the user on a form they have just submitted, with the fields still filled in, reads as
703
- though nothing happened.
704
- - ON FAILURE: raise \`toast.error\` with a message that says what to do about it, and STAY on the
705
- screen with the entered values intact. Never navigate away from a failure.
706
- - The toast belongs in the VIEW MODEL's action handler, beside the call that succeeded or threw —
707
- never in JSX, and never inside a render.
708
- - Never \`alert()\`, never \`window.confirm()\`, never a hand-built floating \`<div>\` announcing an
709
- outcome, and never a bare \`console.error\` as the user-facing report.
710
-
711
- const submit = async (values: RequestInput) => { // right
712
- try {
713
- await model.create(values)
714
- toast.success('Request submitted')
715
- nav.go(app.web.requestsDashboard)
716
- } catch (e) {
717
- toast.error(e instanceof Error ? e.message : 'Could not submit the request')
718
- }
719
- }
720
-
721
- await model.create(values); alert('Saved'); location.href = '/requests' // WRONG
722
- `),
723
-
724
- skill(ViableSkill.OwlmeansState, 'Client state', `
725
- There is NO redux in this project. \`@reduxjs/toolkit\` and \`react-redux\` are NOT installed,
726
- there is no \`store.ts\`, and \`createSlice\`, \`createAsyncThunk\`, \`configureStore\`,
727
- \`useSelector\`, \`useDispatch\`, \`PayloadAction\`, \`RootState\` and \`AppDispatch\` do not exist.
728
- Importing any of them fails to resolve.
729
-
730
- State lives in a state RESOURCE on the OwlMeans context — the same container the entrypoints and
731
- services live in.
732
-
733
- **1. One state module per domain type**, at \`sources/web/src/state/<entity>/<type>.ts\`.
734
- It declares the alias and the hooks that READ it, and nothing else:
735
-
736
- import { useStoreList, useStoreModel } from '@owlmeans/client'
737
- import { useContext } from '@owlmeans/web-client'
738
- import type { Criteria } from '@owlmeans/resource'
739
- import type { Task } from 'project-common/models/task/task'
740
-
741
- export const TASK_STATE = 'task-state'
742
-
743
- export const useTaskResource = () => useContext().getStateResource<Task>(TASK_STATE)
744
- export const useTask = (id?: string) => useStoreModel<Task>(id, TASK_STATE)
745
- export const useTaskList = (query: Criteria<Task> = {}) =>
746
- useStoreList<Task>({ query, resource: TASK_STATE })
747
-
748
- \`useStoreModel\` / \`useStoreList\` come from \`@owlmeans/client\` — \`@owlmeans/web-client\` does
749
- NOT re-export them. \`useContext\` in a STATE module comes from \`@owlmeans/web-client\`, never
750
- from \`@/context.js\`: that module imports this one to register the alias, so importing it back
751
- is a cycle.
752
-
753
- **2. The alias is registered on the context**, one line in \`sources/web/src/context.ts\`
754
- above its sentinel:
755
-
756
- import { TASK_STATE } from '@/state/task/task.js'
757
- ...
758
- appendStateResource<C, T>(context, TASK_STATE)
759
- // owlmeans: add new state resources above this line
760
-
761
- Without that line every hook for the alias throws \`Resource task-state not found\` at runtime,
762
- and no type check can see it.
763
-
764
- **3. Reading.** \`useStoreList({ query })\` is a LIVE list: it re-renders whenever a write changes
765
- which records match, so a screen never recomputes ids and never re-subscribes.
766
-
767
- const open = useTaskList({ status: 'open' }) // StateModel<Task>[]
768
- open.map(model => model.record.title)
769
-
770
- The hook returns the ARRAY itself — there is no \`.list\`, \`.items\`, \`.records\` or \`.data\` on
771
- it, and each element is a MODEL, not a \`Task\`. Where plain records are wanted (a prop typed
772
- \`Task[]\`, a filter, a count), map them out: \`open.map(model => model.record)\`.
773
- An error saying \`StateModel<Task>[]\` is not assignable to \`Task[]\` means exactly that map is missing.
774
-
775
- \`useStoreModel(id)\` is one record — the model always exists so a screen has something to bind
776
- to, but nothing is invented to fill it. \`model.empty\` is what "not loaded yet" looks like:
777
-
778
- const task = useTask(id)
779
- if (task.empty) return <Spinner /> // no record for that id (yet)
780
-
781
- **4. Writing.** A \`StateModel.record\` is a COPY. Assigning to it changes nothing:
782
-
783
- model.record.title = 'renamed' // WRONG — silent no-op, nothing re-renders
784
- model.update({ title: 'renamed' }) // RIGHT — merges and commits
785
- model.clear() // deletes the record
786
-
787
- Through the resource: \`await tasks.save(record)\` creates-or-replaces, \`tasks.delete(id)\`
788
- removes, \`tasks.list(criteria)\` returns \`{ items, total }\` and \`tasks.count(criteria)\` the
789
- number alone.
790
-
791
- When an endpoint answers with a WHOLE set — every task of a project, the session's items — install
792
- it in one step:
793
-
794
- await tasks.replace(fromServer) // the store now holds exactly these
795
-
796
- \`replace\` is what makes the store agree with the server: a record deleted elsewhere leaves in the
797
- same write, and the subscribers wake once instead of once per record. A loop of \`save\` calls only
798
- ever ADDS, so anything stale stays visible forever.
799
-
800
- **5. Criteria** are the same language the backend resources take: a bare value is equality, a
801
- bare ARRAY means "any of these", plus \`$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like
802
- $ilike $regex $startsWith $endsWith $between\` and \`$and\` / \`$or\` / \`$not\`. A dotted key
803
- reaches into the record. A value of \`undefined\` is skipped, so an untouched filter does not
804
- empty the list.
805
- `),
806
-
807
- skill(ViableSkill.StoreAccess, 'Reading and writing the store', `
808
- - The store is NOT granular: it holds whole records. There is no method per field, and adding
809
- one is not how a single property is changed — \`model.update({ field })\` merges.
810
- - A subscribed record is reached through \`model.record\`, never as the model itself:
811
- \`task.record.title\`, not \`task.title\`.
812
- - A record may not have arrived yet, and nothing invents one to stand in. \`model.empty\` says so;
813
- guard on it before rendering, and guard before reading THROUGH the record —
814
- \`model.record?.items?.[id]?.name\` — rather than assuming the shape is populated.
815
- - A list read is \`model.list()\` on the resource, returning \`{ items, total }\` — take the count
816
- from \`total\`, and \`count(criteria)\` when the number is all that is wanted.
817
- - The store carries RECORDS, not request status. Loading flags and error messages are ordinary
818
- \`useState\` in the view model; a field named \`isLoading\` or \`error\` does not belong in a
819
- stored record unless the domain type genuinely has one.
820
- `),
821
-
822
- skill(ViableSkill.ViewModelNaming, 'View-model naming convention', `
823
- A component's view logic lives in its sibling \`.vm.ts\` file. Derive its public
824
- symbols from the component name (PascalCase of the component definition — for example
825
- "main-task-list" becomes "MainTaskList"):
826
-
827
- - The hook is \`use<ComponentName>ViewModel\` (\`useMainTaskListViewModel\`).
828
- - Its options/props type, when it takes arguments, is \`<ComponentName>ViewModelOptions\`.
829
- - The primary loader the hook returns is \`load<ComponentName>\`; a refresh action is
830
- \`reload<ComponentName>\`.
831
- - Action handlers are verb-first camelCase (\`createTask\`, \`removeTask\`).
832
-
833
- Both sides must use these exact names. This is a DEFAULT: when a "View-model contract"
834
- block is present in the task, it lists the symbols that actually exist and overrides this
835
- convention entirely.
836
- `),
837
-
838
- skill(ViableSkill.LandingGate, 'The landing gate — from the landing page into the app', `
839
- A product can let a guest START its key end-user workflow on the landing page, before any account
840
- exists, and finish it in the app after signing in with the choices they made carried over. The
841
- card that does this is the LANDING GATE. It sits in the hero where the call-to-action buttons
842
- would be, and it REPLACES them.
843
-
844
- **Which story is gate-worthy.** At most ONE per project, and only a story that is all of:
845
- - in the \`user\` area — the signed-in END user's own workflow, never an operator's, an owner's or
846
- one a guest already completes without signing in;
847
- - a FIRST-VALUE step — something useful comes out of it the moment the guest acts;
848
- - BEGUN with NON-SENSITIVE input — a few picks, a filter, a short text. Its FIRST input is never a
849
- payment, an address, an upload, contact details or anything else about the person; later steps
850
- may need those, because they happen after signing in.
851
- A pantry-to-recipe finder is gate-worthy: pick ingredients, see what you can bake. So is writing a
852
- post with photos, begun by picking what it is about; checking out a basket, setting up a profile or
853
- reviewing a queue of requests is not. Nearly every product whose end user acts for themselves has
854
- such a step: when none of its stories is phrased that way, the analyst WRITES that story for the
855
- gate, and a product without a gate is the exception.
856
-
857
- **The card.** The sketch is \`@/components/home/gate\`; the implemented one is
858
- \`@/components/home/landing-gate\` and exports the same \`LandingGate\` with the same props.
859
- - Its root is \`<div id="gate" data-home-gate>\` — never a \`<section>\`, \`<aside>\`, \`<header>\`
860
- or \`<footer>\`; the page already has exactly one of each landmark it needs.
861
- - A question in the end user's words as its heading, and a "No account needed" hint beside it.
862
- - 5 to 8 choice chips, each a \`<button type="button" aria-pressed={selected}>\` inside a
863
- \`role="group"\` with an \`aria-label\`, 4 or 5 pre-selected so the first view already shows
864
- results. When choices do not fit, 1 to 3 labelled fields instead.
865
- - A count with \`aria-live="polite"\`, at most 2 result rows re-ranked on every change, and one
866
- line for when nothing matches. A row's lock icon is named "Full details after sign-in".
867
- - A one-line note ("Sign in with your email. Your picks come with you.") and ONE primary button,
868
- straight after the choices in tab order. No second button beside it.
869
- - The sketch works signed out and offline from its sample records. The implemented card may call
870
- a PUBLIC endpoint; never one that needs a signed-in user.
871
-
872
- **The handoff.** The button carries the guest's choices across sign-in in \`sessionStorage\`,
873
- through \`@/lib/handoff\` — never in the URL, never in a cookie, never through the server:
874
-
875
- import { useLandingStart } from '@/lib/handoff'
876
-
877
- const start = useLandingStart(target)
878
- <button type="button" className="cta-btn" onClick={() => start({ picks })}>{cta}</button>
879
-
880
- - \`target\` is the ALIAS of the story's own full-scale screen in the user area — the prop the guest
881
- home passes — never a URL.
882
- - \`useLandingStart(target)\` is the whole button: it writes the handoff, then navigates to
883
- \`target\` when the visitor is already signed in, or starts the ordinary sign-in otherwise. After
884
- sign-in the guest home's \`Hero\` (\`useLandingContinuation\`) sends the visitor on to \`target\`.
885
- - NEVER \`useLogin(target)\` for the gate: its continuation navigates straight to the guarded screen,
886
- which answers a signed-out visitor with "Sign in required", and that card's own sign-in lands on the
887
- home page — the full-scale screen is never reached.
888
- - The handler \`useLandingStart\` returns is SYNCHRONOUS, and the click calls it in the same gesture.
889
- A sign-in that has to open a window is eaten by the popup blocker once anything has been awaited.
890
-
891
- onClick={async () => { await saveDraft(picks); start({ picks }) }} // WRONG — the gesture is gone
892
- href={'/frontoffice/recipes?picks=' + picks.join(',')} // WRONG — a URL, the picks in it, no sign-in
893
-
894
- **The full-scale screen** — the story's own screen in the user area — reads the handoff once in its
895
- view model, pre-fills from it, says so in one line, and clears it:
896
-
897
- import { clearLandingHandoff, readLandingHandoff } from '@/lib/handoff'
898
-
899
- useEffect(() => {
900
- const carried = readLandingHandoff<{ picks: string[] }>(alias)
901
- if (carried == null) return
902
- setPicks(carried.picks)
903
- setCarriedOver(true) // "We kept your 4 picks from the home page."
904
- clearLandingHandoff()
905
- }, [alias])
906
-
907
- \`alias\` is the screen's own alias — the same string the gate was given as \`target\`. A missing,
908
- expired or foreign handoff reads as \`null\`, and the screen then starts empty exactly as it does for
909
- someone who arrived from the menu; never treat its absence as an error.
910
-
911
- **Never:**
912
- - ask for an account, a payment, an email address or any personal detail before sign-in — the gate
913
- collects choices and nothing else, and the handoff stores only those choices;
914
- - render a story code, a "Preview" badge or a dashed frame inside the gate — it is the product's
915
- public face in production;
916
- - keep the hero's buttons beside a gate, or give the gate a second call to action.
917
- `),
918
-
919
- skill(ViableSkill.ResourceLayer, 'OwlMeans resources own the schema', `
920
- A "resource" is ONE file — \`src/resources/{entity}/{type}.ts\` — carrying all data access
921
- for one domain entity: its AJV schema, its \`makePostgresResource()\` maker, and an accessor
922
- that returns the instance already registered on the OwlMeans context.
923
-
924
- \`src/resources\` sits directly under \`src\`, not under \`models\`.
925
-
926
- THE RESOURCE OWNS THE DDL. You never define a table. The AJV schema you write on the
927
- resource IS the table: the OwlMeans layer creates it, and on every start reconciles an
928
- existing table against the schema — adding, retyping and dropping columns and reconciling
929
- indexes. A resource whose schema changed simply converges the next time the backend starts.
930
-
931
- Never do any of the following — each one is the removed Drizzle-DDL layout. Some of these
932
- imports still RESOLVE, because the framework uses the driver internally, so a clean build
933
- proves nothing: the table you define becomes a SECOND owner of one the resource layer
934
- already owns, and the next start reconciles yours away.
935
-
936
- - \`import ... from 'drizzle-orm'\` or \`'drizzle-orm/pg-core'\`. \`drizzle-orm\` is NOT a
937
- dependency of this project. If you ever truly need the builder, the resource hands it to
938
- you — see the resource-method rules below.
939
- - \`import ... from 'postgres'\`, \`'pg'\`, \`'@/lib/db.js'\` or \`'@/db/schema/...'\`. \`pg\` is
940
- installed only because the framework's driver requires it; it is never yours to import.
941
- - calling \`drizzle()\`, \`pgSchema()\` or \`pgTable()\` yourself.
942
- - writing a table definition, a migration file, a \`drizzle.config.ts\`, or a \`src/db\`
943
- directory. There is no \`db:generate\` and no \`db:migrate\`.
944
-
945
- A foreign key is declared by the referenced resource's ALIAS, never by importing another
946
- file:
947
-
948
- pg: { references: { resource: 'other-alias' } }
949
-
950
- The referencing property has EXACTLY the type and format of the referenced resource's \`id\`. An
951
- \`id\` declared \`{ type: 'string', format: 'uuid' }\` is a \`uuid\` column, and an \`id\` left
952
- undeclared is \`text\` — so copy the referenced \`id\`'s declaration onto the reference:
953
-
954
- bakeId: { type: 'string', format: 'uuid', pg: { references: { resource: 'bake' } } }
955
-
956
- A plain \`{ type: 'string' }\` pointing at a \`uuid\` id compiles, and then the backend refuses to
957
- start: \`42804 foreign key constraint … cannot be implemented — Key columns are of incompatible
958
- types\`.
959
-
960
- If the referenced resource does not exist, use a plain string property instead.
961
- `),
962
-
963
- skill(ViableSkill.ResourceResults, 'How resource methods are called, and what they return', `
964
- Reach data through the resource's accessor and call the resource's own methods. Resources live
965
- in the shared backend library: from \`api\` and \`worker\` import the accessor as
966
- \`project-backend/resources/{entity}/{type}\` (no \`.js\`); inside \`sources/backend/\` import it
967
- relatively, with \`.js\` (\`../../resources/{entity}/{type}.js\`). Never write raw SQL in a route
968
- handler, never import a database connection, and never construct a resource yourself.
969
-
970
- A WRITE takes ONE argument — the record — and the id travels INSIDE it. There is no
971
- \`(id, changes)\` overload on any of them:
972
-
973
- await resource.create(payload) // refuses a caller-supplied id
974
- await resource.update({ ...current, ...changes }) // replaces the WHOLE record
975
- await resource.patch({ id: current.id, progress }) // merges only the listed fields
976
- await resource.save(record) // create-or-replace
977
-
978
- THE DATABASE ASSIGNS THE ID. \`id\` is a generated column — the table is created with a
979
- \`gen_random_uuid()\` default — so a NEW record does not have one yet and it is not yours to
980
- invent. NEVER generate an id in application code: no \`randomUUID()\`, no \`crypto.randomUUID()\`,
981
- no \`uuid()\`, no counter, no \`Date.now()\` string, and never \`import { randomUUID } from
982
- 'crypto'\` for this. Build the record WITHOUT an \`id\` key at all and read the id off the record
983
- \`create\` HANDS BACK:
984
-
985
- const created = await resource.create({ title, ownerId }) // no id in the payload
986
- return created.id // the id the database assigned
987
-
988
- \`create\` REJECTS a record that carries an \`id\` — including one you just generated — by
989
- throwing \`resource:record-exists:id-present\`. Despite its name that error does NOT mean a
990
- duplicate record was found and it is NOT a race, a double submit or a uniqueness collision: it
991
- means the payload had an id in it. The only fix is to stop putting one there. Do not "repair" it
992
- with a retry, an in-flight guard, an idempotency check, or a \`load(id)\`-then-return-existing
993
- branch — none of those touch the cause, and looking up an id you just minted can only ever miss.
994
-
995
- When you genuinely have an id already — the record exists and you are writing it back — that is
996
- \`update\`/\`patch\`/\`save\`, never \`create\`. A record type declaring \`id: string\` as required
997
- describes a STORED record; the create payload is that type without its id (\`Omit<T, 'id'>\`).
998
-
999
- Passing an id as a WRITE's first argument fails to compile with \`error TS2559: Type 'string'
1000
- has no properties in common with type 'Partial<...>'\` — a bare id is a READ's argument, never
1001
- a write's. Load the record first when you only have its id, then write the merged object back
1002
- — or use \`patch\`.
1003
-
1004
- A READ takes either an ID or a CRITERIA OBJECT as its first argument. There is no
1005
- "value plus field name" overload — the field is a key of the object:
1006
-
1007
- await resource.load(id) // the record, or null
1008
- await resource.load({ projectId }) // by any other field, or several at once
1009
- await resource.get(id) // throws UnknownRecordError instead of null
1010
- await resource.get({ ownerId, slug })
1011
- await resource.list({ status: 'open' }) // { items, total, page?, size? }
1012
- await resource.count({ status: 'open' }) // number
1013
-
1014
- Writing \`load(projectId, 'projectId')\` fails to compile — the second parameter is
1015
- \`{ sort }\`, not a field name. Fetching ONE record is \`load\`/\`get\`, never a \`list\` whose
1016
- first element you take: \`const { items: [x] } = await resource.list({ a, b })\` is
1017
- \`const x = await resource.load({ a, b })\`.
1018
-
1019
- Inside the criteria object a bare value means equality, a bare ARRAY means "any of these"
1020
- (\`{ status: ['open', 'held'] }\`), \`null\` asks for IS NULL, and \`undefined\` is SKIPPED —
1021
- so an untouched filter cannot empty a list. A field may instead carry an operator object —
1022
- \`$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like $ilike $regex $startsWith $endsWith
1023
- $between $contains $contained $overlaps\` — and \`$and\` / \`$or\` / \`$not\` combine whole
1024
- criteria:
1025
-
1026
- await resource.list({ createdAt: { $gte: since }, $or: [{ ownerId }, { shared: true }] })
1027
-
1028
- \`delete(id)\` returns the removed record or \`null\`. \`take(id)\` DELETES the record it hands
1029
- back and throws \`UnknownRecordError\` when there is none — it is never a way to read one; use
1030
- \`load\`/\`get\` for that. \`purge(where)\` deletes every match and returns how many went, so
1031
- clearing a set is one call and never a page loop around \`delete\`; it refuses an empty \`{}\`
1032
- rather than emptying the table.
1033
-
1034
- These methods return the RECORDS THEMSELVES, never a driver result object. There is NO
1035
- \`rowsAffected\`, \`affectedRows\`, \`changes\` or \`rowCount\` anywhere — reading one
1036
- yields \`undefined\`, which then silently compares as false. Use \`count(where)\` for a
1037
- number and \`await resource.load(id) != null\` for an existence check.
1038
-
1039
- PAGING AND SORT ARE FLAT IN THE SECOND ARGUMENT of \`list\`. There is no \`limit\`, no
1040
- \`offset\`, and no \`pager\` or \`criteria\` wrapper — those two names do not exist:
1041
-
1042
- resource.list({ status: 'open' }, { page: 0, size: 20, sort: ['createdAt'] })
1043
-
1044
- - \`sort\` is an array of field names (ascending) or \`{ field, order: 'desc' }\` objects.
1045
- - Writing \`list({}, { page, limit: 10 })\` fails with \`'limit' does not exist in type
1046
- 'ListOptions<...>'\`, and nesting the same pair under \`pager\` only moves the error to
1047
- \`'pager' does not exist in type 'ListOptions<...>'\`. The declared name is \`size\`.
1048
- - The backend pages by itself: Postgres returns at most \`DEFAULT_PAGE_SIZE\` (100) rows when
1049
- \`size\` is absent, so a listing that genuinely needs every match asks for
1050
- \`{ size: 0 }\` — explicitly, once, where a reader can see it.
1051
- - The result is \`{ items, total, page?, size? }\`. \`total\` is the full count of matching
1052
- records, never \`items.length\` — build pagination UI from it, and never recompute it.
1053
-
1054
- For a join or aggregate the CRUD surface cannot express, use \`resource.select()\`/\`query()\`
1055
- with \`{{}}\` for this resource's table and \`{{other-alias}}\` for another registered
1056
- resource's.
1057
-
1058
- A Drizzle handle exists as a LAST RESORT, and only through the resource:
1059
- \`(await resource.db()).drizzle\` over \`resource.entity\`, reached with NO import. Nothing on
1060
- that path is typed — \`resource.entity\` is \`Record<string, any>\` — and the moment the query
1061
- needs \`eq\`, \`and\` or \`sql\` it needs an import you may not write: that query goes in
1062
- \`resource.query()\` instead. Read \`resource.entity\` inside a function, never at module
1063
- scope; it is built during \`init()\` and is \`undefined\` before then.
1064
- `),
1065
-
1066
- skill(ViableSkill.ResourceMigrations, 'Data migrations', `
1067
- Schema shape needs no migration — reconciliation handles it. Only a DATA change
1068
- reconciliation cannot make on its own (a backfill, a value rewrite) needs one, and it is
1069
- registered on the resource as code, inside the maker, beside \`resource.index(...)\`. The
1070
- signature is \`resource.migration(name, fn, stage)\`:
1071
-
1072
- resource.migration(name, async tx => { ... }, stage)
1073
-
1074
- Registering is all there is to do: the resource runs its pending migrations itself during
1075
- \`init()\`, so nothing ever calls one. The call returns the resource, so several chain, and
1076
- re-registering the same name with the same body is a no-op — which is what makes it safe in a
1077
- maker that runs more than once.
1078
-
1079
- Each one is applied once and recorded under its name and a checksum of its body. Editing a
1080
- migration that has already run throws \`resource:migration-conflict\` at the next boot; a new
1081
- fact needs a NEW migration under a new name. A migration that throws aborts the boot with
1082
- \`resource:migration-failed\` rather than leaving the app on a half-shaped database.
1083
- `),
1084
-
1085
- skill(ViableSkill.OwlmeansContext, 'The OwlMeans context', `
1086
- The context is the application's container. It holds exactly two kinds of thing: RESOURCES (data)
1087
- and SERVICES. Nothing else lives on it.
1088
-
1089
- **The context is built by a LIBRARY and there is no module-level \`owlCtx\` on the server side.**
1090
- \`makeBackendContext\` lives in \`sources/backend/src/context.ts\`, and the api and the worker each
1091
- build their own from it — so a module in \`sources/backend\` that imported a container singleton
1092
- would bind whichever process happened to load it first. The browser is the exception: the web app
1093
- IS one process, and \`sources/web/src/owlmeans.ts\` exports \`owlCtx\` for module-level code.
1094
-
1095
- **Data comes from the resource accessor, and the accessor takes the context.** Every handler and
1096
- every job processor is handed its own; pass it down.
1097
-
1098
- import { taskResource } from 'project-backend/resources/task/task'
1099
- const { items } = await taskResource(ctx).list({ status: 'open' })
1100
-
1101
- **Business logic in \`src/models/**\` is PLAIN EXPORTED FUNCTIONS. It is never a service.**
1102
- Never write this:
1103
-
1104
- // WRONG — this object is not a service, nothing registers it, and the lookup throws
1105
- export const DASHBOARD_SERVICE = 'dashboard-activity'
1106
- export const makeDashboardService = (ctx) => ({
1107
- alias: DASHBOARD_SERVICE, registerContext: ..., assertCtx: ..., initialized: true,
1108
- getSummary: async () => ...
1109
- })
1110
-
1111
- Write this instead, and let the route handler import the function directly:
1112
-
1113
- // RIGHT — sources/backend/src/models/dashboard/activity.ts
1114
- export const getSummary = async (ctx: BasicContext<BasicConfig>, guestId: string) => { ... }
1115
-
1116
- // RIGHT — sources/api/src/app/dashboard/activity.ts
1117
- import { getSummary } from 'project-backend/models/dashboard/activity'
1118
- const summary = await getSummary(ctx, guestId)
1119
-
1120
- Do not create a service just to hold functions.
1121
-
1122
- **A resource is NEVER reached through \`service()\`.** Resources and services are two different
1123
- registries. Asking for a resource alias on the service registry throws even though the resource
1124
- exists and is registered:
1125
-
1126
- // WRONG — 'reservation' is a RESOURCE; this throws
1127
- // SyntaxError: Service reservation not found
1128
- const reservations = ctx.service<PostgresResource<any>>('reservation')
1129
-
1130
- // RIGHT — import the accessor the resource file exports, and hand it the context
1131
- import { reservationResource } from 'project-backend/resources/reservation/reservation'
1132
- const { items } = await reservationResource(ctx).list({ status: 'open' })
1133
-
1134
- A function in \`src/models/**\` takes the context first and its own domain arguments after.
1135
-
1136
- **\`ctx.service(alias)\` THROWS when the alias is unknown** — it does NOT return
1137
- \`undefined\`:
1138
-
1139
- SyntaxError: Service dashboard-activity not found
1140
-
1141
- So a null check around it is dead code that never runs:
1142
-
1143
- // WRONG — the throw already happened on the line above
1144
- const svc = ctx.service(ALIAS)
1145
- if (!svc) { throw { status: 503 } }
1146
-
1147
- \`ctx.resource(alias)\` and \`ctx.entrypoint(protocol)\` throw the same way
1148
- (\`Resource X not found\`, \`Entrypoint X not found\`).
1149
-
1150
- **Only when a real lifecycle-owning singleton is needed** — a client holding a connection, a
1151
- cache, a poller. Business logic never qualifies, and neither does "the handler needs to call
1152
- this": export a function. When it genuinely does qualify, put it in
1153
- \`sources/backend/src/services/{name}.ts\`, one service per file,
1154
- flat in that directory, built with \`createService\` and never as a hand-written object:
1155
-
1156
- import { createService } from '@owlmeans/context'
1157
- import type { InitializedService } from '@owlmeans/context'
1158
- import type { BasicConfig, BasicContext } from '@owlmeans/context'
1159
-
1160
- export const SERVICE_ALIAS = 'dashboard-activity'
1161
-
1162
- export interface DashboardActivityService extends InitializedService {
1163
- getSummary: (guestId: string) => Promise<Summary | null>
1164
- }
1165
-
1166
- // The maker name is fixed — the generated service registry imports exactly this symbol.
1167
- export const makeService = (): DashboardActivityService =>
1168
- createService<DashboardActivityService>(SERVICE_ALIAS, {
1169
- getSummary: async guestId => { ... }
1170
- })
1171
-
1172
- /** The registered instance — import this from API handlers and job processors. */
1173
- export const dashboardActivityService = (ctx: BasicContext<BasicConfig>): DashboardActivityService =>
1174
- ctx.service<DashboardActivityService>(SERVICE_ALIAS)
1175
-
1176
- Never call \`registerService()\` yourself and never edit \`src/services/index.ts\`: that registry
1177
- is generated from the directory, and the context factory registers everything in it before
1178
- \`init()\` runs.
1179
-
1180
- **Never resolve anything at module scope.** Module bodies run before the context is initialized,
1181
- so a top-level \`const svc = ctx.service(X)\` throws while the file is being imported and kills
1182
- the process before it can listen. Resolve inside the function that uses it.
1183
- `),
1184
-
1185
- skill(ViableSkill.OwlMeansServices, 'Services vs entrypoints — two registries', `
1186
- This project has TWO registries of names. They share nothing, and confusing them is the single
1187
- most expensive mistake that still compiles.
1188
-
1189
- **SERVICES** are the two sides of the application as NETWORK ENDPOINTS — a host, a port, a base
1190
- path. They live in \`cfg.services\`, and there are exactly two:
1191
-
1192
- - \`APP_WEB\` — the frontend
1193
- - \`APP_API\` — the backend
1194
-
1195
- Both are declared in \`sources/common/src/consts.ts\` and registered by a \`service({ … })\` /
1196
- \`sservice({ … })\` call in \`sources/web/src/config.ts\` and
1197
- \`sources/api/src/owlmeans.ts\`. **\`serviceRoute()\` reads this registry and nothing else.**
1198
-
1199
- **ENTRYPOINTS** are the \`app.web.*\` and \`app.api.*\` tree — screens and endpoints, i.e. ROUTES.
1200
- \`ctx.entrypoint()\` reads that one.
1201
-
1202
- // RIGHT — sources/web/src/owlmeans.ts, exactly as the project ships
1203
- import { APP_API, APP_WEB } from 'project-common/consts'
1204
- owlCtx.serviceRoute(APP_WEB, true)
1205
- owlCtx.serviceRoute(APP_API, true)
1206
-
1207
- // WRONG — every one of these type-checks, builds clean, and blanks the whole application
1208
- owlCtx.serviceRoute(app.web.base, true)
1209
- owlCtx.serviceRoute(app.web.area.guest, true)
1210
- owlCtx.serviceRoute(app.api.post.base, true)
1211
-
1212
- Both sides are strings, so nothing in TypeScript can tell them apart. The failure appears only
1213
- in the browser, while the module is still being imported:
1214
-
1215
- Uncaught SyntaxError: Service not found web:base
1216
-
1217
- The alias in that message is not a key of \`cfg.services\`. Pass one of the SERVICE constants
1218
- above. Do NOT substitute a different \`app.*\` value: the error simply returns wearing the new
1219
- name, which is how one project spent twenty repair attempts on one line — every one of them
1220
- reporting success.
1221
-
1222
- **\`config.ts\` and \`owlmeans.ts\` on both sides are framework wiring this project was generated
1223
- with.** They carry no sentinel and no application code, nothing in the pipeline authors them, and
1224
- the write tools refuse them. If one of them is the cause, call \`restore_wiring_file\` to put back
1225
- the version the project was generated from — never re-derive it, and never "fix" it by changing
1226
- which name it uses. A service alias is renamed in \`sources/common/src/consts.ts\` and nowhere
1227
- else, and both sides must keep using the same constant or the frontend calls a backend that
1228
- publishes itself under another name.
1229
- `),
1230
-
1231
- skill(ViableSkill.OwlMeansServer, 'Writing an endpoint handler', `
1232
- There is NO express here. \`express\`, \`cors\` and \`@types/express\` are not installed, there is
1233
- no \`app.get(...)\`, no \`req\`/\`res\` of a web server, no \`next\`, and no middleware. Anything
1234
- written for express fails to resolve.
1235
-
1236
- A handler is a PLAIN exported async function that RETURNS its result — named exports only, one
1237
- file per entity: \`sources/api/src/app/<entity>/<action>.ts\`. It NEVER imports \`handlers\` and
1238
- NEVER calls \`handlers<Context>()\`; that belongs only in the file that BINDS it:
1239
-
1240
- // sources/api/src/app/task/create.ts — the handler module
1241
- import type { Task } from 'project-common/models/task/task'
1242
- export const handleTaskCreate = async (payload: Task, ctx: Context) => {
1243
- const tasks = getTaskResource(ctx)
1244
- return await tasks.create(payload)
1245
- }
1246
-
1247
- sources/api/src/entrypoints.ts wraps that export EXACTLY ONCE, choosing the helper by what the
1248
- protocol carries — the request BODY → \`api.body\`, route PARAMS (the ':' segments of the declared
1249
- path) → \`api.params\`, anything else (query, headers, nothing at all) → \`api.request\`:
1250
-
1251
- import { handlers } from '@owlmeans/server-api'
1252
- import { bind } from '@owlmeans/server-entrypoint'
1253
- import { protocols } from 'project-common/entrypoints'
1254
- import { handleTaskCreate } from '@/app/task/create.js'
1255
- const api = handlers<Context>()
1256
-
1257
- export const entrypoints = [
1258
- bind(protocols.api.task.create, api.body(protocols.api.task.create, handleTaskCreate)),
1259
- ]
1260
-
1261
- WRONG — the handler module ALSO calls \`handlers()\` and wraps itself, so the export bound above is
1262
- wrapped a SECOND time. \`tsc\` catches it: \`TS2345 "Argument of type 'BoundEntrypointHandler<…>' is
1263
- not assignable"\`. Left unfixed, the build stays clean anyway if nothing runs \`tsc\` on this
1264
- package, and every request to the route then fails with \`TypeError: handler is not a function\`
1265
- (\`execute is not a function\` for \`.request\`) — an endpoint that "completed" and answers nothing:
1266
-
1267
- // WRONG — sources/api/src/app/task/create.ts
1268
- const api = handlers<Context>()
1269
- export const handleTaskCreate = api.body(protocols.api.task.create, async (payload, ctx) => {
1270
- const tasks = getTaskResource(ctx)
1271
- return await tasks.create(payload)
1272
- })
1273
-
1274
- - The SECOND argument is the OwlMeans context. Reach every resource through it — never
1275
- import a database connection and never write raw SQL in a handler.
1276
- - RETURN the value. \`res.json(...)\`, \`res.status(...)\`, \`res.send(...)\` do not exist; a
1277
- returned value IS the 200 response body.
1278
- - Do not wrap the body in try/catch to convert an error into a response. A thrown
1279
- \`ResilientError\` subclass is mapped to its status by the framework; catching it produces a
1280
- 200 carrying an error object instead.
1281
- - Never read a token, never check a role, never look at an \`Authorization\` header. Access is
1282
- declared on the protocol (\`guards\` / \`gate\` options) and enforced before the handler runs.
1283
- - A handler is inert until the \`bind(protocols.api.<name>, api.body/params/request(protocols.api.<name>, handler))\`
1284
- line in \`sources/api/src/entrypoints.ts\` binds it to its protocol. Without that line the
1285
- endpoint answers 404 and nothing reports an error.
1286
- - The wrap decides the handler's signature — match it exactly:
1287
- \`api.body(p, (payload, ctx, request) => …)\` — the typed body first;
1288
- \`api.params(p, (params, ctx, request) => …)\` — the typed route params first;
1289
- \`api.request(p, (request, ctx) => …)\` — the whole request first.
1290
- - The PROTOCOL decides the handler's types. The body, params and response its declaration in
1291
- \`sources/common/src/entrypoints.ts\` names are the handler's parameter and return types, imported
1292
- from the shared package. Never declare an input or record type in \`sources/api\` or
1293
- \`sources/backend\` (\`CreateTaskInput\`, \`TaskRecord\`, \`Task & { id: string }\`): the shared
1294
- package cannot import it, so the declaration can never name it, and the binding fails with
1295
- \`TS2345 "Argument of type '(payload: CreateTaskInput, ctx: Context) => …' is not assignable"\`.
1296
- Only \`.request\` hands you the request as the FIRST argument. When you need to name its type,
1297
- \`HandlerRequest\` comes from \`@owlmeans/entrypoint\` — \`@owlmeans/server-api\` does NOT export it.
1298
- - Handlers are ENTITY-SCOPED: \`sources/api/src/app/<entity>/<action>.ts\`, named exports only.
1299
- The directory is what keeps two entities' \`list\` apart — the file name carries no marker.
1300
- `),
1301
-
1302
- skill(ViableSkill.FixerHeuristics, 'Reading a build error', `
1303
- Change as little as the reported error requires, and read the error for what it actually
1304
- says before rewriting anything.
1305
-
1306
- - When the task carries a \`NAME REGISTRY\` block, it is AUTHORITATIVE for every directory,
1307
- path, alias and exported symbol it lists. NEVER rename an identifier that appears in it. An
1308
- error saying one "does not exist" means the thing is MISSING and must be DECLARED or CREATED
1309
- where the registry says it lives — add the alias line above the sentinel, add the import,
1310
- write the file at that path. Renaming the reference to a different guess makes the error move
1311
- rather than go away: the next build reports it again under the new spelling, and the loop
1312
- never converges. "Change as little as required" is about the SIZE of the edit, never a licence
1313
- to rename instead of declare.
1314
- - Two files disagreeing about where something lives is not two problems. Take the registry's
1315
- spelling in both, and never invent a third.
1316
-
1317
- - **"Cannot find module '@/…'" in a file under \`sources/backend/\` is the ALIAS, not the
1318
- suffix.** That package is the shared library: it is built by \`tsc -b\`, declares no tsconfig
1319
- \`paths\`, and \`@/\` resolves to nothing in it. Rewrite the specifier as a RELATIVE path keeping
1320
- its \`.js\` suffix — \`'../../resources/task/task.js'\`. Adding, removing or changing the
1321
- extension on the alias fixes nothing and the same error comes back under the other spelling;
1322
- two attempts that differ only in the suffix mean you are in this case.
1323
- - **"Cannot find module 'project-common/…' or 'project-backend/…'" is almost always the
1324
- extension, not the path.** These two are PACKAGES, resolved through their published
1325
- \`exports\` map, and a deep import into either carries NO suffix — no \`.js\`, no \`.ts\`, no
1326
- \`.type\`. \`project-backend/models/enquiry/enquiry.js\` resolves to a file
1327
- (\`build/models/enquiry/enquiry.js.js\`) that can never exist; drop the extension entirely.
1328
- This is unrelated to the \`sources/backend/\` bullet above it: that one is the SAME package
1329
- importing itself and keeps \`.js\`; this one is a DIFFERENT package importing it by name.
1330
- - **\`TS2345\` naming \`BoundEntrypointHandler\` at a \`bind(...)\` line in
1331
- \`sources/api/src/entrypoints.ts\` or \`sources/worker/src/entrypoints.ts\` is a handler wrapped
1332
- TWICE** — the handler module already exported a bound handler, and this line wraps it again
1333
- with \`.body\`/\`.params\`/\`.request\`. Bind the already-bound export DIRECTLY
1334
- (\`bind(protocol, handler)\`, no wrapper) — never cast around the type error, and never touch the
1335
- handler module. Left as a type error, this is also the runtime fingerprint of the same defect:
1336
- a story reported \`completed\` whose every request to that route fails with
1337
- \`TypeError: handler is not a function\` (\`execute is not a function\` for \`.request\`).
1338
- - **\`TS2345 "Argument of type '(payload: X, …) => …' is not assignable to parameter of type
1339
- '(payload: Y, …) => …'"\` at a \`bind(...)\` line in \`sources/api/src/entrypoints.ts\` is the
1340
- HANDLER disagreeing with its protocol.** The declaration in \`sources/common/src/entrypoints.ts\`
1341
- is the authority: change the handler's parameter and return types to exactly the declared ones,
1342
- imported from the shared package. Never edit the declaration toward the handler, never declare a
1343
- type in \`sources/common/src/entrypoints.ts\` to match it, and never cast at the binding — each
1344
- of those moves the error into the other file and the loop never converges.
1345
- - "Cannot find module" for \`postgres\`, \`@/lib/db.js\` or \`@/db/schema/...\` inside a
1346
- \`resources/**\` file means the file was written against the REMOVED Drizzle-DDL layout.
1347
- Rewrite it as an OwlMeans resource: an AJV schema on \`resource.schema\`, built with
1348
- \`makePostgresResource\`, with no table definition at all. Do not create the missing module.
1349
- - An \`import\` of \`drizzle-orm\`, \`drizzle-orm/pg-core\` or \`pg\` reports NO error — the
1350
- driver is installed for the framework's own use — and is still wrong. It is never the cause
1351
- of the error you were given, so do not go hunting for one; but when the reported error puts
1352
- you in that file anyway, drop the import as part of the fix. The builder is
1353
- \`(await resource.db()).drizzle\`, and anything that needed \`eq\`/\`and\`/\`sql\` becomes
1354
- \`resource.query()\` with \`{{}}\` and \`$1\` parameters.
1355
- - A missing foreign-key target means the referenced resource does not exist. Declare the
1356
- reference by alias (\`pg: { references: { resource: '<alias>' } }\`) or fall back to a
1357
- plain string property — never import another resource file. The alias must be one the
1358
- registry (or the listed existing resources) actually names: an invented one compiles and then
1359
- kills the backend at startup with \`fk-unknown-resource\`, which no type check can catch.
1360
- - An error about \`rowsAffected\`/\`rowCount\`/\`changes\` not existing is the resource result
1361
- shape — see the result rules above. \`result.pager\` is the same fault: a listing returns
1362
- \`{ items, total, page?, size? }\`, so \`pager?.total\` becomes \`total\`.
1363
- - \`'limit' does not exist in type 'ListOptions<...>'\` (or the same for \`offset\` or
1364
- \`pager\`) is one call written against a shape that has none of them. Paging is FLAT in
1365
- \`list\`'s second argument: \`list(where, { page, size, sort })\`. \`size\` is the declared
1366
- name — moving the pair under a \`pager\` key only changes which of the two names the next
1367
- build rejects, and that swap is the loop that never converges.
1368
- - \`Property 'pick' does not exist\` on a resource means \`take(id)\` — delete-and-return, which
1369
- throws \`UnknownRecordError\` instead of returning null.
1370
- - \`Argument of type 'string' is not assignable to parameter of type 'Criteria<...>'\` on a
1371
- \`load\`/\`get\` call is the removed \`(value, 'field')\` overload. The field is a key of the
1372
- criteria object: \`load({ projectId })\`. The second argument now carries only \`sort\`.
1373
- - \`delete\` takes an id and nothing else. A \`delete(criteria)\` call is \`purge(where)\`, which
1374
- returns the number of records removed rather than a record.
1375
- - "Cannot find module" for a third-party router or HTTP-server package (\`express\`, \`cors\`),
1376
- \`@reduxjs/toolkit\`, \`react-redux\`, \`@/state/store\` or \`@/lib/fetch\` means the file was
1377
- written against the REMOVED stack. None of those packages are installed and none will be.
1378
- Rewrite the file against entrypoints: \`useNavigate\` from \`@owlmeans/client\` for navigation,
1379
- \`ctx.entrypoint(protocol).call(...)\` for a backend call, a plain exported handler wrapped ONCE
1380
- where it is bound for an endpoint (see "Writing an endpoint handler"), and the state hooks
1381
- (\`useStoreModel\`/\`useStoreList\` over a state resource) for client state. Do NOT install the
1382
- package and do NOT create the missing module.
1383
- - A missing export from a \`*.ts\` module — a selector, an action creator, a thunk, a
1384
- reducer — is the same removed stack in a different disguise. That module exports an ALIAS
1385
- constant and read hooks only. Replace a selector with the matching hook, a dispatched action
1386
- with \`resource.save(record)\` / \`model.update({ ... })\`, and a thunk with an
1387
- \`entrypoint(protocol).call(...)\` in the VIEW MODEL followed by a \`save\`. Do not add the missing
1388
- export to the state module.
1389
- - A runtime \`Resource <alias> not found\` for a state alias means the state module is written but
1390
- nothing registered it. Add the import and ONE
1391
- \`appendStateResource<C, T>(context, ALIAS)\` line above the sentinel in
1392
- \`sources/web/src/context.ts\` — never a second alias string, and never a new store file.
1393
- - \`DEFAULT_ID\` has no export from \`@owlmeans/state\`. A record is either held or it is not, and
1394
- \`model.empty\` is the test — \`record.id === DEFAULT_ID\` was a placeholder that no longer gets
1395
- invented. Never reintroduce the constant, and never compare against \`'_default'\` in its place.
1396
- - \`Property 'erase' does not exist\` on a state resource is \`clear()\`; \`all()\` and
1397
- \`match(criteria)\` are both \`list(criteria)\`, which returns \`{ items, total }\` rather than a
1398
- bare array. An \`erase()\` followed by a \`save\` per record is \`replace(records)\` — one write,
1399
- and the store ends up holding exactly what the server sent.
1400
- - A list screen that recomputes ids to keep itself in step is fighting the store. One
1401
- \`useStoreList({ query, resource })\` subscribes to the CRITERIA and re-renders on every write
1402
- that changes which records match, so the \`useValue(() => resource.list())\` that fed it a list
1403
- of ids goes away entirely rather than being repaired.
1404
- - A runtime \`TypeError: entrypoint.call is not a function\` (or \`<name>.call is not a function\`
1405
- on the result of \`ctx.entrypoint(...)\`) is a MISSING CLIENT BINDING and nothing else. The
1406
- protocol resolves — the browser context carries every shared declaration — but only a bound
1407
- one has \`call\`. Add \`bindAll(protocols.api)\` (or a specific \`bind(protocols.api.<name>)\`) above the
1408
- API binding sentinel in
1409
- \`sources/web/src/entrypoints.ts\`. Do NOT rewrite the view model, do NOT replace the
1410
- call with \`fetch\`, and do NOT add a component to a backend alias. The binding line is the fix;
1411
- the declaration itself cannot provide \`call\` until a client binding materializes it.
1412
- - A screen that renders blank is the same class of fault on the other side: the screen alias has
1413
- no \`bindScreen(protocols.web.<name>, handler(Screen))\` line. Adding a protocol without its binding
1414
- is the usual cause of both.
1415
- - A header, menu or footer rendered TWICE is a screen importing its own layout. The AREA is the
1416
- screen's parent entrypoint and the framework already wraps it — delete the import and the
1417
- wrapper element from the screen, never the area from the entrypoint tree.
1418
- - An error naming an AREA — \`app.web.area.<area>\`, its \`route(...)\`, its \`guards\`/\`gate\` options,
1419
- or one of the four layouts in \`sources/web/src/layout/area.tsx\` — means generated code
1420
- EDITED something that ships with the project. RESTORE the shipped form: four areas under
1421
- \`app.web.base\` at \`/\`, \`/frontoffice\`, \`/admin\` and \`/backoffice\`, each with its own guard
1422
- and one \`default: true\` child. Never re-declare an area to satisfy the error, never add a
1423
- fifth, and never move a screen's access up onto the area — a screen that needs different
1424
- access belongs in a different area.
1425
- - A user who WAS granted a permission and still gets 403 is almost always an \`@\` in the stored
1426
- permission NAME. The gate splits its parameter at the first \`@\`: \`'enquiry--view@enquiryId'\`
1427
- in a protocol's \`gate: { alias: OIDC_GATE, params: [...] }\` is CORRECT and means "look up \`enquiry--view\`, read the resource
1428
- id from the \`:enquiryId\` route param". The same string registered or granted as a permission
1429
- NAME is a key nothing ever looks up, so every grant against it is a silent no-op. Fix the
1430
- registration and the grant, never the gate line — and never delete the \`@\` from the gate to
1431
- "make the names match". A gate whose \`@name\` is not a \`:\` segment of that entrypoint's own
1432
- declared path is the other half of the same fault: it refuses every request.
1433
- - A PERMISSION that no definition backs is a gate nobody can pass, and there are two ways generated
1434
- code gets one. A bracketed word — \`gate: { alias: OIDC_GATE, params: ['<permission>'] }\` — is an instruction's
1435
- placeholder written out as if it were a name. A foreign name — \`article--modify\` in an
1436
- application that has no articles — is an example copied from a comment or a skill. Both look
1437
- perfectly valid to the compiler and to the boot check, so nothing reports them; the app simply
1438
- refuses those requests forever. Replace the string with a permission built from THIS project's own
1439
- resource and action, spelling it exactly as the project's other declarations spell that capability
1440
- — never delete the gate to make the error go away, and never invent a definition to match a name
1441
- that should not have been written.
1442
- - A whole area rendering BLANK is its missing \`default: true\` child: an entrypoint with
1443
- children and no default child matches nothing. Restore the default child; do not give the area
1444
- a component of its own.
1445
- - \`'X' does not exist in type 'Y'\` means the SHAPE you wrote is wrong, not that the
1446
- feature is impossible. When a TYPE_DECLARATIONS block is present in the task, it is the
1447
- ground truth — rewrite the object or call to match it exactly, instead of renaming the
1448
- property to another guess or deleting the code.
1449
- - If the same error survives a fix, the fix was wrong. Re-read the message rather than
1450
- applying a larger version of the same change.
1451
- - NEVER make a handler or a function stop doing its job to make the build pass. Returning
1452
- \`{ items: [], total: 0 }\`, an empty object, a hard-coded literal or \`res.sendStatus(204)\`
1453
- in place of the real call is not a fix — it turns a loud build error into a silent wrong
1454
- answer nobody will notice. If a call does not type-check, correct the ARGUMENTS to match
1455
- the function's real signature; the imports and the call must survive the fix.
1456
- - A runtime \`SyntaxError: Service not found <alias>\` from \`context.serviceRoute\` is a
1457
- DIFFERENT error from the one below, and the difference decides the repair. It means an
1458
- ENTRYPOINT alias (\`app.web.*\`, \`app.api.*\`) was passed where a SERVICE alias is required.
1459
- The registered ones are \`APP_WEB\` and \`APP_API\` from \`sources/common/src/consts.ts\`, and
1460
- the REGISTERED SERVICE ALIASES block in this task lists what this project actually declares.
1461
- **Substituting another \`app.*\` value is what makes this loop circle** — it has already cost
1462
- one project twenty attempts, each one reporting success and changing only which name the error
1463
- prints. The two files that can produce it, \`config.ts\` and \`owlmeans.ts\`, are framework
1464
- wiring: they are refused to the write tools, and \`restore_wiring_file\` is the repair.
1465
- - A runtime \`Service <alias> not found\` means generated code called
1466
- \`owlCtx.service('<alias>')\` for something nothing registers. Two causes, both common:
1467
- a hand-written service object in \`src/models/**\`, or a RESOURCE alias asked for on the
1468
- service registry (\`ctx.service('reservation')\` — use the resource accessor instead,
1469
- \`reservationResource()\` from \`@/resources/reservation/reservation.js\`, and drop the
1470
- \`ctx\` parameter the function only needed for the lookup). **Do NOT create a service to fix it, and do NOT add a
1471
- file under \`src/services/\`.** Delete the fake service object and the lookup, EXPORT the
1472
- functions it wrapped from the same model file (dropping the \`ctx\` parameter they never
1473
- needed), and import those functions directly in the handler. TypeScript cannot see the
1474
- original error — the code compiles — but it does see the follow-ups, so expect
1475
- \`declares 'X' locally, but it is not exported\` next and fix it by exporting, never by
1476
- reintroducing a service.
1477
- - A runtime \`Resource <alias> not found\` means the resource module is missing from the
1478
- generated \`src/resources/index.ts\`. Report it; never hand-register a resource and never
1479
- edit a generated registry.
1480
- `),
1481
-
1482
- skill(ViableSkill.LayoutDefinition, 'What "layout" means here', `
1483
- A layout is one of the FOUR AREA shells this project already ships, all in one file —
1484
- \`sources/web/src/layout/area.tsx\`: \`GuestLayout\`, \`UserLayout\`, \`AdminLayout\`,
1485
- \`OperatorLayout\`. They carry the chrome of the application — header, section menu, side menu,
1486
- content region, footer — and each is a thin wrapper over \`NavLayout\` from
1487
- \`@owlmeans/web-panel\`, which builds both menu levels from \`sources/web/src/nav.ts\`.
1488
-
1489
- A layout is mounted as the PARENT entrypoint of its area, never imported by a screen. The
1490
- framework renders the matched screen INTO it as \`children\`, so a layout is
1491
- \`FC<PropsWithChildren>\` and puts \`{children}\` where the screen belongs. It knows nothing about
1492
- which screen that is.
1493
-
1494
- A generated project never creates a layout. There are four and there will only ever be four:
1495
- customise one by RESTYLING it — its title, its classes, its header actions — never by writing a
1496
- fifth shell and never by rebuilding the navigation inside it by hand.
1497
- `),
1498
-
1499
- skill(ViableSkill.ScreenDefinition, 'What "screen" means here', `
1500
- A screen is the React component that arranges other components and their layout inside
1501
- the page. It may also switch between the components it contains. It never contains
1502
- general chrome — no header, no footer, no navigation, no menu, no sidebar.
1503
-
1504
- A screen NEVER imports and NEVER renders a layout component. Its layout is the AREA it is
1505
- declared under — \`frontend({ parent: app.web.area.<area> })\` — and the framework wraps the
1506
- screen in it. A screen that wraps itself renders the whole chrome twice, nested.
1507
-
1508
- A screen's declared path is the TAIL ONLY: \`'/tasks'\`, never \`'/frontoffice/tasks'\`. The area
1509
- contributes the prefix, so repeating it publishes the screen at a doubled URL nothing links to.
1510
- `),
1511
-
1512
- skill(ViableSkill.QueueDiscipline, 'When work belongs off the request path', `
1513
- Almost never. Start from "this is a request handler and a table" and stay there unless one of
1514
- the reasons below is TRUE of this feature. A queue turns one request into two processes and a
1515
- message that can be delivered twice; a worker is a second thing that can be down; an LLM agent
1516
- turns a click into a bill. None of that is free, and none of it is undone easily.
1517
-
1518
- ## Reasons that are real
1519
- - The work calls an LLM, or any third-party API that is slow or rate-limited.
1520
- - The work walks an unbounded set — every row a user owns, every file in an upload.
1521
- - The work must survive the user closing the tab: a long import, a generated report.
1522
- - The work is scheduled or repeated rather than requested.
1523
- - The work must be retried on failure without the user doing anything.
1524
-
1525
- ## Reasons that are NOT real
1526
- - "It might be slow one day." Measure first; moving it later is a small change.
1527
- - "It writes to several tables." That is a transaction, not a job.
1528
- - "It sends one email." One outbound call in a request is fine.
1529
- - "It feels like background work." Feelings are not a reason; name the property.
1530
- - "It is complicated." Complexity belongs in a function, not in another process.
1531
-
1532
- ## If the answer is yes
1533
- Say WHY in one sentence naming the property above, and say how the processor is safe to run
1534
- twice — a worker can die mid-job, the lock expires and the step re-runs. There is no way to
1535
- make that automatic. "Skip rows already marked done" and "delete what a previous attempt
1536
- created before recreating it" are answers; "it should be fine" is not.
1537
-
1538
- ## And say who watches it
1539
- A queued job has no screen and no session. Name the story whose screen shows its progress, or
1540
- the work is invisible and the user is left pressing a button that appears to do nothing.
1541
- `),
1542
-
1543
- skill(ViableSkill.WorkerJobs, 'Queues, jobs and processors', `
1544
- Three server-side bindings have to agree, and a job that exists in two of the three is worse than one that
1545
- exists in none — a declared name nothing processes is a message that piles up, and a processor
1546
- with no declaration is dead code the barrel still imports.
1547
-
1548
- ## 1. The protocol and queue — \`sources/backend/src/jobs/index.ts\`
1549
- A queue is an ADDRESS: it says what exists and which job names it accepts. Both the api (which
1550
- enqueues) and the worker (which consumes) read this one list, and a job name the queue does not
1551
- declare is refused at enqueue time. The technical protocol belongs here too; common and web must
1552
- not import it.
1553
-
1554
- \`\`\`ts
1555
- export const jobProtocols = {
1556
- reportBuild: protocol(route(
1557
- 'job:report:build', '/report-build',
1558
- queueRoute({ service: APP_WORKER, queue: APP_QUEUE, timeout: 30_000 }),
1559
- ), contract(typed())),
1560
- }
1561
- export const queues: QueueDeclaration[] = [
1562
- { name: APP_QUEUE, jobs: [jobProtocols.reportBuild.alias],
1563
- worker: { concurrency: 4, lockDuration: 60_000 } },
1564
- ]
1565
- \`\`\`
1566
-
1567
- Use \`job()\` from \`@owlmeans/queue\`, never from the generic route package.
1568
-
1569
- ## 2. The producer — \`sources/api/src/entrypoints.ts\`
1570
- Bind \`bindClient(jobProtocols.reportBuild)\`. Public API contracts in common describe the domain
1571
- operation and do not disclose that the handler delegates to a queue.
1572
-
1573
- ## 3. The processor — \`sources/worker/src/jobs/<name>.ts\`
1574
- A PLAIN exported async function — named exports only, exactly like an endpoint handler. It never
1575
- imports \`handlers\` and never calls \`handlers<Context>()\`; that belongs only in
1576
- \`sources/worker/src/entrypoints.ts\`, which wraps it EXACTLY ONCE when binding it:
1577
-
1578
- // sources/worker/src/jobs/report-build.ts — the processor module
1579
- export const handleReportBuildJob = async (req: ReportBuildRequest, ctx: Context) => { ... }
1580
-
1581
- // sources/worker/src/entrypoints.ts — the ONLY wrap
1582
- import * as jobs from '@/jobs/index.js'
1583
- const worker = handlers<Context>()
1584
- bind(jobProtocols.reportBuild, worker.request(jobProtocols.reportBuild, jobs.handleReportBuildJob))
1585
-
1586
- WRONG — the processor module ALSO calls \`handlers()\` and wraps itself, so the export bound above
1587
- is wrapped a SECOND time. \`tsc\` catches it (\`TS2345 "BoundEntrypointHandler<…> is not
1588
- assignable"\`); left unfixed, every enqueued job then fails with \`TypeError: handler is not a
1589
- function\`:
1590
-
1591
- // WRONG — sources/worker/src/jobs/report-build.ts
1592
- const worker = handlers<Context>()
1593
- export const handleReportBuildJob = worker.request(jobProtocols.reportBuild, async (req, ctx) => { ... })
1594
-
1595
- It RETURNS its result; throwing a \`ResilientError\` subclass is how a refusal is reported, and the
1596
- class survives the broker.
1597
-
1598
- Two rules with no equivalent on the HTTP side:
1599
- - **Call \`job.touch()\` inside every long loop.** The broker judges liveness by the lock, and
1600
- silence for longer than \`lockDuration\` is indistinguishable from a dead worker — the job is
1601
- handed to somebody else and the work runs twice.
1602
- - **A processor must be safe to run twice.** Skip what a previous attempt recorded, or delete
1603
- what it created, and say in a comment which of the two this one does.
1604
-
1605
- Enqueue from an endpoint with the same typed call used for HTTP:
1606
- \`context.entrypoint(jobProtocols.<name>).call({ body: data })\`.
1607
- `),
1608
-
1609
- skill(ViableSkill.TargetAgents, 'LLM agents inside the application', `
1610
- An agent generated into the application is an \`@owlmeans/agent\` model, built in the shared
1611
- backend package and RUN FROM A JOB — never on the request path. A model call takes seconds to
1612
- minutes and costs money per attempt; holding a request open for it gives the user a timeout and
1613
- the operator a bill with no result attached.
1614
-
1615
- - \`makeAgentModel({ exec, tools, ... })\` for a tool loop that converses.
1616
- - \`makePipeline(spec, steps)\` for ordered, resumable steps whose position must survive a crash.
1617
- Pipeline state holds KEYS, never artifacts — a step writes its output somewhere and puts the
1618
- id in the state.
1619
-
1620
- The agent module goes in \`sources/backend/src/agents/<alias>.ts\` and is registered in that
1621
- directory's generated barrel. The endpoint the user presses enqueues the job; the job invokes
1622
- the agent; the screen watches the job. Never import an agent from \`sources/api\`.
1623
- `),
1624
-
1625
- skill(ViableSkill.AgenticChoice, 'A call, a pipeline, or an agent', `
1626
- Three shapes can perform work with a model, and they are not interchangeable. Pick the
1627
- SIMPLEST one that does the job, and increase complexity only when the simpler shape provably
1628
- cannot. Every step up costs latency, money and a failure mode.
1629
-
1630
- **A call** — one prompt, one answer. The default, and the right answer far more often than it
1631
- is chosen. Summarise, classify, extract, rewrite, draft, translate, answer a question about
1632
- text you already have. If you can write down the prompt, it is a call.
1633
-
1634
- **A pipeline** — fixed, ordered steps, decided by you and not by the model. Choose it when the
1635
- work decomposes cleanly into subtasks you can NAME IN ADVANCE, and each step's output is the
1636
- next step's input: transcribe then summarise then file; extract then validate then store. You
1637
- are trading latency for accuracy, and you know the number of steps before you start.
1638
-
1639
- **An agent** — the model chooses its own path through tools, and you cannot say in advance how
1640
- many steps it will take. Choose it ONLY when all of these are true:
1641
- - the number of steps genuinely cannot be predicted;
1642
- - which tool to use next is a judgement, not a rule you could write down;
1643
- - the input is unstructured and the decision is contextual;
1644
- - and the work runs in a place where an unpredictable number of tool calls is acceptable.
1645
-
1646
- Not reasons to choose an agent: the task is "complex"; it uses more than one piece of data; it
1647
- sounds impressive; a tool exists. A rules engine with a model in it is a pipeline. An app that
1648
- calls a model but does not let the model direct the work is not an agent at all.
1649
-
1650
- If you are unsure between two of them, take the simpler one. A call that turns out to need a
1651
- second step becomes a pipeline with one edit; an agent that never needed to be one is a bill
1652
- nobody can explain.
1653
-
1654
- Whichever shape it is, it runs off the request path — a model call is seconds to minutes and
1655
- costs money per attempt, and holding a request open for it gives the user a timeout and the
1656
- operator a bill with no result attached. So it is ALWAYS reached through something a person does:
1657
- somebody starts it, and somebody watches it finish. Work with no human half is work nobody can
1658
- see, cancel or be told about, and it reaches the user as a button that appears to do nothing.
1659
-
1660
- Describe that work INSIDE the story of the person who starts it — "I request a write-up and come
1661
- back to read it" — rather than as a story of its own told in the machine's voice. A machine has
1662
- no account to sign in with, no screen of its own and no permission that can be granted to it, so
1663
- "As an AI assistant, I want to open the queue…" is a story whose actor can never use what gets
1664
- built for it.
1665
- `),
1666
-
1667
- skill(ViableSkill.TargetLlm, 'Calling a model from the application', `
1668
- Model calls happen in \`sources/backend\` and are invoked from a job in \`sources/worker\`.
1669
- NEVER from \`sources/web\` — a browser bundle cannot hold a provider key — and never inline in
1670
- a request handler in \`sources/api\`.
1671
-
1672
- The key is the user's, not the platform's, and it may be absent. Follow the shape
1673
- \`sources/backend/src/config.ts\` already uses for the database and the queue:
1674
-
1675
- \`\`\`ts
1676
- export const anthropicKey = process.env.ANTHROPIC_API_KEY ?? ''
1677
- export const llmConfigured = anthropicKey !== ''
1678
- \`\`\`
1679
-
1680
- - Never throw at module scope when it is missing. The application must still boot, exactly as
1681
- it does without \`VALKEY_URL\`.
1682
- - The endpoint that would use it answers a refusal explaining that a model API key has not been
1683
- configured yet — not a 500, and not silence.
1684
- - Declare the variable so the platform can ask the owner for it; do not invent a default.
1685
-
1686
- Give every call a bounded output and treat a refusal or an empty answer as an outcome the code
1687
- handles, never as an exception that reaches the user.
1688
-
1689
- Where the answer has a SHAPE, describe it with the same AJV schema style the rest of this
1690
- application uses — the one already exported beside each type. Do not reach for \`zod\`: it is not
1691
- a dependency of this project, and importing it fails the build with \`TS2307\`.
1692
- `),
1693
-
1694
- skill(ViableSkill.TargetAgentTools, 'Tools and skills for the application’s agent', `
1695
- An agent is only as good as the tools it is given, and a bloated tool set is the most common
1696
- way to make one worse.
1697
-
1698
- - Give it the FEWEST tools that can complete the work. If you cannot say which of two tools the
1699
- agent should reach for in a given situation, neither can it.
1700
- - Name each tool for what it accomplishes, not for the endpoint behind it, and describe it in
1701
- one sentence that says WHEN to use it.
1702
- - Return human-readable results. An agent reasons better over a name than over a row id, and a
1703
- large result should be filtered or truncated by the tool rather than by the model.
1704
- - A tool must never reject. Catch inside it and return the failure as text the agent can act
1705
- on; a thrown tool call aborts the whole turn.
1706
-
1707
- The application's own \`.agents/skills/\` directory is loadable into its agent's prompt. Where
1708
- guidance is long or situational, write it as a skill and let the agent read it when it needs
1709
- it, rather than pasting it into the system prompt where it is paid for on every call.
1710
- `),
1711
-
1712
- skill(ViableSkill.GameDesign, 'What a game brief is made of', `
1713
- A game is not a business flow, and analysing it as one produces a menu with nothing behind it.
1714
-
1715
- Describe, in this order:
1716
- 1. **The core loop** — the one thing the player does over and over, in a sentence. "Steer, dodge,
1717
- collect." "Place a tile, score the line."
1718
- 2. **The win and lose conditions** — how a session ends, both ways. A game with no end is a toy.
1719
- 3. **The controls** — what input does what. Keyboard, pointer, touch.
1720
- 4. **Progression** — what changes between the first minute and the tenth: speed, levels, score,
1721
- unlocks. One axis is enough.
1722
- 5. **What is persisted** — usually a score, a run history, a player profile. Almost never the
1723
- frame-by-frame state of a session.
1724
-
1725
- Steps that are NOT part of a game brief: sign-up, settings, billing, admin. They exist, they are
1726
- ordinary screens, and they are not the product.
1727
-
1728
- Keep it to one game. A brief that describes a platform of several games describes none of them.
1729
- `),
1730
-
1731
- skill(ViableSkill.GameScene, 'The three.js scene component', `
1732
- Exactly ONE component owns the 3D scene. It creates the renderer, the camera and the scene,
1733
- runs the animation loop, and tears all of it down again.
1734
-
1735
- \`\`\`tsx
1736
- // right — the loop and the renderer live in one effect, and it cleans up after itself
1737
- useEffect(() => {
1738
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
1739
- mount.current!.appendChild(renderer.domElement)
1740
- let frame = 0
1741
- const tick = () => { frame = requestAnimationFrame(tick); renderer.render(scene, camera) }
1742
- tick()
1743
- return () => {
1744
- cancelAnimationFrame(frame)
1745
- renderer.dispose()
1746
- renderer.domElement.remove()
1747
- }
1748
- }, [])
1749
- \`\`\`
1750
-
1751
- Rules that are not optional:
1752
- - **Never drive the animation loop from React state.** A \`setState\` per frame re-renders the
1753
- tree sixty times a second and the game stutters. Mutate the object3D directly in the loop and
1754
- publish to React only what the interface shows — a score, a life count — and only when it
1755
- changes.
1756
- - **Dispose what you create.** Geometries, materials and the renderer all hold GPU memory that
1757
- unmounting does not release. A scene mounted and unmounted a few times without disposal
1758
- exhausts the context and the canvas goes black.
1759
- - **Resize is an event, not a render.** Update \`camera.aspect\`, call
1760
- \`camera.updateProjectionMatrix()\` and \`renderer.setSize(...)\` from a resize listener.
1761
- - **Geometry is generated, never loaded.** Compose it from the built-in geometries and simple
1762
- materials — box, sphere, cylinder, plane, lathe, extrude. No textures, no image files, no
1763
- external model formats. Colour, light and shape carry the whole look.
1764
- `),
1765
-
1766
- skill(ViableSkill.GameUi, 'The interface is React, above the canvas', `
1767
- Everything a player reads or presses — score, menus, dialogs, buttons, settings — is an
1768
- ordinary React component rendered ABOVE the canvas, using the same shadcn primitives as the rest
1769
- of the application. Nothing is drawn as text inside the 3D scene.
1770
-
1771
- \`\`\`tsx
1772
- // right — the canvas fills the frame, the interface floats over it
1773
- <div className="relative h-full w-full">
1774
- <div ref={mount} className="absolute inset-0" />
1775
- <div className="pointer-events-none absolute inset-0 p-4">
1776
- <ScoreBadge value={score} />
1777
- <div className="pointer-events-auto"><Button onClick={pause}>Pause</Button></div>
1778
- </div>
1779
- </div>
1780
- \`\`\`
1781
-
1782
- - The overlay container carries \`pointer-events-none\` so clicks reach the canvas; each
1783
- interactive control turns them back on with \`pointer-events-auto\`. Forgetting this makes the
1784
- game unplayable — every drag lands on an invisible div.
1785
- - Text rendered into the scene cannot be selected, translated, scaled by the browser or read by
1786
- a screen reader. Use it for nothing that matters.
1787
- - SVG is the second half of the art: icons, badges, backgrounds and 2D games are inline SVG
1788
- written by hand. No raster images anywhere.
1789
- `),
1790
-
1791
- skill(ViableSkill.GameNetworking, 'Who owns the state', `
1792
- Three kinds of game, and the difference is entirely about where the state lives.
1793
-
1794
- **Casual** — one player, one browser. The session runs entirely on the client; the server sees
1795
- only what is worth keeping: a final score, a run record, a profile. No queue, no worker, no
1796
- socket. This is most games, and it is the right answer unless the brief asks otherwise.
1797
-
1798
- **Online, turn-based** — several players acting one after another, minutes or days apart. The
1799
- match is an ordinary record and every move is an ordinary endpoint that validates it and writes
1800
- the next state. Anything slow that follows a move — scoring a finished match, notifying the next
1801
- player, rebuilding a leaderboard — is a job. No realtime anything.
1802
-
1803
- **Online, live** — several players acting at once, and the server simulates.
1804
- - The client sends INTENT ("move forward", "fire"), never an outcome ("I am at x=12", "I hit
1805
- them"). A client that reports outcomes is a client that decides them, and one player's browser
1806
- then decides everybody's game.
1807
- - The server holds the authoritative state, advances it on a fixed tick, and broadcasts it.
1808
- - The client may predict its own movement locally so it feels immediate, and corrects when the
1809
- server's answer disagrees. Other players' entities are interpolated between the last two
1810
- updates rather than snapped.
1811
- - Anything the outcome depends on — damage, scoring, currency, who won — is decided on the
1812
- server and nowhere else.
1813
- `),
1814
-
1815
- skill(ViableSkill.MarketingConsent, 'Marketing consent — the eight standard keys', `
1816
- This application ships \`@owlmeans/marketing-consent\` wired by \`@owlmeans/web-marketing-consent\`
1817
- and \`@owlmeans/server-marketing-consent\`: a post-sign-in step, a "Privacy choices" settings
1818
- screen, and a server-side ledger. Everything about consent already exists — never build a second
1819
- one.
1820
-
1821
- **The eight standard keys**, each independently opt-in and unchecked until the person says
1822
- otherwise: \`marketing.email\`, \`marketing.sms\`, \`marketing.phone\`, \`marketing.push\` (which
1823
- channel may send promotional messages), \`data.profiling\` (personalising from behaviour),
1824
- \`data.partners\` (sharing with another company for ITS OWN marketing), \`trackers.analytics\` and
1825
- \`trackers.advertising\` (already bridged to the cookie-consent categories — never build a second
1826
- cookie banner or duplicate that bridge).
1827
-
1828
- **Never build your own.** No new consent table, flag, checkbox, pre-ticked box, "I agree to
1829
- marketing" toggle, or cookie dialog. No sign-in-time bundling of consent with the terms checkbox —
1830
- they are two different questions with two different lawful bases, kept apart on purpose. Consent
1831
- is never a condition of using the product: a person who declines everything still gets a working
1832
- account.
1833
-
1834
- **Every send and every share checks the RECIPIENT's grant, at the moment it acts — never the
1835
- sender's, never a cached value from earlier in the request.** A campaign email checks
1836
- \`marketing.email\`; an SMS reminder checks \`marketing.sms\`; handing a record to another company
1837
- checks \`data.partners\`. An absent record reads as NOT granted — never assume consent because a
1838
- person signed up, imported a contact, or has not yet been asked. A withdrawal takes effect on the
1839
- very next send; nothing already queued is exempt.
1840
-
1841
- **Service messages are never gated.** A password reset, a receipt, a delivery notice, an
1842
- account-security alert, an invite the person themselves triggered — none of these are "marketing"
1843
- and none of them ever consult this ledger. Gating a service message on marketing consent is itself
1844
- a bug: it makes the product not work for someone who declined an unrelated choice.
1845
-
1846
- **Every marketing message links to Privacy choices**, the standing control that changes a decision
1847
- at any time — reference it as an alias, never write a fresh URL.
1848
-
1849
- **A new channel or purpose the product genuinely needs** — a notification type this catalogue does
1850
- not name — is one line in the target's marketing-consent config, above its own sentinel; it is
1851
- never invented inline in a component or a handler.
1852
- `),
1853
-
1854
- libraries(ViableSkill.LibrariesCommon, 'Libraries — shared code', commonLibraryList),
1855
- libraries(ViableSkill.LibrariesUiState, 'Libraries — UI state', uiStateLibraryList),
1856
- libraries(ViableSkill.LibrariesUi, 'Libraries — UI', fullUiLibraryList),
1857
- libraries(ViableSkill.LibrariesBackend, 'Libraries — backend', backendLibraryList),
1858
- libraries(ViableSkill.LibrariesAi, 'Libraries — model calls and agents', aiLibraryList),
1859
- libraries(ViableSkill.LibrariesGame, 'Libraries — game', gameLibraryList),
1860
- ]
1861
-
1862
- /** Look one up by alias — for the places that still splice a rule into a task prompt. */
1863
- export const viableSkill = (alias: ViableSkill): SkillDefinition =>
1864
- VIABLE_SKILLS.find(entry => entry.alias === alias)!