@pikku/skills 0.12.16 → 0.12.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.16",
3
+ "version": "0.12.18",
4
4
  "description": "The Pikku agent skills — the instruction set coding agents read to build, wire and deploy Pikku projects",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -28,7 +28,10 @@ project shaped so `pikku fabric init` later adopts it with zero rework.
28
28
  ## Agent Operating Procedure
29
29
 
30
30
  1. Discover before editing. Run `pikku info functions --verbose --silent` and
31
- read `AGENTS.md` before your first change.
31
+ read `AGENTS.md` before your first change. Read `locale` in
32
+ `pikku.config.json` too: it is the language every `description`, `title` and
33
+ step `template` you write must be in (§1a). Identifiers stay English whatever
34
+ it says.
32
35
  2. Make the smallest source change that satisfies the task. Keep generated files
33
36
  generated — never hand-edit `.pikku/`, `*.gen.*`, or the SDK.
34
37
  3. Validate with the narrowest relevant command, then `pikku all` when functions,
@@ -72,10 +75,68 @@ in one message. Then stop; do not interview the user.
72
75
  Neutral (fine for an internal tool, but say so out loud); a direction in words;
73
76
  a reference (brand guide, screenshots, a site whose register they want); or
74
77
  their own design agent/prompt, whose output you take as the direction.
78
+ - **What language should the app speak, and what language does the team work
79
+ in?** Two answers, not one — see §1a, which is where they go. Ask only if the
80
+ request is not obviously English; a brief written in English about an English
81
+ product answers both.
75
82
 
76
83
  Skip anything you can decide yourself. If nobody answers, assume one app with
77
84
  paths, the roles implied by the request, Neutral, English — and say so.
78
85
 
86
+ ## 1a. Three languages, and you must not collapse them
87
+
88
+ A brief saying "the entire UI is German" is about **one** of these. Getting this
89
+ wrong has already shipped a project that can never add a second language, so
90
+ settle all three explicitly before you write code.
91
+
92
+ | Axis | What it covers | Where it goes |
93
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
94
+ | **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Commit messages. | Nowhere — **always English**, no setting, not negotiable |
95
+ | **Meta** | `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role and persona descriptions | `locale` in `pikku.config.json`, default `en` |
96
+ | **Product UI** | Every string the app shows a user | `messages/<locale>.json`, and `defaultLocale` for what a first-time visitor opens in |
97
+
98
+ **Identifiers are English.** The product's market does not change this and
99
+ neither does `locale`. Identifiers are the surface the generated `#pikku/*`
100
+ clients, `pikku info`, the typed RPC map and the Kysely types all bind to, and
101
+ unlike a string an identifier cannot be translated later — renaming one is a
102
+ migration. A German practice management tool gets `getWorklist`, `case`,
103
+ `event`, not `getUebersicht`, `vorgang`, `ereignis`.
104
+
105
+ **Meta follows `locale`.** Write the team's answer into `pikku.config.json` in
106
+ this phase, before there is any meta to be wrong:
107
+
108
+ ```json
109
+ { "locale": "de" }
110
+ ```
111
+
112
+ It exists for the Pikku Console. Meta is the one part of a project the Console
113
+ renders back to a human, so a team working in German reads their own functions,
114
+ features and scenario reports in German. Default `en` and do not ask when the
115
+ project is obviously English. **On every later run, read this field first and
116
+ author descriptions, titles and step templates in it** — a project whose
117
+ `locale` you ignored reports half in one language and half in another.
118
+
119
+ **Product UI is the message catalogue.** `messages/<locale>.json` via
120
+ `pikku-i18n`, with `defaultLocale` deciding what a visitor opens in.
121
+ `baseLocale` in `project.inlang/settings.json` **stays `en`**: it names the
122
+ message source, the catalogue every other language is cloned from, so a project
123
+ that repoints it has nothing to translate from and `--add-locale` is broken
124
+ forever.
125
+
126
+ A German medical portal, correctly:
127
+
128
+ ```jsonc
129
+ // project.inlang/settings.json
130
+ { "baseLocale": "en", "locales": ["en", "de"] }
131
+ // apps/app/src/i18n/active.json (or: fabric i18n --default-locale de)
132
+ { "defaultLocale": "de" }
133
+ // pikku.config.json
134
+ { "locale": "de" }
135
+ ```
136
+
137
+ Record the two non-obvious answers as a `decisions/` note in §2 — neither is
138
+ discoverable from code, and the next agent will otherwise re-derive them wrong.
139
+
79
140
  ---
