create-flowdular 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  2. package/agent-template/.agents/skills/module-update/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/perf-audit/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  5. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  6. package/agent-template/.ai/agents/README.md +1 -1
  7. package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -1
  8. package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -1
  9. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -4
  10. package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -1
  11. package/agent-template/.ai/agents/sandbox/ux-designer.md +1 -1
  12. package/agent-template/.ai/platform-capabilities.md +4 -4
  13. package/agent-template/.ai/policies/model-routing.yaml +2 -1
  14. package/agent-template/.ai/policies/task-budgets.yaml +1 -1
  15. package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.up.sql +2 -2
  16. package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.up.sql +2 -2
  17. package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +2 -2
  18. package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +2 -2
  19. package/agent-template/.ai/references/catalog/module.json +3 -3
  20. package/agent-template/.ai/references/catalog/package.json +2 -2
  21. package/agent-template/.ai/references/catalog/spec/module.yaml +3 -3
  22. package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +2 -2
  23. package/agent-template/.ai/references/catalog/src/services/migration.ts +8 -8
  24. package/agent-template/.ai/references/catalog/tests/migrations.test.ts +1 -1
  25. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  26. package/agent-template/.ai/skills/module-update/SKILL.md +1 -1
  27. package/agent-template/.ai/skills/perf-audit/SKILL.md +1 -1
  28. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  29. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  30. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  31. package/agent-template/.claude/skills/module-update/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/perf-audit/SKILL.md +1 -1
  33. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  34. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  35. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  36. package/agent-template/docs/agent-contract.md +1 -1
  37. package/agent-template/docs/cli-extensions.md +1 -0
  38. package/agent-template/docs/cli.md +16 -0
  39. package/agent-template/docs/configuration.md +32 -4
  40. package/agent-template/docs/database-adapters.md +10 -2
  41. package/agent-template/docs/design-system.md +6 -2
  42. package/agent-template/docs/getting-started.md +5 -1
  43. package/agent-template/docs/module-distribution.md +1 -2
  44. package/agent-template/docs/modules.md +5 -3
  45. package/agent-template/docs/sandbox.md +64 -7
  46. package/package.json +1 -1
  47. package/template/default/.env.example +5 -0
  48. package/template/default/infra/docker/.env.example +5 -0
  49. package/template/default/infra/docker/Dockerfile +5 -1
  50. package/template/default/infra/docker/compose.yaml +4 -0
  51. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  52. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  53. package/template/default/infra/vercel/build.mjs +9 -0
  54. package/template/default/modules/example/package.json +1 -1
  55. package/template/default/package.json +2 -3
  56. package/template/default/platform/octane.config.ts +53 -15
  57. package/template/default/platform/package.json +1 -1
  58. package/template/default/platform/scripts/dev.mjs +66 -21
  59. package/template/default/platform/src/server/lifecycle.ts +325 -0
  60. package/template/default/platform/src/server/setup/modules.ts +28 -32
  61. package/template/default/platform/src/server/setup/page.ts +54 -3
  62. package/template/default/platform/src/server/setup/routes.ts +1 -0
  63. package/template/default/platform/src/server/setup/seed.ts +51 -4
  64. package/agent-template/.ai/references/catalog.provenance.json +0 -65
@@ -20,3 +20,5 @@ The first setting is `auth.core.allowSignUp`. The server is authoritative and re
20
20
  - Administration: `GET /api/settings` (`system.settings.read`) lists every declaration with its current tenant value and metadata; `POST /api/settings/update` (`system.settings.manage`, session only) validates against the declaration, stores or clears the value, and appends `settings.updated` to the auth audit trail. Secrets are write-only: the API returns whether a value is set, never the value. Administration > Modules renders the selected module's declarations in its Settings drawer section. Administration > Settings contains only workspace and organization settings.
21
21
  - `emailConfirmation` cannot be enabled while no mail transport is composed; the API refuses with `MAIL_TRANSPORT_REQUIRED` and the screen shows the setting as locked.
22
22
  - The cross-module read rule (declared dependency, shared, non-secret) is not enforced by `get`; it is a review rule until a requester-aware read exists.
23
+ - Platform-scoped writes (amendment, 2026-10-06): every workspace owner holds `system.settings.manage`, so `POST /api/settings/update` changes a platform-scoped setting only for a principal of the operator workspace, the tenant id the deployment sets in `FD_OPERATOR_TENANT`, and refuses anyone else with 403 `PLATFORM_SETTING_OPERATOR_ONLY`; `GET /api/settings` locks those rows outside it. Unset, no workspace changes one.
24
+ - Recorded operator workspace (amendment, 2026-10-06, 0.6.1): auth.core records the operator workspace in `auth_operator_workspace`, at most one row under forced row-level security bound to the workspace it names. Any path that creates the first workspace of an empty database records it in the same transaction, a deployment with exactly one workspace and no record records it when auth.core opens the database, and `pnpm flowdular auth operator-set` is the only way to change it. `FD_OPERATOR_TENANT` becomes an override: set, it alone decides, and a value that is not a workspace id leaves no operator. system.core resolves the operator on every settings request; while none is known every platform row is locked with `system.settings.platformOperatorUnset` and the write refusal keeps `PLATFORM_SETTING_OPERATOR_ONLY`. This supersedes "unset, no workspace changes one" above.
@@ -18,7 +18,7 @@ Use the already selected task skill. Consult `.ai/references/catalog` for implem
18
18
  7. Every mutation calls `sessionMutationDenial(octane, auth)` first and reads its body with `readJsonObject` plus `requiredString`, `optionalString`, `requiredInteger`. Clients send `content-type: application/json`, `x-csrf-token`, and `credentials: 'same-origin'`.
