@owlmeans/viable-common 0.0.35 → 0.0.36

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