80
141
 
81
142
  ## PHASE 1 — What the app is
@@ -154,6 +154,13 @@ icons, hardcoded `marginLeft`, a nav that opens on the wrong side.
154
154
  Adding a language means adding a locale file. If it means touching components,
155
155
  that is the finding.
156
156
 
157
+ `baseLocale` stays `en` through all of this — it names the message source that
158
+ every added locale is cloned from, so three locales is `locales: ["en", …]` and
159
+ never a repointed base. Shipping locales is also not a reason for anything in
160
+ the code to stop being English: identifiers are English in every project, and
161
+ the language of `description`/`title`/`template` is `locale` in
162
+ `pikku.config.json`. See `pikku-build-app` §1a.
163
+
157
164
  ### Emails — `pikku-emails`
158
165
 
159
166
  Templates in `emails/`, rendered and sent through the injected `email` service,
@@ -52,6 +52,15 @@ it — one kind of person, or several?** Everything else you decide yourself.
52
52
  Do not ask about design, deployment, or scope. This is the quick mode; the
53
53
  defaults are the point.
54
54
 
55
+ Do not ask about language either — take the defaults and note them in §6.
56
+ Identifiers are English in every project, whatever the product's market;
57
+ `locale` in `pikku.config.json` (the language of `description`/`title`/step
58
+ `template`, which the Console renders) stays `en` unless the user already told
59
+ you otherwise. If the request says the app's UI is not English, that is the
60
+ message catalogue only: add the locale and set `defaultLocale`, and leave
61
+ `baseLocale` at `en`. `pikku-build-app` §1a has the three axes in full; getting
62
+ them confused is how a project ends up unable to add a second language.
63
+
55
64
  ## 2. Personas — 60 seconds, not optional
56
65
 
57
66
  `packages/functions/src/personas.ts` ships with a `visitor`. Add one persona per