19
19
  8. Routes mount only through `src/platform.ts` exporting `createServerComposition(context)` with `platform.server: true` in `module.json` and a `./platform` export in `package.json`; the context carries `auth`, `settings`, `agentTools`, `agentDefinitions`, `capabilities` and `databases`. A database module passes `context.databases` into one runtime, which acquires and releases one provider lease lazily; `prepare` stays read-only and never opens a database. A composition may return `settings`, read-only `prepare`, `start`, background-work `stop`, and final `dispose`. Client contributions mount only through `createClientContribution(context)` in `src/client/index.ts` with `platform.client: true`. `pnpm flowdular module validate` fails on a missing entry (`PLATFORM_*`).
20
20
  9. Never edit the composition by hand: `platform/octane.config.ts`, `platform/src/App.tsrx`, `platform/src/generated/**`, `platform/package.json` dependencies and `modules.enabled` in `flowdular.json` are written by `pnpm flowdular module enable <id> --apply` and `pnpm flowdular module sync --apply`.
21
- 10. `pnpm flowdular module enable <id> --apply` grants the spec permissions to every tenant owner as its last step; `pnpm flowdular auth sync-scopes --module <id> --apply` re-grants later (new permission, another database). Members receive scopes only through `MEMBER_SCOPES` in `modules/auth/src/acl/scopes.ts`, a core change.
21
+ 10. `pnpm flowdular module enable <id> --apply` grants the spec permissions to every tenant owner as its last step; first-run setup grants every enabled module's permissions to the owner it creates, so a module enabled before the first workspace needs no extra step; `pnpm flowdular auth sync-scopes --module <id> --apply` re-grants later (new permission, another database). Members receive scopes only through `MEMBER_SCOPES` in `modules/auth/src/acl/scopes.ts`, a core change.
22
22
  11. Declare every imported package in the module `package.json`; the sandbox `dependencies` gate and the eject fail otherwise. Every relative import carries its `.ts` or `.tsrx` extension.
23
23
  12. Use the CLI for discovery, validation and scaffolding: `doctor`, `spec validate`, `module validate`, `module new`, `module enable`, `auth sync-scopes`. Run destructive, external or production capabilities only with what the runner demands (`--apply`, `--confirm`, `--spec`) and never work around a refusal.
24
24
  13. Gates are `spec-schema`, `module-schema`, `dependencies`, `typecheck`, `tests`, `format`. The sandbox runs `dependencies` plus the ones your role lists after every turn, per draft module, and feeds a failure back to you; with a shell you may run the module's own gate commands yourself, never installs, network or git. From a checkout run them yourself and `pnpm verify` before any pull request.
@@ -77,6 +77,7 @@ The CLI imports this code only after the exact command or capability is invoked.
77
77
  - External and non-local destructive module capabilities remain disabled until a signed approval verifier is configured.
78
78
  - A workspace-local destructive capability must declare `localOnly` and a typed confirmation token. It remains dry-run unless both `--apply` and the exact `--confirm` value are present, and it is blocked outside development and test.
79
79
  - `capability list`, `capability describe`, and `capability run` use the same descriptors and handlers as direct commands.
80
+ - A command refuses by throwing. An error with a stable `code` (`^[A-Z][A-Z0-9_]{0,63}$`) and a numeric HTTP `status`, the shape of every module service error, keeps its code in the envelope and the human output. So does a coded error from a platform service the runner hands the command, such as a `DatabaseError` from `context.databases` or a refused local database. Any other error is reported as `COMMAND_FAILED`. Only the code and the message are printed, never the stack, the cause or other fields.
80
81
  - Core commands may reuse an extension: `module enable <id> --apply` runs the `auth.scopes.sync` capability of `auth.core` after regenerating the composition, so a freshly enabled module is visible to workspace owners without a second command.
81
82
 
82
83
  The complete customer example is in `.ai/examples/customer-cli-extension`. New module scaffolds include the catalog and implementation files when the approved spec declares the `cli` capability.
@@ -134,6 +134,8 @@ flowdular auth sync-scopes --module <id> [--apply] # re-grant a module's scopes
134
134
  flowdular auth workspaces [--limit <n>] # workspaces of this deployment and their owners
135
135
  flowdular auth workspace-create --name <name> --owner-email <email> --owner-name <name> [--slug <id>] [--password-env <VAR>] [--actor <label>] [--apply]
136
136
  flowdular auth member-add --workspace <slug|id> --email <email> [--role <key>] [--actor <label>] [--apply]
137
+ flowdular auth operator # the recorded operator workspace and whether FD_OPERATOR_TENANT overrides it
138
+ flowdular auth operator-set <id|slug> [--actor <label>] [--apply]
137
139
  flowdular auth secrets-rotate [--apply] # re-seal enrolled TOTP secrets with the current MFA key
138
140
  flowdular auth greenfield # destructive local auth reset (setup quick)
139
141
 
@@ -197,6 +199,20 @@ Both commands append an audit row to the workspace trail whose actor is
197
199
  what exists, with each workspace's owners, so the slug or id for the other
198
200
  commands is at hand.
199
201
 
202
+ The first workspace of an empty database is recorded as the operator
203
+ workspace, the one whose owners change the branding and the other platform
204
+ settings. `auth operator` shows the record and says whether
205
+ `FD_OPERATOR_TENANT` is set in that shell, which overrides the record wherever
206
+ the deployment sets it. `auth operator-set` names another workspace, previews
207
+ without `--apply`, and with it releases the current operator and records the
208
+ new one, each with an audit row in that workspace's trail. If it fails between
209
+ the two, no workspace is the operator until the same command runs again.
210
+
211
+ ```bash
212
+ flowdular auth operator
213
+ flowdular auth operator-set northwind --apply
214
+ ```
215
+
200
216
  ## Workspace scripts
201
217
 
202
218
  ```bash
@@ -18,6 +18,32 @@ deployments must set the secret keys.
18
18
  | `FD_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` |
19
19
  | `FD_METRICS` | `false` | Expose `GET /api/metrics`; see [operations.md](operations.md) |
20
20
  | `FD_METRICS_TOKEN` | none | Bearer token a metrics scrape must present |
21
+ | `FD_OPERATOR_TENANT` | none | Tenant id that overrides the recorded operator workspace |
22
+
23
+ A platform-scoped module setting has one value for every workspace: the
24
+ branding, sign-up and session policy, the mail relay and the platform settings
25
+ of other modules. Only the operator workspace changes them: a principal there
26
+ holding `system.settings.manage` edits them in Administration. Every other
27
+ workspace sees them read-only, and a write from it is refused with 403
28
+ `PLATFORM_SETTING_OPERATOR_ONLY`.
29
+
30
+ auth.core records the operator workspace. First-run setup records the workspace
31
+ it creates, in the transaction that creates it, and so does any other path that
32
+ creates the first workspace of an empty database (`auth workspace-create`,
33
+ `sandbox provision`, `setup quick`). `pnpm flowdular auth operator` shows the
34
+ record and `pnpm flowdular auth operator-set <id|slug> --apply` moves it to
35
+ another workspace; the next request sees the change, with no restart.
36
+
37
+ `FD_OPERATOR_TENANT` is an override. Set, the workspace whose tenant id it names
38
+ is the operator and the record is not consulted; a value that is not the id of
39
+ an existing workspace leaves no operator rather than falling back to the record.
40
+ Leave it empty to use the record.
41
+
42
+ A deployment upgraded from 0.6.0 with exactly one workspace records that
43
+ workspace the first time it starts. With two or more nothing is recorded, every
44
+ platform row is locked with a reason that names `auth operator-set`, and the
45
+ stored values keep applying until the operator runs that command or sets the
46
+ variable.
21
47
 
22
48
  A `web` process serves HTTP only: it never starts a module worker and never
23
49
  claims queued work from a request, so a deployment of `web` processes also needs
@@ -33,7 +59,8 @@ A Vercel deployment works this way; see
33
59
 
34
60
  The name, the document title, the description, the link preview image, the
35
61
  browser icon, the theme colour and the logo are not environment variables: they
36
- are `system.core` settings an owner with `system.settings.manage` changes under
62
+ are platform-scoped `system.core` settings a principal with
63
+ `system.settings.manage` in the operator workspace changes under
37
64
  Administration, Branding, and every change is audited. One value serves the
38
65
  whole deployment, so the sign-in screen and a shared link carry it too, and a
39
66
  setting nobody changed renders the product's own.
@@ -622,9 +649,10 @@ deployment on the old names keeps working and the server logs one
622
649
  replacement. The platform name wins when both are set, and every refusal names
623
650
  the variable the deployment actually set.
624
651
 
625
- The relay is also five platform-scoped `auth.core` settings, edited under
626
- Administration, Modules: `mailTransport` (`environment`, `none` or `smtp`),
627
- `mailSmtpUrl` (secret, write only), `mailFrom`, `mailRequireTls` and
652
+ The relay is also five platform-scoped `auth.core` settings, edited from the
653
+ operator workspace under Administration, Modules: `mailTransport`
654
+ (`environment`, `none` or `smtp`), `mailSmtpUrl` (secret, write only),
655
+ `mailFrom`, `mailRequireTls` and
628
656
  `mailRejectUnauthorized`. `mailTransport` decides which source wins. It is
629
657
  `environment` by default, and while it stays there the `FD_MAIL_*`
630
658
  configuration above is in effect exactly as described, deprecation warnings
@@ -32,7 +32,12 @@ const databases = createDatabaseProvider(config, {
32
32
  });
33
33
  ```
34
34
 
35
- `@flowdular/sdk/database` owns no driver, so the caller supplies both. Composition
35
+ `@flowdular/sdk/database` owns no driver, so the caller supplies both. The provider
36
+ listens for the `error` events node-postgres emits on a pool and on a leased
37
+ client, so a connection the server closes (a suspended compute, a failover, a
38
+ restart) costs at most the query or transaction it interrupts, never the
39
+ process; a factory
40
+ needs no listener of its own. Composition
36
41
  injects the result as `PlatformServerContext.databases`, and that is the only
37
42
  way a module reaches storage. `GET /api/ready` reports the live adapter and
38
43
  answers 503 while the database is unreachable.
@@ -72,7 +77,10 @@ maximum.
72
77
 
73
78
  With the `pglite` adapter the data directory is the only setting that applies.
74
79
  Under `NODE_ENV=test` the directory is ignored and the database is held in
75
- memory.
80
+ memory. A directory is open once per process: every provider on it in that
81
+ process shares the one embedded database (a development server composes more
82
+ than one), and another process is refused with `LOCAL_DATABASE_LOCKED` until
83
+ the last of them closes it.
76
84
 
77
85
  ## Three roles
78
86
 
@@ -233,7 +233,10 @@ or than the narrowest table beside it; more than two actions always sit in the
233
233
  More menu, and the column then keeps the width of that one button. The menu
234
234
  opens over the page, arrows, Home and End move between items, Enter or Space
235
235
  runs one, and Escape or Tab closes it and returns focus to the button. A refused
236
- action stays in the menu, announced as unavailable, with its `reason`.
236
+ action stays in the menu, announced as unavailable, with its `reason`. The
237
+ action column stays pinned to the right edge while the table scrolls, except a
238
+ single action, which never folds: in a card narrower than three times its
239
+ column it scrolls with the row instead of covering the cells beside it.
237
240
 
238
241
  **Clickable rows.** A table with `onSelect` makes its first cell a button, so
239
242
  Tab reaches the row and Enter or Space opens it. That column holds text, never
@@ -442,7 +445,8 @@ Rendered by components, not written by hand: `ui-page-head*`, `ui-search`,
442
445
  `ui-table__lead` with `ui-table__toggle` and `ui-table__open` (the first cell's
443
446
  expand and open buttons), `ui-table__hide-*` (a column hidden below that width),
444
447
  `ui-table__reveal-*` (the expand button, details list and detail shown below
445
- that width), `ui-table--fold-*` (the action fold step),
448
+ that width), `ui-table--fold-*` (the action fold step), `ui-table--unpin-*`
449
+ (the step below which a single action scrolls with its row),
446
450
  `ui-table__placeholder-body` (the loading and empty row content),
447
451
  `ui-table__details` (+`-list`), `ui-table__detail`,
448
452
  `ui-table-more` (+`--always`), `ui-table-menu` (+`__scrim`, `__label`),
@@ -33,7 +33,11 @@ workspace and owner account. Embedded PostgreSQL is already configured. Restart
33
33
  Vite HMR covers TSRX, TypeScript and styles. The launcher keeps tool warnings
34
34
  quiet; use `pnpm dev -- --verbose` for full diagnostics. `pnpm dev` runs
35
35
  `module sync` first, so a composition change is picked up without a manual
36
- step. The session lives in an HttpOnly cookie and carries the scopes of the
36
+ step. Ctrl+C, SIGTERM or a closed terminal stops the server after open requests
37
+ finish and background work drains, within six seconds. A second Ctrl+C does not
38
+ cut that short, because pnpm already delivers every Ctrl+C more than once;
39
+ Ctrl+\ stops it at once.
40
+ The session lives in an HttpOnly cookie and carries the scopes of the
37
41
  selected tenant membership. A bookmark pointing at another workspace you
38
42
  belong to switches the session on load.
39
43
 
@@ -77,8 +77,7 @@ release. No direct registry installation path remains.
77
77
  Automation that imported `installModule` from `flowdular/distribution` must use
78
78
  the host CLI plan and apply commands; that direct install export was removed.
79
79
 
80
- The old `official-modules` Sandbox delivery target was removed. Change
81
- `sandbox.delivery.targets` to `workspace` or `git-pr`; `git-pr` points to the
80
+ Sandbox delivery targets are `workspace` and `git-pr`; `git-pr` points to the
82
81
  platform repository configured in `flowdular.json`. A catalog publisher can
83
82
  use any Git repository and publish immutable artifacts independently. Historical
84
83
  review and RFC documents retain the old project name as provenance.
@@ -2,7 +2,7 @@
2
2
 
3
3
  A module is a self-contained slice of the product: its own permissions,
4
4
  endpoints, migrations, services, screens, translations and tests, wired into the
5
- platform without touching a core file. `.ai/references/catalog` is the pinned reference
5
+ platform without touching a core file. `.ai/references/catalog` is the reference
6
6
  implementation; copy its shape.
7
7
 
8
8
  ## Lifecycle
@@ -181,7 +181,9 @@ package is not linked yet, and regenerates
181
181
  grants the scopes declared by each newly enabled module to every workspace owner
182
182
  through `auth sync-scopes`; a failed grant is reported as
183
183
  `MODULE_SCOPES_SYNC_FAILED`, and when `auth.core` is unavailable the grant is
184
- skipped with a warning.
184
+ skipped with a warning. That grant reaches only workspaces that already exist,
185
+ so first-run setup grants the permissions of every enabled module to the owner
186
+ it creates.
185
187
 
186
188
  `disable` refuses while another enabled module depends on the target, then
187
189
  removes it and regenerates. `system.core` and `auth.core` are protected.
@@ -417,4 +419,4 @@ RuleSync generates the discovery copies for supported coding agents:
417
419
  `test-hardening`, `cli-extension`, `agent-tool-design`,
418
420
  `business-agent-design`, `workflow-development`, `release-eject-pr`.
419
421
 
420
- Official business modules are maintained outside core. See [module distribution](module-distribution.md) for install, update, lock verification and release checks.
422
+ Business modules kept outside this repository install through [module distribution](module-distribution.md), which covers install, update, lock verification and release checks.
@@ -57,7 +57,18 @@ written.
57
57
 
58
58
  An application already serving on the platform port is left alone, and no
59
59
  credential is prepared for it. `--platform` and `--platform-port` say otherwise
60
- explicitly; `--no-platform` never starts one.
60
+ explicitly; `--no-platform` never starts one. An application the launcher
61
+ started stops with it, including when the terminal closes or the launcher is
62
+ killed. It gets eight seconds to drain before it is killed, two more than the
63
+ six the development server gives itself; both figures live in
64
+ `@flowdular/sdk/dev-console/shutdown`. The installs, gates and Git commands the
65
+ sandbox runs stop with it the same way.
66
+
67
+ One sandbox runs per workspace. A second launcher, or `pnpm eval`, on a
68
+ workspace whose sandbox is running exits with the PID of the process that holds
69
+ it. The hold is the directory `.flowdular/sandbox/workspace.lock`; it goes away
70
+ when that process ends, and one left by a process that was killed outright is
71
+ taken over by the next start.
61
72
 
62
73
  ## The credential is prepared, not pasted
63
74
 
@@ -325,7 +336,13 @@ and a human review remain the repository's own gate.
325
336
  ## Decisions the specialist needs
326
337
 
327
338
  A specialist that cannot continue without a business decision ends its reply
328
- with one fenced block tagged `questions` holding a single JSON object:
339
+ with one fenced block tagged `questions` holding a single JSON object. An
340
+ implementer (every role but the business manager) asks this way for anything
341
+ the approved specification does not decide, such as a new error code, field,
342
+ permission, state or changed behaviour, and leaves that part unbuilt instead of
343
+ deciding it and mentioning it in prose. Every turn's instruction carries the
344
+ format and the limits below from `questions.ts`, the module that parses the
345
+ block.
329
346
 
330
347
  ````
331
348
  ```questions
@@ -349,9 +366,17 @@ of 1 to 120 characters, and a recommendation that must be one of those options.
349
366
  `allowFreeText` defaults to false, and a question with neither an option nor
350
367
  free text cannot be answered, so it is refused. The block has to be the last
351
368
  thing in the reply apart from the mandatory handoff line, and a reply carries at
352
- most one. A block the sandbox cannot read is a warning on that turn, never a
353
- failed turn: the words of the reply still stand and the transcript says why the
354
- block was ignored.
369
+ most one. A block the sandbox cannot read is never a failed turn and never
370
+ dropped in silence: the transcript says why it was refused, and the specialist
371
+ gets the reason and these limits for one repair turn. A second refusal in a row
372
+ stops for the operator, who answers in words. A turn that asks stops for the
373
+ answers, never for approval, and the approval route refuses a module with open
374
+ questions (`409 QUESTIONS_PENDING`). A gate that failed in the same turn waits
375
+ too: the handoff names it, the gates run again after the answering turn, and a
376
+ failure that remains then goes back to the specialist. Such a session is in the
377
+ `awaiting-answers` state, so `awaiting-approval` only ever means an approval.
378
+ `sandbox.core` has no state for open questions, so the platform record keeps
379
+ `awaiting-approval` for it.
355
380
 
356
381
  A readable block is stored on the session as `pendingQuestions`, with the
357
382
  transcript sequence of the message that asked, the role that asked and the
@@ -370,8 +395,11 @@ POST /sandbox/api/sessions/:id/answers
370
395
  ```
371
396
 
372
397
  behind the same origin, header and ownership checks as every other mutation. It
373
- clears `pendingQuestions` and starts the next turn in the role that asked, in
374
- the module it asked about, with the decisions leading the request text:
398
+ clears `pendingQuestions` and starts the next turn in the module the questions
399
+ were about, with the decisions leading the request text. The business manager's
400
+ own questions go back to it. An implementer's questions go to the business
401
+ manager first (`answeringRole` in `planning.ts`), and so does an answer the
402
+ operator types in the message box instead:
375
403
 
376
404
  ```
377
405
  Decisions:
@@ -387,6 +415,35 @@ INVALID_INPUT` for a body that leaves a question unanswered, names a question
387
415
  the session did not ask, exceeds 400 characters, or gives an answer that is not
388
416
  one of the offered options when the specialist allowed no free text.
389
417
 
418
+ ### Answers to an implementer's questions
419
+
420
+ The business manager applies the answers to `spec/module.yaml`, and the text it
421
+ leaves behind decides what happens next:
422
+
423
+ - An answer that changes what the module must do is recorded in the
424
+ specification, which goes back to `draft`. Its hash no longer matches the
425
+ approval, so the session waits in `awaiting-approval` with the implementer
426
+ that asked as the next role, and implementation is refused until the operator
427
+ approves the new hash. Approving resumes that implementer with its decisions.
428
+ - An answer the approved text already decides leaves the file untouched. The
429
+ approval still holds, and the implementer that asked continues at once with
430
+ its decisions.
431
+
432
+ The wait is read from the transcript (`specFollowUp` in `planning.ts`): the
433
+ newest handoff that is not the business manager's own is the implementer's
434
+ question, so the implementer still resumes when the business manager asks a
435
+ question of its own in between. A gate the implementer left failing runs again
436
+ on the business manager's turn. When it still fails, only a repair the business
437
+ manager takes in that module runs first; any other waits until the implementer
438
+ has resumed with its decisions, and the gates run again after that turn.
439
+
440
+ The `module-rules` gate backs the rule for permissions: `permissions-specified`
441
+ fails a module whose `src/acl/permissions.ts`, or an inline `permission:`
442
+ value, names a permission the specification does not list. Error codes have no
443
+ structured list in a specification and the shipped modules throw many codes
444
+ their specifications never name, so a matching rule for them would fail correct
445
+ modules; the instruction and the question card cover them.
446
+
390
447
  ## Operator commands
391
448
 
392
449
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-flowdular",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Scaffold a Flowdular application: the platform, one example module and the secrets a fresh install needs.",
6
6
  "license": "MIT",
@@ -84,6 +84,11 @@ FD_AUTH_MAIL_TRANSPORT=none
84
84
  FD_AUTH_SMTP_URL=
85
85
  FD_AUTH_MAIL_FROM=
86
86
 
87
+ # Tenant id that overrides the operator workspace, the one that changes platform
88
+ # settings such as the branding and the mail relay. Empty, the workspace auth.core
89
+ # recorded at first-run setup decides (pnpm flowdular auth operator shows it).
90
+ FD_OPERATOR_TENANT=
91
+
87
92
  # Read by infra/docker/compose.yaml only. It builds the three URLs above from
88
93
  # these passwords and publishes the container port on FD_PORT.
89
94
  FD_POSTGRES_SUPERUSER_PASSWORD=
@@ -61,6 +61,11 @@ FD_AUTH_PUBLIC_ORIGIN=
61
61
  # behind TLS, set true and set FD_AUTH_PUBLIC_ORIGIN to its HTTPS origin.
62
62
  FD_AUTH_SECURE_COOKIE=
63
63
 
64
+ # Tenant id that overrides the operator workspace, the one that changes platform
65
+ # settings such as the branding and the mail relay. Empty, the workspace auth.core
66
+ # recorded at first-run setup decides (pnpm flowdular auth operator shows it).
67
+ FD_OPERATOR_TENANT=
68
+
64
69
  # Prometheus exposition on GET /api/metrics. Leave FD_METRICS unset to keep it off.
65
70
  FD_METRICS=false
66
71
  FD_METRICS_TOKEN=
@@ -18,6 +18,7 @@ RUN pnpm build
18
18
  RUN mkdir -p module-plans \
19
19
  && if [ ! -f flowdular.modules.lock.json ]; then printf '{"schemaVersion":1,"modules":[]}\n' > flowdular.modules.lock.json; fi \
20
20
  && if [ ! -f flowdular.module-sources.json ]; then printf '{"schemaVersion":1,"sources":[]}\n' > flowdular.module-sources.json; fi
21
+ RUN mkdir -p /sdk-manifests && node infra/sdk-module-manifests.mjs . /sdk-manifests
21
22
 
22
23
  FROM node:24-bookworm-slim AS runtime
23
24
 
@@ -31,7 +32,9 @@ RUN groupadd --system --gid 1001 octane \
31
32
  && chown octane:octane /data
32
33
 
33
34
  # The server bundle is self-contained: platform/dist/server/entry.js imports
34
- # only node:* built-ins, so no node_modules (and no devDependencies) ship.
35
+ # only node:* built-ins, so no node_modules (and no devDependencies) ship. The
36
+ # platform does read the manifests and specs of the modules @flowdular/sdk
37
+ # ships, so the last copy puts those files alone where platform/ resolves them.
35
38
  COPY --from=builder --chown=octane:octane /workspace/platform/dist ./platform/dist
36
39
  COPY --from=builder --chown=octane:octane /workspace/platform/package.json ./platform/package.json
37
40
  COPY --chown=octane:octane infra/docker/app-entrypoint.mjs infra/docker/database-urls.mjs ./infra/docker/
@@ -40,6 +43,7 @@ COPY --from=builder --chown=octane:octane /workspace/flowdular.modules.lock.json
40
43
  COPY --from=builder --chown=octane:octane /workspace/flowdular.module-sources.json ./flowdular.module-sources.json
41
44
  COPY --from=builder --chown=octane:octane /workspace/module-plans ./module-plans
42
45
  COPY --from=builder --chown=octane:octane /workspace/modules ./modules
46
+ COPY --from=builder --chown=octane:octane /sdk-manifests/ ./
43
47
 
44
48
  USER octane
45
49
  EXPOSE 3000
@@ -124,6 +124,10 @@ services:
124
124
  FD_APPLICATION_PATH: ${FD_APPLICATION_PATH:-/app}
125
125
  FD_SETUP_AUTO_RESTART: 'true'
126
126
  FD_AUTH_ALLOW_SIGN_UP: 'false'
127
+ # Tenant id that overrides the operator workspace, the one that changes
128
+ # platform settings such as the branding and the mail relay. Empty, the
129
+ # workspace auth.core recorded at first-run setup decides.
130
+ FD_OPERATOR_TENANT: ${FD_OPERATOR_TENANT:-}
127
131
  # TLS terminates in front of this container. Only a plain-HTTP run on a
128
132
  # workstation may set FD_AUTH_SECURE_COOKIE=false in .env.
129
133
  FD_AUTH_SECURE_COOKIE: ${FD_AUTH_SECURE_COOKIE:-true}
@@ -61,6 +61,11 @@ spec:
61
61
  key: backgroundUrl
62
62
  - name: FD_AUTH_ALLOW_SIGN_UP
63
63
  value: 'false'
64
+ # Tenant id that overrides the operator workspace, the one that
65
+ # changes platform settings such as the branding and the mail relay.
66
+ # Empty, the workspace auth.core recorded at first-run setup decides.
67
+ - name: FD_OPERATOR_TENANT
68
+ value: ''
64
69
  - name: FD_AUTH_SECURE_COOKIE
65
70
  value: 'true'
66
71
  # With none, invitations and password resets are refused. The smtp
@@ -0,0 +1,118 @@
1
+ import {
2
+ cp,
3
+ lstat,
4
+ mkdir,
5
+ readFile,
6
+ realpath,
7
+ writeFile,
8
+ } from 'node:fs/promises';
9
+ import { createRequire } from 'node:module';
10
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
11
+
12
+ /* A deployment ships no node_modules, yet the platform lists the modules
13
+ @flowdular/sdk ships by resolving the SDK's module index from platform/. This
14
+ copies what that lookup reads (the index, each indexed module.json and its
15
+ spec/module.yaml, and a package.json exporting the index) to the same place
16
+ under the deployment root. A workspace without the SDK copies nothing.
17
+
18
+ Usage: node infra/sdk-module-manifests.mjs <workspace> <deployment root> */
19
+
20
+ /* The platform refuses a larger index, so the copy does too. */
21
+ const INDEX_LIMIT = 256;
22
+ const UNRESOLVED = new Set([
23
+ 'MODULE_NOT_FOUND',
24
+ 'ERR_PACKAGE_PATH_NOT_EXPORTED',
25
+ ]);
26
+
27
+ const [workspaceArgument, deploymentArgument] = process.argv.slice(2);
28
+ if (!workspaceArgument || !deploymentArgument) {
29
+ throw new Error(
30
+ 'Usage: node infra/sdk-module-manifests.mjs <workspace> <deployment root>',
31
+ );
32
+ }
33
+ const workspace = resolve(workspaceArgument);
34
+ const destination = join(
35
+ resolve(deploymentArgument),
36
+ 'platform/node_modules/@flowdular/sdk',
37
+ );
38
+
39
+ function sdkIndexPath() {
40
+ try {
41
+ return createRequire(join(workspace, 'platform/package.json')).resolve(
42
+ '@flowdular/sdk/modules.json',
43
+ );
44
+ } catch (error) {
45
+ if (UNRESOLVED.has(error.code)) return null;
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ function insidePath(root, path) {
51
+ const fromRoot = relative(root, path);
52
+ return (
53
+ Boolean(fromRoot) && !fromRoot.startsWith('..') && !isAbsolute(fromRoot)
54
+ );
55
+ }
56
+
57
+ /* Copies a file the index names, refusing one that leaves the SDK lexically or
58
+ through a link. An absent optional file is skipped. */
59
+ async function copyFromSdk(sdkRoot, path, optional = false) {
60
+ const lexical = resolve(sdkRoot, path);
61
+ if (!insidePath(sdkRoot, lexical)) {
62
+ throw new Error(`SDK module file escapes the package: ${path}`);
63
+ }
64
+ let source;
65
+ try {
66
+ source = await realpath(lexical);
67
+ } catch (error) {
68
+ if (optional && error.code === 'ENOENT') return;
69
+ throw error;
70
+ }
71
+ if (!insidePath(sdkRoot, source) || !(await lstat(source)).isFile()) {
72
+ throw new Error(
73
+ `SDK module file must be a regular file inside the package: ${path}`,
74
+ );
75
+ }
76
+ const target = join(destination, relative(sdkRoot, lexical));
77
+ await mkdir(dirname(target), { recursive: true });
78
+ await cp(source, target);
79
+ }
80
+
81
+ const indexPath = sdkIndexPath();
82
+ if (indexPath) {
83
+ const sdkRoot = await realpath(dirname(indexPath));
84
+ const indexSource = await readFile(indexPath, 'utf8');
85
+ const index = JSON.parse(indexSource);
86
+ if (
87
+ index.schemaVersion !== 1 ||
88
+ !Array.isArray(index.modules) ||
89
+ index.modules.length > INDEX_LIMIT
90
+ ) {
91
+ throw new Error('Invalid SDK module index.');
92
+ }
93
+ await mkdir(destination, { recursive: true });
94
+ for (const entry of index.modules) {
95
+ if (typeof entry?.manifest !== 'string') {
96
+ throw new Error('Invalid SDK module manifest path.');
97
+ }
98
+ await copyFromSdk(sdkRoot, entry.manifest);
99
+ await copyFromSdk(
100
+ sdkRoot,
101
+ join(dirname(entry.manifest), 'spec/module.yaml'),
102
+ true,
103
+ );
104
+ }
105
+ await writeFile(join(destination, 'modules.json'), indexSource);
106
+ await writeFile(
107
+ join(destination, 'package.json'),
108
+ `${JSON.stringify(
109
+ {
110
+ name: '@flowdular/sdk',
111
+ private: true,
112
+ exports: { './modules.json': './modules.json' },
113
+ },
114
+ null,
115
+ '\t',
116
+ )}\n`,
117
+ );
118
+ }
@@ -160,6 +160,15 @@ async function writeFunction(directory, runtimeRole) {
160
160
  );
161
161
  }
162
162
  }
163
+ const sdkManifests = spawnSync(
164
+ process.execPath,
165
+ [join(repositoryRoot, 'infra/sdk-module-manifests.mjs'), root, directory],
166
+ { stdio: 'inherit' },
167
+ );
168
+ if (sdkManifests.error) throw sdkManifests.error;
169
+ if (sdkManifests.status !== 0) {
170
+ throw new Error('Copying the SDK module manifests failed.');
171
+ }
163
172
  await copyRegular(
164
173
  join(repositoryRoot, 'infra/vercel/handler.mjs'),
165
174
  join(directory, 'handler.mjs'),
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "octane": "0.9.1",
18
18
  "segment-state": "0.4.0",
19
- "@flowdular/sdk": "0.6.0"
19
+ "@flowdular/sdk": "0.6.2"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsrx/typescript-plugin": "0.3.120",
@@ -15,7 +15,6 @@
15
15
  "format": "prettier --write .",
16
16
  "format:check": "prettier --check .",
17
17
  "flowdular": "flowdular",
18
- "cl": "flowdular",
19
18
  "doctor": "flowdular doctor",
20
19
  "verify": "pnpm rules:check && pnpm typecheck && pnpm test && flowdular spec validate --all && flowdular module validate && pnpm format:check",
21
20
  "rules:generate": "rulesync generate",
@@ -23,10 +22,10 @@
23
22
  "build": "flowdular module sync --apply && pnpm --filter @app/platform build"
24
23
  },
25
24
  "devDependencies": {
26
- "@flowdular/sandbox": "0.6.0",
25
+ "@flowdular/sandbox": "0.6.2",
27
26
  "@tsrx/prettier-plugin": "0.3.120",
28
27
  "prettier": "3.6.2",
29
- "flowdular": "0.6.0",
28
+ "flowdular": "0.6.2",
30
29
  "rulesync": "16.21.0"
31
30
  }
32
31
  }