@@ -138,7 +138,9 @@ Services can be destructured inline in the `func` signature (e.g. `async ({ logg
138
138
 
139
139
  ```typescript
140
140
  pikkuFunc({
141
- // Identity and documentation
141
+ // Identity and documentation — prose, so it follows `locale` in
142
+ // pikku.config.json (default `en`). The identifier does not; see
143
+ // "What Language You Write In".
142
144
  title?: string, // Human-readable name
143
145
  description?: string, // What the function does
144
146
  version?: number, // Contract version (see pikku-versioning)
@@ -321,6 +323,94 @@ src/
321
323
  └── pikku-bootstrap.gen.ts
322
324
  ```
323
325
 
326
+ ## What Language You Write In
327
+
328
+ Three different things in a Pikku project have a human language, and they are
329
+ **not** the same language. Collapsing them is the mistake this section exists to
330
+ prevent, and it has already shipped in a real product — the failure is at the
331
+ bottom.
332
+
333
+ | Axis | What it covers | What decides it |
334
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
335
+ | **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Branch names and commit messages. | Nothing. **Always English.** There is no setting. |
336
+ | **Meta** | The prose authored _inside_ the code: `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role/persona descriptions. | `locale` in `pikku.config.json`. Defaults to `en`. |
337
+ | **Product UI** | Every string the app shows a user. | `messages/<locale>.json`, with `active.json`'s `defaultLocale` choosing what a first-time visitor opens in. |
338
+
339
+ ### Identifiers are English, and nothing changes that
340
+
341
+ Not the product's market, not the team's working language, and **not `locale`**.
342
+ A German medical practice, an Arabic marketplace and a Japanese logistics tool
343
+ all get `getOverview`, `AttentionStripe`, `case`, `event`.
344
+
345
+ This is not linguistic preference, it is mechanics. Identifiers are the surface
346
+ every other tool binds to: the generated `#pikku/*` clients, `pikku info` and
347
+ `pikku meta`, the RPC map a scenario's `actor.invoke` is typed over, the
348
+ generated SQL types, every skill and every agent that ever picks the project up.
349
+ A `vorgang` table types as `Vorgang` in Kysely and reads as noise to everyone who
350
+ did not name it, and unlike a string it cannot be translated later — renaming an
351
+ identifier is a migration, not an edit.
352
+
353
+ ### Meta follows `locale`, and that is what the field is for
354
+
355
+ ```json
356
+ { "locale": "de" }
357
+ ```
358
+
359
+ Meta is the one part of a project the **Pikku Console** renders back to a human.
360
+ A team reviewing their own functions, features and scenario reports in the
361
+ Console is reading meta and nothing else, so a team whose working language is
362
+ German should be able to read their Console in German. That is the entire reason
363
+ the field exists.
364
+
365
+ Read it before you author meta, and write descriptions, titles and templates in
366
+ it. Absent, it is `en`. It is a BCP-47 tag (`en`, `de`, `pt-BR` — a hyphen, not
367
+ an underscore), and the CLI rejects anything else by name.
368
+
369
+ `locale` is **not** licence to rename anything. `locale: "de"` buys a German
370
+ `description: 'Zeigt die Arbeitsliste'` on a function still called
371
+ `getWorklist`.
372
+
373
+ ### Product UI language lives in the catalogue, and only there
374
+
375
+ What the app says to its users is a translation concern, not a code concern. It
376
+ belongs in `messages/<locale>.json`; `pikku-i18n` owns the details. The one rule
377
+ worth repeating here: **`baseLocale` in `project.inlang/settings.json` stays
378
+ `en`.** It names the message _source_ — the catalogue every other language is
379
+ cloned from and translated against — so a project that sets it to anything else
380
+ has no English catalogue to translate from and can never gain a second language
381
+ without re-authoring every key.
382
+
383
+ ### The failure this comes from
384
+
385
+ An agent was asked to build a doctor's portal for a German practice. The brief
386
+ said "the entire UI is German, no English strings visible anywhere". The agent
387
+ read one sentence about the product's users as an instruction about the
388
+ codebase, and produced:
389
+
390
+ - `project.inlang/settings.json` with `baseLocale: "de"` and `locales: ["de"]`,
391
+ no `en.json` at all — which silently broke `--add-locale` forever
392
+ - RPC functions `getUebersicht` and `getPatientendetail`
393
+ - React components `Zeitstrahl` and `AufmerksamkeitStreifen`
394
+ - database tables `vorgang` and `ereignis`, with German columns
395
+
396
+ Every one of those is wrong, and the brief was satisfied by none of them: a
397
+ German UI needs German _messages_. What that project actually wanted was three
398
+ settings, each on its own axis:
399
+
400
+ ```jsonc
401
+ // project.inlang/settings.json — the message source stays English
402
+ { "baseLocale": "en", "locales": ["en", "de"] }
403
+
404
+ // apps/app/src/i18n/active.json — what a first-time visitor opens in
405
+ { "defaultLocale": "de" }
406
+
407
+ // pikku.config.json — the language the team reads their Console in
408
+ { "locale": "de" }
409
+ ```
410
+
411
+ Identifiers stay English throughout. When a brief tells you the product speaks a
412
+ language, it is telling you about axis three and nothing else.
413
+
324
414
  ## Environment Variables
325
415
 
326
416
  Never use `process.env` inside Pikku functions. Use the `variables` service (see `pikku-config`):
@@ -322,7 +322,12 @@ Why it parked is the whole story, and it is `statusReason`, not `status`:
322
322
  accepts them for that deploy.
323
323
  - `needs_config` — a declared secret or variable has no value covering the
324
324
  stage. The CLI names them. `--auto-approve` will **not** force this through;
325
- set the values (`pikku fabric secrets set <name>`) and re-attach.
325
+ set the values and re-attach — `pikku fabric secrets set <name>` for a
326
+ declared secret, `pikku fabric variables set <name> --value <v>` for a declared
327
+ variable. They are separate stores: a secret is sealed to the stage and cannot
328
+ be read back, a variable is stored plainly and can (`variables get`). `set`
329
+ reads the value as JSON when it parses, so `--value true` is the boolean on a
330
+ stage exactly as it is from `.env`, and `--value '"true"'` is the string.
326
331
  - `needs_attention` — the plan is red. Nothing to approve.
327
332
 
328
333
  `--sync` defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout
@@ -411,6 +416,7 @@ These apply in every Fabric app:
411
416
  - **No hand-editing `.pikku/db/schema.gen.ts`** — write a migration and re-run `pikku db migrate`.
412
417
  - **One runtime unit per file** — never define multiple functions/workflows in a single source file.
413
418
  - **Workflow steps don't need manual wiring** — `pikkuSessionlessFunc` step functions in `*.steps.ts` files are auto-discovered by codegen.
419
+ - **Identifiers are English** — functions, components, types, files, database tables and columns, in every app whatever market it serves. The team's language is `locale` in `pikku.config.json` and reaches `description`/`title`/`template` only; the app's language is the message catalogue, where `baseLocale` stays `en` and `defaultLocale` decides what a visitor opens in. `pikku fabric validate` warns (`app-base-locale-not-english-<app>`) when an app repoints its base.
414
420
 
415
421
  ## Converting an existing app to Fabric format
416
422
 
@@ -426,7 +432,7 @@ Fix every `error` and `warn` in the output before continuing. Then:
426
432
  2. **Replace route handlers with pikkuFuncs**: extract business logic into `pikkuFunc`/`pikkuSessionlessFunc`, add `wireHTTP` or `expose: true` for transport.
427
433
  3. **Replace DI/IoC with pikkuServices**: move service construction to `createSingletonServices` in `services.ts`.
428
434
  4. **Replace `process.env` calls**: plain config becomes `defineVariable` + `variables.get()`, anything sensitive becomes `defineSecret` + `secrets.getSecret()`.
429
- 5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles`.
435
+ 5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles` — plus `locale` if the team does not work in English, which is the language every `description`, `title` and step `template` is then authored in.
430
436
  6. **Add `pikkufabric.config.json`** at project root with `projectId`, `production.domain`, and `frontends` (production is always `main`, so there is no `production.branch`).
431
437
  7. **Run `pikku all`** — verify codegen succeeds and there are no type errors.
432
438
  8. **Run `pikku fabric validate`** once more to confirm no structural issues remain.
@@ -41,6 +41,13 @@ schemas (`yarn pikku meta functions get <id>`) or workflow steps
41
41
  **Capability rule:** do not introduce new wires of a type whose
42
42
  `capabilities.<type>` is `false` unless the user explicitly asked for it.
43
43
 
44
+ **Language rule:** read `locale` in `pikku.config.json` (default `en`). It is
45
+ the language of every `description`, `title` and step `template` you author —
46
+ the meta the Pikku Console renders back to the team. It renames nothing:
47
+ functions, components, types, files, tables and columns are English in every
48
+ project, and what the app says to its users is the message catalogue. See
49
+ `pikku-concepts` → _What Language You Write In_.
50
+
44
51
  ## Stage 2 — State intent in plain English (BEFORE writing code)
45
52
 
46
53
  Before touching any files, give the user one paragraph stating exactly what
@@ -11,10 +11,81 @@ installGroups: [client, fabric]
11
11
  Use this skill as an execution checklist, not reference material.
12
12
 
13
13
  1. Every user-facing string in a frontend is a message. Never hardcode display text — add a key to `messages/en.json` and render `m.the__key()`. This holds even when the app ships only English; the messages are the seam a second language slots into later.
14
- 2. One `messages/<locale>.json` per language at the app root (NOT under `src/`), declared in `project.inlang/settings.json`. English (`en`) is `baseLocale` and the only locale until someone adds another.
14
+ 2. One `messages/<locale>.json` per language at the app root (NOT under `src/`), declared in `project.inlang/settings.json`. English (`en`) is `baseLocale` and the only locale until someone adds another. **`baseLocale` stays `en` whatever language the product speaks** — see [The product's language is not the code's language](#the-products-language-is-not-the-codes-language), which is the first thing to read if the brief says the app is not in English.
15
15
  3. Messages compile to typed ESM functions in `src/paraglide/` (generated, self-gitignored — never edit or commit it). The Vite plugin compiles during `dev`/`build` with HMR on message edits; run the CLI compile only when you need `tsc` before Vite has ever run.
16
16
  4. Validate with the app's own `tsc` then its `build`. The deploy pipeline compiles Paraglide and runs each frontend's `tsc` before building it — an i18n mistake blocks the deploy.
17
17
 
18
+ ## The product's language is not the code's language
19
+
20
+ A brief that says "the entire UI is German, no English strings visible anywhere"
21
+ is a statement about **one** of three separate things, and reading it as a
22
+ statement about the codebase is the single most expensive mistake available in
23
+ this skill. Three axes:
24
+
25
+ | Axis | What it covers | What sets it |
26
+ | --------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
27
+ | **Identifiers** | Function, component, type and file names; database tables and columns | Nothing — always English, no setting |
28
+ | **Meta** | `description` / `name` / `title` / `template` authored inside the code, which the Pikku Console renders | `locale` in `pikku.config.json`, default `en` |
29
+ | **Product UI** | Every string the app shows a user | `messages/<locale>.json` + `defaultLocale` — **this axis only** |
30
+
31
+ A non-English product moves the third row and nothing else.
32
+
33
+ ### `baseLocale` stays `en`
34
+
35
+ `baseLocale` in `project.inlang/settings.json` does not mean "the language the
36
+ app is in". It names the message **source** — the catalogue every other locale is
37
+ cloned from and translated against. Setting it to the product's language looks
38
+ like it works, because the app does come up in that language, and then:
39
+
40
+ - there is no `en.json`, so `--add-locale` has no catalogue to translate from
41
+ - the app can never gain a second language without re-authoring every key
42
+ - a message missing from a locale falls back to a catalogue nobody wrote
43
+
44
+ The setting that actually decides what a first-time visitor sees is
45
+ `defaultLocale`, held in `apps/app/src/i18n/active.json` in the Fabric app
46
+ template and read by `src/i18n/config.ts`. It is deliberately a separate file
47
+ from `settings.json` for exactly this reason — the source language and the
48
+ served language are different questions.
49
+
50
+ So a German medical portal is **three** settings, not one:
51
+
52
+ ```jsonc
53
+ // project.inlang/settings.json — the source catalogue is English
54
+ { "baseLocale": "en", "locales": ["en", "de"] }
55
+
56
+ // apps/app/src/i18n/active.json — what a visitor opens in
57
+ { "defaultLocale": "de" }
58
+
59
+ // pikku.config.json — the language the team reads their Console in
60
+ { "locale": "de" }
61
+ ```
62
+
63
+ In the Fabric template both of the first two have a command, so you rarely edit
64
+ them by hand:
65
+
66
+ ```sh
67
+ fabric i18n --add-locale de # adds "de" to locales, seeds messages/de.json from en.json
68
+ fabric i18n --default-locale de # writes active.json — the app now OPENS in German
69
+ ```
70
+
71
+ ### The failure this is written from
72
+
73
+ A real build, from this template. The brief said the UI was German; the agent
74
+ set `baseLocale: "de"` with `locales: ["de"]` and no `en.json`, then carried the
75
+ same reading into the code — RPC functions `getUebersicht` and
76
+ `getPatientendetail`, components `Zeitstrahl` and `AufmerksamkeitStreifen`,
77
+ helpers `datumDeutsch` and `voraussichtlichFertig`, database tables `vorgang`
78
+ and `ereignis` with German columns.
79
+
80
+ The German UI it was asked for needed none of that. It needed German **values**
81
+ in a catalogue whose keys and source stayed English. What it got instead was a
82
+ project that cannot add a second language and cannot be picked up by anyone who
83
+ does not read German.
84
+
85
+ If you find a project in this state, say so plainly rather than working around
86
+ it: `baseLocale` cannot be repointed without re-keying every message, so it is a
87
+ migration someone has to agree to, not a fix to slip in.
88
+
18
89
  ## The moving parts (starter-template layout)
19
90
 
20
91
  - `messages/en.json` — flat keys, `{param}` interpolation, inlang message-format:
@@ -109,9 +180,10 @@ The gate catches _invalid_ messages but not _inlined_ strings. The `@pikku/manti
109
180
  ## Adding a second language
110
181
 
111
182
  1. `messages/fr.json` mirroring `en.json`'s keys (translate the values, keep `{param}` names identical).
112
- 2. Add `"fr"` to `locales` in `project.inlang/settings.json`.
183
+ 2. Add `"fr"` to `locales` in `project.inlang/settings.json`. **Leave `baseLocale` at `en`** — step 1 only works because there is an English catalogue to mirror.
113
184
  3. Recompile (restart/`vite dev` or the CLI compile). A locale file missing keys falls back to the base locale per message.
114
185
  4. Content is reachable via the `/<lang>` URL prefix (`detectLocale` already resolves it); the base locale needs no prefix. Expose the switcher via `useLocale().setLocale`.
186
+ 5. Only if the app should **open** in the new language rather than merely offer it: set `defaultLocale` (`active.json` / `fabric i18n --default-locale fr`). Adding a locale and changing the default are different asks — do the second only when asked.
115
187
 
116
188
  ## i18n debug mode (find inlined strings)
117
189
 
@@ -137,6 +209,9 @@ The wrapper alternative — a module that walks the namespace and pipes each mes
137
209
  ## What NOT to do
138
210
 
139
211
  - Don't hardcode display strings "just for now" — the message is the work.
212
+ - Don't set `baseLocale` to anything but `en`, whatever language the product speaks. It names the source catalogue, and a project without one can never add a language. Set `defaultLocale` instead.
213
+ - Don't let a non-English UI reach the identifiers. Functions, components, types, files, tables and columns are English in every project; the product's language lives in `messages/*.json` and nowhere else.
214
+ - Don't translate message **keys**. `auth__login__title` stays English in `de.json`; only the value changes.
140
215
  - Don't edit or commit anything under `src/paraglide/` — it's regenerated; change `messages/*.json` instead.
141
216
  - **Don't wrap `m`.** No re-export module, no branding layer, no resolver. Components import `m` from `../paraglide/messages.js` and call it. `@pikku/react`'s `I18nString` is declared as `string & { readonly __brand: 'LocalizedString' }` — deliberately identical to Paraglide's own `LocalizedString` — so `m.some__key()` satisfies the `@pikku/mantine` `I18nNode` gate natively. A wrapper adds nothing and costs per-message tree-shaking.
142
217
 
@@ -184,7 +184,82 @@ const earlyMiddleware = pikkuMiddleware({
184
184
 
185
185
  Within the same priority level, the collection order above is preserved. Use priority when a middleware must run before/after others regardless of where it was registered (e.g. telemetry wrapping everything, session extraction before auth checks).
186
186
 
187
- ## Service-to-Service Bearer Auth (canonical pattern)
187
+ ## ⛔ MACHINE AUTH: THE TOKEN BECOMES A SESSION. ⛔
188
+
189
+ **A caller that has an identity — a sandbox, a deployed stage, a pool host, a device — is authenticated ONCE, in middleware, which calls `setSession`. The function is then an ordinary `pikkuFunc` gated with `scopes`. It reads `session`. It NEVER re-derives who the caller is.**
190
+
191
+ Either a function is sessionless (genuinely public) or it has a session. Anything in between — a token verified inside `func`, a token verified in a `permissions` check that returns `true`, an identity passed in the input schema, the same resolver memoised per request so N functions can each call it — is the anti-pattern this section exists to kill.
192
+
193
+ ```typescript
194
+ // middleware.ts — resolve the bearer ONCE, for every route
195
+ const sandboxBearerAuth = pikkuMiddleware<SingletonServices>(
196
+ async ({ kysely, auth }, { http, getSession, setSession }, next) => {
197
+ if (await getSession?.()) return next()
198
+ const header = http?.request?.header?.('authorization')
199
+ if (!header?.startsWith('Bearer ')) return next()
200
+ const sandbox = await resolveSandboxSession(kysely, auth, header.slice(7).trim())
201
+ if (sandbox) {
202
+ setSession?.({ userId: sandbox.createdByUserId ?? sandbox.sandboxInstanceId,
203
+ orgId: sandbox.organizationId, scopes: ['machine:sandbox'], sandbox } as UserSession)
204
+ }
205
+ return next()
206
+ },
207
+ )
208
+
209
+ addHTTPMiddleware('*', [cors(...), betterAuthSession(), apiBearerAuth, sandboxBearerAuth as any])
210
+ ```
211
+
212
+ ```typescript
213
+ // functions/report-something.function.ts
214
+ export const reportSomething = pikkuFunc({
215
+ expose: true,
216
+ scopes: ['machine:sandbox'], // ← the gate. Enforced by the runner, seen by the inspector.
217
+ input: ReportSomethingInput,
218
+ output: ReportSomethingOutput,
219
+ func: async ({ kysely }, input, { session }) => {
220
+ const sandbox = sandboxOf(session) // ← narrowing only, no verification
221
+ ...
222
+ },
223
+ })
224
+ ```
225
+
226
+ An unresolved token leaves the session unset and the function throws `MissingSessionError` — 401, for free. Declare the scope tree once with `defineScope` (see `pikku-permissions`).
227
+
228
+ ### It MUST be `addHTTPMiddleware`, never `addTagMiddleware`
229
+
230
+ **A session set in tag middleware is invisible to the function when the call arrives over `POST /rpc/:rpcName`.** Tag middleware runs inside `runPikkuFunc`, and the RPC dispatch calls it without a `sessionService`, so `invocationWire.session` is never re-read after your `setSession` — the function sees the session the OUTER wire had, which is none. `addHTTPMiddleware('*')` runs on the `/rpc` route itself, before its handler, and that session is the one the dispatched function inherits. Tag middleware is still right for a gate that only says yes/no.
231
+
232
+ ### A cron is a machine identity too — set it in the task's own middleware
233
+
234
+ A scheduled task has no caller and no header, but it is still a machine principal, and without a session it cannot invoke a gated RPC or be attributed in anything it writes. Give it one the same way, in the task's own `middleware`:
235
+
236
+ ```typescript
237
+ const cronSession = pikkuMiddleware(async (_services, { scheduledTask, setSession }, next) => {
238
+ setSession?.({ userId: `cron:${scheduledTask?.name}`, scopes: ['machine:cron'] } as UserSession)
239
+ return next()
240
+ })
241
+
242
+ wireScheduler({
243
+ name: 'tickVirtualUserSchedules',
244
+ schedule: '*/15 * * * *',
245
+ middleware: [cronSession],
246
+ func: tickVirtualUserSchedules,
247
+ })
248
+ ```
249
+
250
+ One `const`, not a `machineSession(name)` factory: the inspector rejects a bare `pikkuMiddleware()` that is not assigned to a variable or object property, and the task name is on the wire anyway. Parameterised middleware goes through `pikkuMiddlewareFactory`.
251
+
252
+ The task can then be a thin `rpc.invoke('someGatedRpc')` against the same entry point a person calls, instead of factoring the logic into a `lib/` helper purely to route around the missing identity.
253
+
254
+ Unlike tag middleware over `/rpc`, this works: `runScheduledTask` builds its wire with a `sessionService`, so the session set here is the one the function is frozen with. And unlike a person, a cron is **not** a user row — inventing a seeded account for it buys a phantom member in every list, seat count and bill, and a per-org membership that a cross-org sweep has to ignore anyway. Platform-wide authority is a scope, not a membership.
255
+
256
+ ### The one sessionless exception: bootstrap
257
+
258
+ An endpoint that runs BEFORE the caller has an identity — registering a new host with a shared bootstrap key, a login, a device-code request — has no session to set. That one stays `pikkuSessionlessFunc` and declares its gate in `permissions` (see `pikku-permissions`).
259
+
260
+ ## Service-to-Service Bearer Auth (gate-only pattern)
261
+
262
+ Use this when the callee needs to know only THAT the caller is trusted, not WHICH caller it is. If it needs to know which, use the session pattern above.
188
263
 
189
264
  A server that exposes RPCs only to a trusted caller (e.g. an API calling a machine-agent). Auth lives in a tag middleware — NOT in the function body. Authorization/permission checks belong in the `permissions` field (see `pikku-permissions`), never inside `func`.
190
265
 
@@ -13,6 +13,10 @@ installGroups: [core]
13
13
 
14
14
  # Pikku Permissions
15
15
 
16
+ ## ⛔ FIRST: is the caller a machine with a token? ⛔
17
+
18
+ **Then this is NOT a permissions problem.** Resolve the token in `addHTTPMiddleware('*')` middleware that calls `setSession`, make the function a `pikkuFunc`, and gate it with `scopes`. A `permissions` check that verifies a bearer token and returns `true` is authentication wearing an authorization hat — and it leaves the function sessionless, so every body still has to work out who called it. See the machine-auth section of `pikku-middleware`. The only exception is a bootstrap endpoint whose caller has no identity yet (a shared-secret registration, a login): that one is sessionless and declares its gate here.
19
+
16
20
  ## The Rule
17
21
 
18
22
  **ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**
@@ -356,6 +356,56 @@ const order = await scenario.do(
356
356
 
357
357
  Reach for a `pikkuScenarioStep` on the non-browser side only when one intent genuinely spans several RPCs, or when the step asserts something the RPC result alone does not say.
358
358
 
359
+ ### What language the prose is in
360
+
361
+ A scenario carries two kinds of text, and they do not share a language.
362
+
363
+ **Identifiers are English.** The exported const (`buysAnApple`,
364
+ `credentialFeature`), the step's `name` — which is its `pikkuFuncId`, the typed
365
+ string the generated step map is keyed by — the file name, and every helper in
366
+ `*.browser.ts`. These bind to generated code and to `pikku scenario list`; they
367
+ are English in every project regardless of who the product is for or what
368
+ language the team speaks. There is no setting that changes this.
369
+
370
+ **Prose follows `locale` in `pikku.config.json`** (default `en`). That is a
371
+ step's `description` and `template`, a feature's `name` and `description`, a
372
+ scenario's `title`, and the positional step names passed to
373
+ `scenario.given/when/then`. Read the field before you write any of them.
374
+
375
+ This split is the same one the feature table already states — _the export
376
+ identifier is the feature's id; `name` is the human-readable label_ — applied to
377
+ language. The report is the deliverable, and it is read by the team; the
378
+ identifier is an API, and it is read by the toolchain.
379
+
380
+ ```typescript
381
+ // pikku.config.json: { "locale": "de" }
382
+ export const buysAnApple = pikkuScenarioStep<{ qty: number }, { orderId: string }>({
383
+ name: 'buysAnApple', // identifier — English, always
384
+ description: 'kauft einen Apfel', // prose — follows locale
385
+ template: 'kauft {qty} Äpfel', // prose — follows locale
386
+ actor: true,
387
+ default: async (_services, { qty }, { actor }) =>
388
+ await actor.invoke('placeOrder', { qty }),
389
+ })
390
+ ```
391
+
392
+ Note what does **not** change: `placeOrder` is still `placeOrder`, and the file
393
+ is still `apple.scenario.ts`.
394
+
395
+ A product with a non-English UI is not on its own a reason to set `locale` — that
396
+ is the app's language, not the team's. Ask, or leave it `en`.
397
+
398
+ **Where a non-`en` `locale` still shows English, today.** The reporter composes a
399
+ sentence as `<Keyword> the <actor> <template>` (`composeStepProse`), and both the
400
+ keyword and the article `the` are English literals. The Console translates the
401
+ Given/When/Then keywords into its own UI language; the CLI reporter does not, and
402
+ nothing translates `the`. So `locale: "de"` gives you German step prose inside an
403
+ English frame — `Given the shopper kauft 1 Äpfel`. Write templates that read
404
+ acceptably in that frame rather than trying to defeat it. A second gap: where a
405
+ function or scenario declares no `title`, the Console falls back to splitting the
406
+ **identifier** into an English-looking label (`toEnglishName`), so under a
407
+ non-`en` `locale` meta is worth authoring rather than leaving to the fallback.
408
+
359
409
  ### `then` bindings are witnesses, not alternatives
360
410
 
361
411
  This is the one place the surface bindings do **not** behave like a switch, and it is the part worth reading twice.
@@ -783,6 +833,7 @@ Services are plain objects — a Pikku function is pure business logic, so a moc
783
833
  | Assuming a clean database | There is no state reset — it may be a staging server. Scope what you create. |
784
834
  | `sleep()` before asserting | Use `expectEventually`. |
785
835
  | A step named `clicksAddToBasket` / `opensThePage` | That is an action, not an intent. Name the step for what the actor wanted; put the clicking in a utility. |
836
+ | A step named `kauftEinenApfel` / a `vorgang` table | Identifiers are English in every project. The German belongs in `description` / `template`, and only when `pikku.config.json` sets `locale`. |
786
837
  | A browser step that assumes it is already on a page | It can then only run mid-flow. Arrive first — check the URL, navigate if needed. |
787
838
  | `getByLabel('Full Name')` in a translated app | Passes only in the base locale, and a copy edit breaks it as an unexplained timeout. Locate by message key. |
788
839
  | A `browser` binding guarding `if (!browser)` | The binding guarantees it. The guard hides the real error, which is a missing actor (`PKU677`). |
@@ -50,6 +50,29 @@ wireScheduler({
50
50
  })
51
51
  ```
52
52
 
53
+ ### Giving a cron an identity
54
+
55
+ A cron has no caller, so it runs with **no session at all**: it cannot invoke a
56
+ permission- or scope-gated RPC, and nothing it writes can be attributed. A
57
+ scheduled task is a machine principal — give it a session in the task's own
58
+ `middleware`, exactly as a bearer-authenticated caller gets one:
59
+
60
+ ```typescript
61
+ wireScheduler({
62
+ name: 'bookingLifecycleDaily',
63
+ schedule: '0 3 * * *',
64
+ middleware: [cronSession],
65
+ func: bookingLifecycleDaily,
66
+ })
67
+ ```
68
+
69
+ `runScheduledTask` builds its wire with a `sessionService`, so a `setSession`
70
+ here is the session the function is frozen with. See the machine-auth section of
71
+ `pikku-middleware` for the factory and for why a cron is not a user row.
72
+
73
+ A scheduler service running a task on someone's behalf can pass a session
74
+ directly instead: `runScheduledTask({ name, session })`.
75
+
53
76
  ### Wire Object (`wire.scheduledTask`)
54
77
 
55
78
  Inside scheduled functions: