create-flowdular 0.5.1 → 0.6.1

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 (105) hide show
  1. package/README.md +8 -5
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
  5. package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  6. package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
  7. package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
  8. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  9. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  10. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  11. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  12. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  13. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
  14. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  15. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  16. package/agent-template/.ai/platform-capabilities.md +10 -8
  17. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  18. package/agent-template/.ai/skills/README.md +1 -1
  19. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  20. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  21. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  22. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  23. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  24. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  25. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  26. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  27. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  28. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  29. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  30. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  31. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  33. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  34. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  35. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  36. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  37. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  38. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  39. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  40. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  41. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  42. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  43. package/agent-template/docs/agent-contract.md +3 -3
  44. package/agent-template/docs/cli-extensions.md +1 -0
  45. package/agent-template/docs/cli.md +40 -3
  46. package/agent-template/docs/configuration.md +64 -9
  47. package/agent-template/docs/database-adapters.md +30 -22
  48. package/agent-template/docs/design-system.md +7 -3
  49. package/agent-template/docs/getting-started.md +29 -32
  50. package/agent-template/docs/module-distribution.md +79 -86
  51. package/agent-template/docs/module-web-surfaces.md +9 -7
  52. package/agent-template/docs/modules.md +6 -2
  53. package/agent-template/docs/sandbox.md +23 -4
  54. package/agent-template/platform/scripts/build.mjs +7 -0
  55. package/dist/bin.js +3 -6
  56. package/package.json +1 -1
  57. package/template/default/.env.example +8 -3
  58. package/template/default/.vercelignore +8 -0
  59. package/template/default/README.md +20 -11
  60. package/template/default/_gitignore +3 -2
  61. package/template/default/infra/README.md +86 -65
  62. package/template/default/infra/docker/.env.example +71 -0
  63. package/template/default/infra/docker/Dockerfile +29 -11
  64. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  65. package/template/default/infra/docker/compose.yaml +109 -58
  66. package/template/default/infra/docker/database-urls.mjs +28 -0
  67. package/template/default/infra/docker/pitr.sh +177 -0
  68. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  69. package/template/default/infra/docker/start.mjs +402 -0
  70. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  71. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  72. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  73. package/template/default/infra/vercel/README.md +262 -0
  74. package/template/default/infra/vercel/build.mjs +223 -0
  75. package/template/default/infra/vercel/handler.mjs +100 -0
  76. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  77. package/template/default/modules/example/module.json +1 -1
  78. package/template/default/modules/example/package.json +3 -3
  79. package/template/default/modules/example/spec/module.yaml +1 -1
  80. package/template/default/modules/example/src/services/migration.ts +2 -2
  81. package/template/default/modules/example/tests/module.test.ts +1 -1
  82. package/template/default/package.json +2 -3
  83. package/template/default/platform/octane.config.ts +290 -156
  84. package/template/default/platform/package.json +5 -5
  85. package/template/default/platform/scripts/build.mjs +56 -0
  86. package/template/default/platform/scripts/dev.mjs +101 -18
  87. package/template/default/platform/src/generated/modules.server.ts +1 -0
  88. package/template/default/platform/src/server/database.ts +24 -0
  89. package/template/default/platform/src/server/lifecycle.ts +325 -0
  90. package/template/default/platform/src/server/runtime-role.ts +33 -0
  91. package/template/default/platform/src/server/setup/access.ts +160 -0
  92. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  93. package/template/default/platform/src/server/setup/environment.ts +154 -0
  94. package/template/default/platform/src/server/setup/gate.ts +84 -0
  95. package/template/default/platform/src/server/setup/index.ts +181 -0
  96. package/template/default/platform/src/server/setup/modules.ts +119 -0
  97. package/template/default/platform/src/server/setup/page.ts +548 -0
  98. package/template/default/platform/src/server/setup/routes.ts +788 -0
  99. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  100. package/template/default/platform/src/server/setup/seed.ts +192 -0
  101. package/template/default/platform/src/server/setup/token.ts +79 -0
  102. package/template/default/platform/src/server/worker-tick.ts +193 -0
  103. package/template/default/platform/src/server/workspace-root.ts +16 -0
  104. package/template/default/render.yaml +70 -0
  105. package/template/default/vercel.json +5 -0
package/README.md CHANGED
@@ -17,16 +17,19 @@ npm create flowdular@latest my-app
17
17
  cd my-app
18
18
  ```
19
19
 
20
- For this new local application, initialize demo authentication and start the development server:
20
+ Start the development server:
21
21
 
22
22
  ```sh
23
- pnpm flowdular setup
24
23
  pnpm dev
25
24
  ```
26
25
 
27
- Open [localhost:4310](http://localhost:4310). `setup` opens an interactive wizard for a local demo, PostgreSQL settings or a configuration check. It asks before resetting the local demo database.
26
+ The browser opens the first-run setup automatically. If it cannot open, visit
27
+ [localhost:4310/setup](http://localhost:4310/setup). Enter the token printed in
28
+ the terminal, then create your workspace and owner account. Embedded PostgreSQL
29
+ is already configured. Restart `pnpm dev` when setup finishes, then sign in with
30
+ the owner account you chose.
28
31
 
29
- The starter enables all nine platform modules: system, authentication, users, profile, agents, automations, workflows, their integration, and sandbox access. Local demo setup grants their declared permissions to demo owners. The example module stays available as a starting point; business modules from Official Modules are optional downloads.
32
+ The starter enables all nine platform modules: system, authentication, users, profile, agents, automations, workflows, their integration, and sandbox access. First-run setup grants their declared permissions to the new owner. The example module stays available as a starting point. Module Studio can later connect a catalog, a pinned Git repository or local releases; Sandbox helps create your own modules.
30
33
 
31
34
  `npm create` launches the generator. The generated application uses **pnpm workspaces**. If pnpm is unavailable during installation, the generator invokes its pinned version through `npm exec`.
32
35
 
@@ -96,7 +99,7 @@ pnpm verify
96
99
  pnpm flowdular help
97
100
  ```
98
101
 
99
- Read the [module guide](https://github.com/Flowdular/flowdular/blob/main/docs/modules.md) to extend the application. Business modules from [Official Modules](https://github.com/Flowdular/official-modules) are installed as source through the CLI.
102
+ Read the [module guide](https://github.com/Flowdular/flowdular/blob/main/docs/modules.md) to extend the application and the [Module Studio guide](https://github.com/Flowdular/flowdular/blob/main/docs/module-distribution.md) to install reviewed module source.
100
103
 
101
104
  ## Packages and support
102
105
 
@@ -19,7 +19,7 @@ also ship a ready business agent through `defineAgent()`, read
19
19
  ## 1. The contract in code
20
20
 
21
21
  - Composition: `PlatformServerContext` (`modules/auth/src/server/composition.ts`) carries `agentTools: PlatformToolRegistry` (`register(tools)`, `list()`; `packages/kernel/src/tool-registry.ts`; a duplicate tool id throws at boot), `settings: ModuleSettingsRuntime`, and `capabilities: PlatformCapabilityRegistry` (`register(id, service)`, `get(id)`, `has(id)`; `packages/kernel/src/capability-registry.ts`). `platform/octane.config.ts` creates the registries, passes them to every module's `createServerComposition`, declares each `settings`, owns each `dispose`, then calls each `start`.
22
- - Ordering is a non-issue: `agents.core` (`modules/agents/src/platform.ts`) passes `tools: () => context.agentTools.list()` into `createAgentRuntime`, and the harness is built lazily in `start()`, which runs after every module has composed. Tools any module registers during its own compose are therefore visible, whatever the module order.
22
+ - Ordering is a non-issue: `agents.core` (`modules/agents/src/platform.ts`) passes `tools: () => context.agentTools.list()` into `createAgentRuntime`, and the harness is built when `agents.core` opens, on its first request, capability call or `startWorker()`, all of which run after every module has composed. Tools any module registers during its own compose are therefore visible, whatever the module order.
23
23
  - Helpers: import `defineApiAgentTool` from `@flowdular/sdk/harness/tool-adapters` and the types `AgentTool`, `AgentToolContext` from `@flowdular/sdk/harness/runtime`. Both subpaths are free of the Vercel AI SDK; only the harness root (`@flowdular/sdk/harness`) and `@flowdular/sdk/modules/agents/server` pull it. `defineApiAgentTool` returns a frozen `AgentTool { id, transport: 'api', target, description, requiredPermissions, inputSchema?, execute }`. `defineCliAgentTool({ id, capability: { id, risk }, ... })` wraps a CLI capability and throws at definition time for `external` or `destructive` risk.
24
24
  - Skills inside `agents.core` are tenant database records behind `agents.skills.*`, appended to agent instructions. They are unrelated to `.ai/skills/**`, which are files for coding agents.
25
25
  - A read tool's output can also become a resolvable `{{ variable }}` for variable-aware fields: the tool's `requiredPermissions` is the variable's scope mask. Register a source on `platformVariableRegistry(context.capabilities)`, require an explicit record binding, and invoke the tool with the trusted tenant, actor permission snapshot, and signal. See the `variables` skill for the complete refusal contract.
@@ -57,7 +57,7 @@ Recipe in `modules/auth/tests/endpoints.test.ts`: build the runtime with a `Data
57
57
 
58
58
  - 401 without a cookie or token.
59
59
  - 403 with a principal that lacks the permission.
60
- - Cross-tenant read returns an empty list (service level, on the suite's test provider under the non-bypass `coreloom_runtime` role).
60
+ - Cross-tenant read returns an empty list (service level, on the suite's test provider under the non-bypass `flowdular_runtime` role).
61
61
  - Mutation without `x-csrf-token` returns 403 `CSRF_REJECTED`; without `origin` returns 403.
62
62
  - Each validation bound returns 400 with its code.
63
63
 
@@ -88,12 +88,12 @@ PostgreSQL tenant-owned tables also use database-enforced isolation:
88
88
 
89
89
  - enable and force row-level security on the table;
90
90
  - define a policy whose `USING` and `WITH CHECK` clauses compare `tenant_id`
91
- with `current_setting('coreloom.tenant_id', true)`;
91
+ with `current_setting('flowdular.tenant_id', true)`;
92
92
  - run application traffic under a role that is neither a superuser nor granted
93
93
  `BYPASSRLS`;
94
94
  - use a separate migration role for DDL or policy ownership when required.
95
95
 
96
- The adapter sets `coreloom.tenant_id` with parameterized `set_config(..., true)`
96
+ The adapter sets `flowdular.tenant_id` with parameterized `set_config(..., true)`
97
97
  after `BEGIN` on the pinned connection. Never use an unpinned root query.
98
98
  Explicit tenant predicates remain required as defense in depth.
99
99
 
@@ -133,7 +133,7 @@ transactions without `tenantId`, fail with `TENANT_CONTEXT_REQUIRED`.
133
133
 
134
134
  Use `DatabaseMigration` and `runDatabaseMigrations` from `@flowdular/sdk/database`.
135
135
  Each migration has one immutable id and its PostgreSQL SQL. The ledger is
136
- `_coreloom_migrations_v2`, keyed by module namespace and migration id; its
136
+ `_flowdular_migrations_v2`, keyed by module namespace and migration id; its
137
137
  checksum covers that exact SQL.
138
138
 
139
139
  `inspectExisting(database)` is the only pre-ledger adoption proof. Use
@@ -157,8 +157,8 @@ constant. A migration-only task uses `migration-authoring` in a separate phase.
157
157
  ## 7. Tests run on a real PostgreSQL
158
158
 
159
159
  `createTestDatabaseProvider()` from `@flowdular/sdk/database-testing` gives a suite its
160
- own PostgreSQL in process by default, with the same `coreloom_runtime` and
161
- `coreloom_background` roles and the same forced row-level security a deployment
160
+ own PostgreSQL in process by default, with the same `flowdular_runtime` and
161
+ `flowdular_background` roles and the same forced row-level security a deployment
162
162
  enforces. There is no server to start and no second dialect to keep green, so
163
163
  the isolation assertions run on every turn rather than behind an environment
164
164
  flag. CI selects server PostgreSQL with `FD_TEST_DATABASE_ADAPTER=postgresql`
@@ -83,8 +83,8 @@ adapter change, sandbox eject, and deployment validation.
83
83
 
84
84
  Every turn runs against a real PostgreSQL, because the embedded one starts in
85
85
  process. The sandbox and the test suites use `createTestDatabaseProvider()` from
86
- `@flowdular/sdk/database-testing`, which brings the `coreloom_runtime` and
87
- `coreloom_background` roles and forced row-level security with it.
86
+ `@flowdular/sdk/database-testing`, which brings the `flowdular_runtime` and
87
+ `flowdular_background` roles and forced row-level security with it.
88
88
 
89
89
  A target run covers tenant A and B fixtures, operations without tenant context,
90
90
  forged cross-tenant inserts, direct row-security bypass probes, concurrent
@@ -48,7 +48,7 @@ Set `FD_TRUST_PROXY` behind a load balancer. `FD_DATABASE_BACKGROUND_URL` gives
48
48
 
49
49
  ## 3. Migrations at rollout
50
50
 
51
- Migrations are module-owned, numbered, immutable once applied, and verified by checksum against the `_coreloom_migrations_v2` ledger. The commands (`packages/cli/src/runner.ts`):
51
+ Migrations are module-owned, numbered, immutable once applied, and verified by checksum against the `_flowdular_migrations_v2` ledger. The commands (`packages/cli/src/runner.ts`):
52
52
 
53
53
  ```bash
54
54
  pnpm flowdular migration status [--module <id>] # what the ledger holds
@@ -26,7 +26,7 @@ Migration SQL is checked in and immutable after release:
26
26
  ## 2. What the v2 runner guarantees
27
27
 
28
28
  `runDatabaseMigrations(database, namespace, databaseMigrations)` uses the
29
- namespaced `_coreloom_migrations_v2` ledger. A row records namespace, migration
29
+ namespaced `_flowdular_migrations_v2` ledger. A row records namespace, migration
30
30
  id, dialect id, checksum, and applied time. The checksum covers the selected
31
31
  dialect's exact SQL.
32
32
 
@@ -76,14 +76,14 @@ Every PostgreSQL tenant table includes:
76
76
  ALTER TABLE inventory_locations ENABLE ROW LEVEL SECURITY;
77
77
  ALTER TABLE inventory_locations FORCE ROW LEVEL SECURITY;
78
78
  CREATE POLICY inventory_locations_tenant_policy ON inventory_locations
79
- USING (tenant_id = current_setting('coreloom.tenant_id', true))
80
- WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
79
+ USING (tenant_id = current_setting('flowdular.tenant_id', true))
80
+ WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
81
81
  ```
82
82
 
83
83
  The runtime role is not a superuser and has no `BYPASSRLS`. DDL and policy
84
84
  ownership use `purpose: 'migration'`. Runtime repository calls use
85
85
  `database.transaction(operation, { tenantId, access })`; the adapter sets
86
- transaction-local `coreloom.tenant_id` on the pinned connection. Queries still
86
+ transaction-local `flowdular.tenant_id` on the pinned connection. Queries still
87
87
  include `WHERE tenant_id = ...` as defense in depth.
88
88
 
89
89
  ## 6. Add one migration
@@ -142,7 +142,7 @@ export function createServerComposition(
142
142
 
143
143
  `PlatformServerContext` also carries `databases: DatabaseProvider`, `settings: ModuleSettingsRuntime`, `agentTools: PlatformToolRegistry`, `agentDefinitions: PlatformAgentRegistry`, and `capabilities: PlatformCapabilityRegistry`. The runtime shares one lazy database initialization, uses separate migration and runtime leases, and releases the runtime lease from `dispose()`; `prepare()` remains read-only. A composition may return module settings, lifecycle hooks, agent registrations, and typed cross-module capabilities as described in the focused skills.
144
144
 
145
- Schema: write PostgreSQL SQL in `migrations/0001_inventory_core.up.sql` and mirror it byte for byte in `databaseMigrations` as `sql: { postgresql: ... }` with an `inspectExisting` built from `postgresTenantTableState(...)`. Tenant tables enable and force row-level security with an `<table>_tenant_policy` whose `USING` and `WITH CHECK` compare `tenant_id` with `current_setting('coreloom.tenant_id', true)`; the runtime role has no superuser or `BYPASSRLS`. Repository operations use `database.transaction(..., { tenantId, access })` and retain explicit tenant predicates. Details in `migration-authoring` and `database-adapter`.
145
+ Schema: write PostgreSQL SQL in `migrations/0001_inventory_core.up.sql` and mirror it byte for byte in `databaseMigrations` as `sql: { postgresql: ... }` with an `inspectExisting` built from `postgresTenantTableState(...)`. Tenant tables enable and force row-level security with an `<table>_tenant_policy` whose `USING` and `WITH CHECK` compare `tenant_id` with `current_setting('flowdular.tenant_id', true)`; the runtime role has no superuser or `BYPASSRLS`. Repository operations use `database.transaction(..., { tenantId, access })` and retain explicit tenant predicates. Details in `migration-authoring` and `database-adapter`.
146
146
 
147
147
  Errors: `{ error: { code, message } }`; service errors `class XServiceError extends Error { constructor(readonly code: string, message: string, readonly status = 400) }`; a `failure(error)` helper routes them to `jsonResponse(..., error.status)` and everything else to `problemResponse(error, 'The <module> operation failed.')`.
148
148
 
@@ -45,8 +45,12 @@ is a new spec-authoring step and needs approval after that edit.
45
45
  ## 3. Sandbox path
46
46
 
47
47
  In the sandbox, use the operator approval action for the selected session module.
48
- The live route is POST /sandbox/api/sessions/:id/approve, exposed by
49
- approveSpecification in packages/sandbox/src/client/api.ts.
48
+ The live route is POST /sandbox/api/sessions/:id/approve with
49
+ { "module", "specHash" }, exposed by approveSpecification in
50
+ packages/sandbox/src/client/api.ts. specHash is the SHA-256 the review card
51
+ shows for the text it renders. The route refuses with 409 SPEC_CHANGED when the
52
+ current text has another hash, and with 409 QUESTIONS_PENDING while the module
53
+ has unanswered questions; it records nothing then.
50
54
 
51
55
  The route changes the status presentation and records the SHA-256 hash of the
52
56
  exact approved text in the session. Do not patch the session workspace file to
@@ -68,7 +68,7 @@ In the sandbox, end the reply with exactly one fenced block tagged `questions`,
68
68
  ```
69
69
  ````
70
70
 
71
- The sandbox renders it as a form and the answers return in the next turn as a `Decisions` section. Outside the sandbox: in Claude Code ask through the question tool with the same options, and in Codex ask in plain text with the options numbered. In every host, `recommended` is the default from the card, and an unanswered question stays a question, never a guess.
71
+ The sandbox enforces the bounds: at most 12 questions, each 1 to 400 characters; at most 8 options per question, each 1 to 120 characters; `recommended` is one of the options; no line breaks inside a value; the whole block at most 8000 characters. A block outside them comes back to you once with the reason. The sandbox renders it as a form and the answers return in the next turn as a `Decisions` section. Outside the sandbox: in Claude Code ask through the question tool with the same options, and in Codex ask in plain text with the options numbered. In every host, `recommended` is the default from the card, and an unanswered question stays a question, never a guess.
72
72
 
73
73
  When the answers come back, copy each one into `decisions[]` with `decidedBy: user` and the answer text, and update whatever the answer changed.
74
74
 
@@ -86,7 +86,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
86
86
 
87
87
  `modules/<dir>/spec/module.yaml`, `schemaVersion: 2`, `status: draft`. Keep the v1 keys (`id`, `specVersion`, `name`, `description`, `profile`, `capabilities`, `dependencies`, `tenancy`, `locales`, `invariants`, `permissions`, `dataOwnership`, `acceptanceScenarios`) and add the v2 arrays:
88
88
 
89
- - `entities[]`: `{ id, name, fields[], states? }`. A field is `{ id, type, required?, unique?, maxLength?, values?, reference?, description? }`. `type` is one of `string`, `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `enum`, `reference`, `json`; `unique` is `tenant` or `none`. `enum` needs `values`, `reference` needs `reference`. Entity and screen ids are `^[a-z][a-z0-9-]*$`; field and setting keys are `^[a-z][a-zA-Z0-9]*$`. Money is `integer` minor units plus an explicit currency field, never `decimal`.
89
+ - `entities[]`: `{ id, name, fields[], states? }`. A field is `{ id, type, required?, unique?, maxLength?, values?, reference?, description? }`. `type` is one of `string`, `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `enum`, `reference`, `json`; `unique` is `tenant` or `none`. `enum` needs `values`, `reference` needs `reference`. Entity and screen ids are `^[a-z][a-z0-9-]*$`; field and setting keys are `^[a-z][a-zA-Z0-9]*$`. A field never repeats a column every tenant table owns (`id`, `tenantId`, `createdAt`; a screen may still list `createdAt`), and its key in snake case is never a PostgreSQL reserved word (`order`, `user`, `currentUser`): `spec-schema` refuses either with `SPEC_FIELD_RESERVED`. Money is `integer` minor units plus an explicit currency field, never `decimal`.
90
90
  - `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
91
91
  - `actions[]`: `{ id, entity?, permission, kind: create|update|delete|custom, risk, idempotent, description }`. `risk: external` is refused by the platform, so an action may not declare it.
92
92
  - `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
@@ -15,7 +15,7 @@ description: >-
15
15
 
16
16
  ## 2. Repositories on an embedded PostgreSQL
17
17
 
18
- `createPgliteTestProvider()` from `@flowdular/sdk/database-testing` runs a real PostgreSQL inside the test process, with the same `coreloom_runtime` and `coreloom_background` roles and the same forced row-level security a deployment enforces. Booting it costs about two seconds, so a suite opens one provider per test file, migrates it once, and truncates the module's tables between cases; `.ai/references/catalog/tests/support/database.ts` is the shape (`createCatalogTestDatabase` hands out a lease per fixture, `closeCatalogTestDatabases` runs in `afterAll`). Build the service on top: `new CatalogService((await createCatalogTestDatabase()).repository)`. A database module also keeps `tests/migrations.test.ts` for SQL byte parity, fresh apply, safe pre-ledger adoption, and a clean second start. A module whose spec has no `database` capability gets a `MemoryXRepository` from the scaffold instead; a module with a database tests the database repository, never a hand-written fake, because the SQL, the ledger and the row-level security are what need testing.
18
+ `createPgliteTestProvider()` from `@flowdular/sdk/database-testing` runs a real PostgreSQL inside the test process, with the same `flowdular_runtime` and `flowdular_background` roles and the same forced row-level security a deployment enforces. Booting it costs about two seconds, so a suite opens one provider per test file, migrates it once, and truncates the module's tables between cases; `.ai/references/catalog/tests/support/database.ts` is the shape (`createCatalogTestDatabase` hands out a lease per fixture, `closeCatalogTestDatabases` runs in `afterAll`). Build the service on top: `new CatalogService((await createCatalogTestDatabase()).repository)`. A database module also keeps `tests/migrations.test.ts` for SQL byte parity, fresh apply, safe pre-ledger adoption, and a clean second start. A module whose spec has no `database` capability gets a `MemoryXRepository` from the scaffold instead; a module with a database tests the database repository, never a hand-written fake, because the SQL, the ledger and the row-level security are what need testing.
19
19
 
20
20
  ## 3. Route recipe (from `modules/auth/tests/endpoints.test.ts`)
21
21
 
@@ -55,7 +55,7 @@ The auth middleware must have set the principal for `endpointIdentityFromContext
55
55
  - 403 on a mutation without `x-csrf-token` (`CSRF_REJECTED`) and without `origin` (`ORIGIN_REQUIRED`).
56
56
  - 400 with the stable code for each validation bound (`INVALID_INPUT`, module codes such as `INVALID_ITEM_KIND`).
57
57
  - 409 for the tenant-scoped uniqueness rule, and success for the same key in another tenant.
58
- - Tenant isolation: rows created for `tenant-a` are invisible to `list('tenant-b')`. The provider hands the suite the non-bypass `coreloom_runtime` role, so this runs against real forced row-level security; also assert that a call without tenant context fails with `TENANT_CONTEXT_REQUIRED`.
58
+ - Tenant isolation: rows created for `tenant-a` are invisible to `list('tenant-b')`. The provider hands the suite the non-bypass `flowdular_runtime` role, so this runs against real forced row-level security; also assert that a call without tenant context fails with `TENANT_CONTEXT_REQUIRED`.
59
59
  - Identity: `moduleDefinition.manifest.id` equals the module id (keeps `module.json` and `src/index.ts` aligned). The scaffold writes this and the isolation case; everything else in this list is yours.
60
60
 
61
61
  Assert at the observation boundary: status code, `error.code`, returned record fields. Do not assert internal helper names, call order, or SQL text.
@@ -1,9 +1,8 @@
1
1
  ---
2
2
  name: workflow-development
3
3
  description: >-
4
- Build, publish, invoke, and test a workflows.core DAG through its typed graph
5
- and public execution capability without bypassing agent, action, tenant, or
6
- audit boundaries.
4
+ Author module-owned action templates, or build, publish, invoke, and test a
5
+ workflows.core DAG through its typed graph and public execution capability.
7
6
  ---
8
7
  # Build and integrate an agentic workflow
9
8
 
@@ -12,18 +11,22 @@ pinned agent revisions, deterministic gates, schema validators, registered
12
11
  module actions, data mappings, and terminal output. It does not own schedules or
13
12
  webhook secrets. Those remain optional concerns of `automations.core`.
14
13
 
15
- Read `docs/adr/0006-agentic-workflows.md`, the approved
16
- `modules/workflows/spec/module.yaml`, and the contracts in
17
- `modules/workflows/src/domain/types.ts` before changing a workflow surface.
14
+ At the repository root, read `docs/adr/0006-agentic-workflows.md`, the approved
15
+ `modules/workflows/spec/module.yaml`, and the owning public contracts before
16
+ changing a workflow surface. In the Sandbox, read the approved active-module
17
+ specification and the relevant public contract under `reference/sdk` when it is
18
+ installed. A missing public contract is a core blocker, not a reason to invent
19
+ one or search beyond the session workspace.
18
20
 
19
21
  ## Pick the correct extension point
20
22
 
21
23
  - A workflow definition belongs in `workflows.core` and is edited through its
22
24
  API or canvas. Do not hardcode a tenant workflow in source.
23
25
  - A business operation that a workflow may call is a versioned agent action.
24
- Register it through the agents action catalog. If missing, implement it in a
25
- separate `agent-tool-design` phase with permission, input, output, timeout,
26
- idempotency and audit tests before returning to workflow integration.
26
+ Register one ordinary `AgentTool` with `context.agentTools`. Optional
27
+ `workflowTemplate` metadata makes that action a named palette choice through
28
+ `agents.actions.v2`; it creates no second handler or node kind. Use the
29
+ authoring recipe below when the action is part of this task.
27
30
  - A business module that starts a workflow resolves
28
31
  `workflows.execution.v1` from `context.capabilities`. It never imports a
29
32
  workflow repository or database.
@@ -33,6 +36,87 @@ Read `docs/adr/0006-agentic-workflows.md`, the approved
33
36
  - If the workflow module is absent, the capability registry returns `null`.
34
37
  Hide an optional feature or return a clear stable refusal.
35
38
 
39
+ ## Author a module-owned workflow node template
40
+
41
+ An action-backed template is module source, not graph source. The business
42
+ manager first records its stable action id, permission, named inputs and
43
+ validation rules, output, effect, replay behavior, and success and refusal
44
+ scenarios in the owning module's specification. In a Sandbox session, the
45
+ operator must approve the hash of that exact spec before implementation. A
46
+ later edit requires renewed approval. If any of these decisions are absent,
47
+ hand the spec delta to `business-manager`; do not infer a field, permission,
48
+ external effect, or idempotency rule from the brief.
49
+
50
+ Work inside the existing role boundaries:
51
+
52
+ 1. `backend-engineer` owns the tenant-bound service, code-level validator,
53
+ endpoint or CLI target, and durable target ledger for a mutation. Validation
54
+ runs before any write and returns a stable bounded refusal. A repeated
55
+ idempotency key returns its first result; the same key with different input
56
+ conflicts. Ask backend to supply a missing service rather than writing in
57
+ `src/services/**` or `src/api/**` from the agentic role.
58
+ 2. `agentic-engineer` defines the tool under `src/agent/**`, registers it once
59
+ from `createServerComposition` in `src/platform.ts`, and adds behavioral
60
+ tests. `defineApiAgentTool` or `defineCliAgentTool` carries the existing
61
+ public target. `agents.core` exposes the registered action through
62
+ `agents.actions.v2`; the business module does not register that capability
63
+ or access the workflow database.
64
+ 3. Give the tool a stable dotted id, positive `contractVersion`, required
65
+ permission, bounded input and output JSON Schemas, `risk: 'read'` or
66
+ `'workspace-write'`, `idempotency: 'required'`, cancellation policy, and
67
+ timeout. A workspace write also needs
68
+ `idempotencyProtection: 'target-ledger'` backed by the service's real ledger.
69
+ The handler receives trusted tenant, actor, permission snapshot,
70
+ idempotency key, and `AbortSignal` from `AgentToolContext`; none comes from
71
+ graph input. Its output must satisfy its declared schema.
72
+ 4. Add static `workflowTemplate: { label, description, effect }` on that same
73
+ tool. The label is at most 80 characters, the description at most 240, and
74
+ effect is `local` or `connector-egress`. Metadata contains no code,
75
+ credential, or tenant value. Invalid or duplicate metadata must fail
76
+ composition with a safe diagnostic. A workflow-eligible tool without the
77
+ metadata remains in the generic action editor.
78
+
79
+ The selected template becomes a normal `action` node with input, success, and
80
+ failure ports. It pins the action id, contract version, schemas, permissions,
81
+ risk, idempotency protection, timeout, cancellation, and effect. Changing any
82
+ of those or the handler's behavior requires a new action identity or contract
83
+ version. The current registry keeps one version per action id, so retain the
84
+ old id and register a distinct id when published graphs must keep running;
85
+ label and description may change without rebinding a published graph.
86
+
87
+ Graph bindings may never supply a raw secret. `writeOnly` and
88
+ `x-flowdular-secret` apply at every schema depth, including array items and
89
+ alternatives. A marked field cannot carry `default`, `const`, `enum`,
90
+ `example`, or `examples` data. A template requiring
91
+ a raw secret input is ineligible. Accept a nonsecret opaque reference and let
92
+ the owning module resolve a credential from its tenant-bound vault or a
93
+ declared capability. Never put secret values in fixtures, graph definitions,
94
+ events, audit, or error text.
95
+
96
+ For `connector-egress`, the consumer module declares `connectors.core` and
97
+ `connectors.calls.v1`, uses `caller: 'workflow'`, checks the instance's
98
+ `allowWorkflows` consent, and passes the stable workflow side-effect key to
99
+ the connector. Credentials remain in `connectors.core`. A replay-stable
100
+ output may contain the recorded call id and outcome, not a response body the
101
+ connector replay does not return. Propagate `CALL_OUTCOME_UNKNOWN` as a
102
+ terminal failure when the remote mutation may have happened but no call was
103
+ recorded; never send a second request just to rebuild output. A separate
104
+ provider contract with remote idempotency or readback is required before
105
+ retrying an uncertain mutation.
106
+
107
+ Prove the module behavior at its public service or action boundary: valid
108
+ input, structural and business validation refusal before mutation, missing
109
+ permission, foreign tenant, same-key replay, different-input conflict,
110
+ cancellation, and recovery around the target commit. Connector actions also
111
+ prove absent consent and `CALL_OUTCOME_UNKNOWN` with a recorded fixture. Run a
112
+ deterministic workflow simulation on invented, reviewed fixtures for success,
113
+ failure, and refusal edges. Assert semantic attempts and edge outcomes, not
114
+ real timestamps; simulation must never invoke the handler, connector, or
115
+ network. Inspect the named template and safe run trail in Sandbox preview,
116
+ then run scoped gates, exact-source `auto-review`, and host eject. The agent
117
+ cannot approve the spec, install code into the running server, or push a
118
+ repository from the Sandbox.
119
+
36
120
  ## Graph contract
37
121
 
38
122
  Version one is a bounded DAG. The graph contains:
@@ -41,9 +125,11 @@ Version one is a bounded DAG. The graph contains:
41
125
  - `agent`: calls one exact immutable agent revision and validates structured
42
126
  output.
43
127
  - `agent-decision`: produces one schema-valid `pass` or `fail` outcome.
128
+ - `typed-decision`: routes one bounded typed answer through `pass` or `fail`.
44
129
  - `gate`: evaluates the versioned allowlisted logic language.
45
130
  - `validator`: validates an envelope against a pinned JSON schema.
46
131
  - `action`: calls one exact registered action contract version.
132
+ - `human-approval`: waits for an `approvals.core` request to resolve.
47
133
  - `merge`: waits for all declared incoming paths.
48
134
  - `output`: settles the workflow with a typed result.
49
135
 
@@ -14,9 +14,9 @@ handoff:
14
14
 
15
15
  You own specification decisions and locale terminology, never implementation. Use only the Task skill selected under Session. Consult reference/platform-capabilities.md, reference/packages/contracts/schemas/module-spec.schema.json and reference/example-module/spec/module.yaml when writing the spec.
16
16
 
17
- Write schemaVersion 2: entities with typed fields and states, screens, actions, widgets, settings, agentTools, plus outOfScope and decisions. Fill decisions for every choice, including the platform defaults you proposed. The capability card is closed: anything it lists as missing goes to outOfScope with the business decision, never into a scenario. v1 specs stay valid.
17
+ Write schemaVersion 2: entities with typed fields (never id, tenantId or createdAt) and states, screens, actions, widgets, settings, agentTools, plus outOfScope and decisions. Fill decisions for every choice, including the platform defaults you proposed. The capability card is closed: anything it lists as missing goes to outOfScope with the business decision, never into a scenario. v1 specs stay valid.
18
18
 
19
- When a decision is missing, end the reply with exactly one fenced block tagged questions holding {"questions":[{"id":"Q-1","question":"...","options":["..."],"recommended":"...","allowFreeText":true}]} and nothing after it. The operator answers in a form and the replies arrive next turn as a Decisions section.
19
+ When a decision is missing, end the reply with exactly one fenced block tagged questions holding {"questions":[{"id":"Q-1","question":"...","options":["..."],"recommended":"...","allowFreeText":true}]} and nothing after it. Stay within the limits the sandbox enforces: at most 12 questions, each 1 to 400 characters; at most 8 options per question, each 1 to 120 characters; recommended is one of the options; no line breaks inside a value; the whole block at most 8000 characters. A block outside them comes back to you once with the reason. The operator answers in a form and the replies arrive next turn as a Decisions section.
20
20
 
21
21
  For an edit, compare against base/modules/<dir>/spec/module.yaml and make the smallest delta covering the brief. Start new specs as draft; change an existing approved spec to draft or in-review before editing requirements. Never set approved: only the operator records approval of the exact hash. Later edits invalidate it.
22
22
 
@@ -11,7 +11,7 @@ rules:
11
11
  - PostgreSQL only; CREATE TABLE IF NOT EXISTS
12
12
  - tenant_id TEXT NOT NULL on tenant-owned tables
13
13
  - UNIQUE and indexes start with tenant_id, indexes end with id
14
- - a new tenant table enables and forces row-level security and adds a <table>_tenant_policy on current_setting('coreloom.tenant_id', true)
14
+ - a new tenant table enables and forces row-level security and adds a <table>_tenant_policy on current_setting('flowdular.tenant_id', true)
15
15
  - ALTER TABLE ADD COLUMN runs once through the ledger; inspectExisting detects an existing column
16
16
  - applied migration bytes are immutable; a change always gets a new number
17
17
  - inspectExisting returns complete only for every effect, absent only for none, partial for anything mixed
@@ -26,7 +26,7 @@ manifest:
26
26
  - ./server
27
27
  - ./platform
28
28
  src/platform.ts:
29
- - createServerComposition(context) returns routes, optional settings (defineModuleSettings), optional start() and optional dispose()
29
+ - createServerComposition(context) returns routes, optional settings (defineModuleSettings), optional start() for registration only, optional startWorker() that starts every poll loop and background worker, optional stop() and optional dispose()
30
30
  - context carries auth, settings (ModuleSettingsRuntime), agentTools (PlatformToolRegistry), agentDefinitions (PlatformAgentRegistry) and capabilities (PlatformCapabilityRegistry)
31
31
  conditional:
32
32
  api:
@@ -20,15 +20,15 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
20
20
 
21
21
  **List export.** A list endpoint that already pages with the keyset helpers becomes a CSV export by declaring one: `defineListExport({ id, label, permission, columns, page })` (`packages/server/src/export/`), where `id` is the owning module id plus the list key (`users.core.members`), `permission` is the one the list endpoint itself requires, `columns` is 1 to 64 `{ key, header, value(row) }` entries and `page(principal, cursor, limit)` is the paging the endpoint already implements. The declaration is type erased at definition time, so no row object leaves the module that produced it. A module registers its declarations while it composes, through the public capability `exports.lists.v1` (`context.capabilities.get<ExportLists>(EXPORT_LISTS_CAPABILITY)?.register(moduleId, […])`, `modules/exports`); the id must sit inside the registering module's namespace and the catalogue is sealed before the first request; the same capability answers `find(id)` with the registered declaration or null, for a module that pages a list itself under a principal holding the list's permission. `exports.core` owns the rest: `POST /api/exports/start` behind `exports.lists.manage` plus the list's own permission checked on the live principal, `GET /api/exports/jobs` and `/api/exports/jobs/:id` paged behind `exports.lists.read`, `GET /api/exports/lists` behind the same permission for the catalogue of registered lists (id, label, registering module and whether the asking principal holds that list's permission, never the permission id), which is what the Exports screen's start control offers to a reader holding `exports.lists.manage`, and `POST /api/exports/jobs/read-url` for a signed storage route minted per request, which re-checks the exported list's permission on the live principal so the file is never easier to read than the list. A poll loop on the job runner walks the pages under the requester's snapshot and writes RFC 4180 with a UTF-8 byte order mark to the storage port under `exports.core`. Neither bound truncates: over `maxRows` (default 100000) or `maxBytes` (default 50 MB, both platform settings, and the storage object ceiling of 25 MB applies underneath) the job fails with `EXPORT_ROWS_EXCEEDED` or `EXPORT_BYTES_EXCEEDED` and writes no file. A list that answers a next cursor answers at least one row with it and never the cursor it was given. Cells are written as the declaration answered them and are never rewritten, so a value beginning with `=`, `+`, `-` or `@` reaches the file as data and this module neither prefixes nor quotes it against a spreadsheet reading it as a formula; a module whose column can carry such a value neutralises it in its own `value(row)`. Jobs are held 30 days by the data class `exports.core.jobs`, whose sweep deletes the file with the row.
22
22
 
23
- **PostgreSQL with forced row-level security.** A module receives a `DatabaseProvider` as `context.databases` and acquires a lease per purpose (`runtime`, `migration`, `background`, `preview`, `test`); it never sees a DSN or a pool. Every statement runs inside `database.transaction(fn, { tenantId, access })` with `access: 'read' | 'write'`; a runtime lease without a `tenantId` throws `TENANT_CONTEXT_REQUIRED`. The adapter sets `coreloom.tenant_id` transaction-locally, and each tenant table must `ENABLE` and `FORCE ROW LEVEL SECURITY` with a `USING` and `WITH CHECK` policy against it. Migrations are numbered, immutable once applied, mirrored byte for byte in `databaseMigrations`, and recorded in the `_coreloom_migrations_v2` ledger by checksum. The runtime role holds neither `SUPERUSER` nor `BYPASSRLS`. (`packages/database/src/{contracts,postgresql,migrations,provider}.ts`.)
23
+ **PostgreSQL with forced row-level security.** A module receives a `DatabaseProvider` as `context.databases` and acquires a lease per purpose (`runtime`, `migration`, `background`, `preview`, `test`); it never sees a DSN or a pool. Every statement runs inside `database.transaction(fn, { tenantId, access })` with `access: 'read' | 'write'`; a runtime lease without a `tenantId` throws `TENANT_CONTEXT_REQUIRED`. The adapter sets `flowdular.tenant_id` transaction-locally, and each tenant table must `ENABLE` and `FORCE ROW LEVEL SECURITY` with a `USING` and `WITH CHECK` policy against it. Migrations are numbered, immutable once applied, mirrored byte for byte in `databaseMigrations`, and recorded in the `_flowdular_migrations_v2` ledger by checksum. The runtime role holds neither `SUPERUSER` nor `BYPASSRLS`. (`packages/database/src/{contracts,postgresql,migrations,provider}.ts`.)
24
24
 
25
- **Background work.** A module that polls its own routing table runs one loop per job through `createJobRunner` (`packages/server/src/jobs/`), taking `{ name, intervalMs, claim, perform, heartbeat?, heartbeatEveryMs?, staleAfterMs, backoff?, batchLimit?, logger, now?, onEvent? }`. The runner owns the loop and nothing else: the timer and its `unref`, the guard that keeps two passes from overlapping, at most `batchLimit` claims per pass, per-item isolation so one failing item never stops the pass, a renewal timer that calls `heartbeat` while `perform` runs and aborts its `AbortSignal` with the stable code `CLAIM_LOST` when the fence answers false, exponential `backoff` after a pass that raised and a reset by one that did not, and `start`, `tick`, `stop`, `quiesce` and `dispose`. It opens no database handle: the table, the routing read, the claim statement with its stale window, the renewal statement and every outcome recorded stay the module's own, `claim` answering null ends the pass, and a stage that observes the abort stops without settling anything. `onEvent` is a trace hook that costs nothing when nobody listens. A composition wires it as `start`, `stop: () => runner.quiesce()` and `dispose: () => runner.dispose()`; `import.core` is the first adopter and `packages/server/src/jobs/index.ts` carries the recipe for the rest.
25
+ **Background work.** A module that polls its own routing table runs one loop per job through `createJobRunner` (`packages/server/src/jobs/`), taking `{ name, intervalMs, claim, perform, heartbeat?, heartbeatEveryMs?, staleAfterMs, backoff?, batchLimit?, logger, now?, onEvent? }`. The runner owns the loop and nothing else: the timer and its `unref`, the guard that keeps two passes from overlapping, at most `batchLimit` claims per pass, per-item isolation so one failing item never stops the pass, a renewal timer that calls `heartbeat` while `perform` runs and aborts its `AbortSignal` with the stable code `CLAIM_LOST` when the fence answers false, exponential `backoff` after a pass that raised and a reset by one that did not, and `start`, `tick`, `stop`, `quiesce` and `dispose`. It opens no database handle: the table, the routing read, the claim statement with its stale window, the renewal statement and every outcome recorded stay the module's own, `claim` answering null ends the pass, and a stage that observes the abort stops without settling anything. `onEvent` is a trace hook that costs nothing when nobody listens. A composition starts it from `startWorker: () => runner.start()` and wires `stop: () => runner.quiesce()` and `dispose: () => runner.dispose()`. `start` is for registration only (sealing a registry, reading what other modules registered): the platform calls it in every process, and calls `startWorker` only where workers run, never with `FD_RUNTIME_ROLE=web`. A worker host may call `startWorker` and `stop` many times on one composition, so the loop restarts after a stop and a failed database open is not cached: the next `startWorker` opens again. `runner.wake()` runs a pass even on a stopped runner, so a request path that wakes the loop after it enqueues checks a flag set in `startWorker` and cleared in `stop` first (`workerActive` in `modules/adapters/src/server/runtime.ts`). `import.core` is the first adopter and `packages/server/src/jobs/index.ts` carries the recipe for the rest.
26
26
 
27
- **Module settings.** `defineModuleSettings` (`packages/kernel/src/module-settings.ts`) declares `{ moduleId, settings }`. A setting has `type: 'string' | 'number' | 'boolean'`, `defaultValue`, `visibility: 'private' | 'shared'`, `client: boolean`, and optionally `kind: 'flag'`, `scope: 'platform' | 'tenant'`, `secret`, `labelKey`, `descriptionKey`, `label`, `description`, `enum` (string type only), `min`, `max`, `pattern`, `multiline`. Keys match `^[a-z][a-zA-Z0-9]*$`. The allowed-values field is `enum`, not `values`. A `secret` setting can be neither `client: true` nor `visibility: 'shared'`. Values are read live with `context.settings.get(tenantId, moduleId, key)` and edited in Administration, Modules behind `system.settings.read` and `system.settings.manage` (`GET /api/settings`, `POST /api/settings/update`, `modules/system/src/server/endpoints.ts`). A read needs the tenant primed first (`await context.settings.prime(tenantId)`): the authenticated request path and the platform composition prime, a background path primes itself, and `set` is awaited.
27
+ **Module settings.** `defineModuleSettings` (`packages/kernel/src/module-settings.ts`) declares `{ moduleId, settings }`. A setting has `type: 'string' | 'number' | 'boolean'`, `defaultValue`, `visibility: 'private' | 'shared'`, `client: boolean`, and optionally `kind: 'flag'`, `scope: 'platform' | 'tenant'`, `secret`, `labelKey`, `descriptionKey`, `label`, `description`, `enum` (string type only), `min`, `max`, `pattern`, `multiline`. Keys match `^[a-z][a-zA-Z0-9]*$`. The allowed-values field is `enum`, not `values`. A `secret` setting can be neither `client: true` nor `visibility: 'shared'`. Values are read live with `context.settings.get(tenantId, moduleId, key)` and edited in Administration, Modules behind `system.settings.read` and `system.settings.manage` (`GET /api/settings`, `POST /api/settings/update`, `modules/system/src/server/endpoints.ts`). A `scope: 'platform'` value is one for every workspace, so only the operator workspace changes it: any other workspace sees the row locked and its write is refused with 403 `PLATFORM_SETTING_OPERATOR_ONLY`. The operator workspace is the one auth.core records (the first workspace of an empty database, recorded by first-run setup, `auth workspace-create`, `sandbox provision` or `setup quick` in the transaction that creates it; changed only by `pnpm flowdular auth operator-set`), unless `FD_OPERATOR_TENANT` is set, which then decides alone. system.core resolves it per request through `authService.operatorStanding(tenantId)` (`own`, `other` or `none`, never naming another workspace); while none is known every platform row is locked with `system.settings.platformOperatorUnset`. A read needs the tenant primed first (`await context.settings.prime(tenantId)`): the authenticated request path and the platform composition prime, a background path primes itself, and `set` is awaited. A primed snapshot is at most 5 seconds behind a write another process made. Every write appends one row to auth.core's change log, which names the setting and never its value: `context.settings.changesAfter({ after, limit, moduleId?, key? })` pages it from an opaque cursor (`after: null` is the start, 1 to 500 changes a page, the newest change of every setting kept whatever its age) and answers `{ expired: true }` for a cursor past retention, whose holder reads from the start again; `prime(tenantId, { revision })` then reflects at least a revision read there. `onChange` fires only in the process that wrote, so work that must see every change or survive a crash follows the log instead, as `automations.core` does for the workspace time zone.
28
28
 
29
29
  **Feature flags.** A flag is a module setting declared `kind: 'flag'`: a non-secret `boolean` with a `defaultValue`, a `label`, a `description` and `scope: 'tenant'`, which is the default and the only scope a flag may take; `defineModuleSettings` refuses anything else, a platform-scoped flag included. There is no flag store, no flag endpoint and no flag registry. A module reads one on the request path with the ordinary settings read, `context.settings.get<boolean>(tenantId, moduleId, key)`: a lookup of the declaration, a lookup of the cached per `(tenant, module)` value set under a key the read builds, the touch that keeps that entry at the head of the cache, the property read and one `typeof` check. No query of its own, and nothing beyond what any setting already costs: a declared `pattern` is compiled once with the declaration, never per read. There is deliberately no `context.flags`; the read is the settings read, and the module id is the one the module already knows. An override is per workspace behind `system.settings.manage` on the Flags tab of Administration, Modules, which groups every declared flag by its owning module and links the audit trail. Every change appends one `settings.flag.changed` auth audit event with the module, the key, the previous and next values and the actor (`settings.updated` stays the event for every other setting). No percentage rollout and no targeting: a flag is on or off for a workspace. A specification declares one as a `settings[]` entry with `kind: flag`, which `spec validate` holds to boolean, `scope: tenant` and a stated default; `research.core` ships the first one, `allowAgents`.
30
30
 
31
- **Branding.** The identity one deployment is served with is seven platform-scoped `system.core` settings (`appName`, `documentTitle`, `description`, `ogImageUrl`, `faviconUrl`, `themeColor`, `logoUrl`, `modules/system/src/settings.ts`), edited by a principal with `system.settings.manage` on the Administration screen `branding` and audited like any other setting. They are one value for the whole installation, because the sign-in screen and a shared link are rendered before a workspace is known. The application route resolves them once per request through the provider `system.core` installs with `installApplicationBranding` (`packages/server/src/application-branding.ts`), hands the server render page state and the browser one JSON data block, and the client reads both with `configureBrandingFromPage` plus `applicationBranding` (`packages/client/src/branding.ts`); the shell wordmark, the mobile header, the sign-in screen, the boot splash, the document title, the description, the icon, the theme colour, the social image and the label an authenticator lists a TOTP enrolment under all come from that one read, so no module fetches branding and nothing disagrees. Every value is bounded by its declaration: a name carries no markup or quote, an address is a same-origin absolute path or an https URL (`javascript:`, `data:` and protocol-relative values are refused at the write), a colour is six hex digits, and a stored value that no longer fits falls back to the product's own. Every https branding image origin an operator stores is added to `img-src` of the content security policy for that deployment, so the icon, the logo and the screen's own preview load. A logo is an address, never an upload, and there is no per-workspace branding, no colour theme and no custom CSS. An application whose own entry predates this and renders none of it, or still declares a static icon or theme colour beside the rendered one, is reported by `pnpm flowdular doctor` as the `platform.branding` check.
31
+ **Branding.** The identity one deployment is served with is seven platform-scoped `system.core` settings (`appName`, `documentTitle`, `description`, `ogImageUrl`, `faviconUrl`, `themeColor`, `logoUrl`, `modules/system/src/settings.ts`), edited by a principal with `system.settings.manage` in the operator workspace (the recorded one, or `FD_OPERATOR_TENANT` when set) on the Administration screen `branding` and audited like any other setting. They are one value for the whole installation, because the sign-in screen and a shared link are rendered before a workspace is known. The application route resolves them once per request through the provider `system.core` installs with `installApplicationBranding` (`packages/server/src/application-branding.ts`), hands the server render page state and the browser one JSON data block, and the client reads both with `configureBrandingFromPage` plus `applicationBranding` (`packages/client/src/branding.ts`); the shell wordmark, the mobile header, the sign-in screen, the boot splash, the document title, the description, the icon, the theme colour, the social image and the label an authenticator lists a TOTP enrolment under all come from that one read, so no module fetches branding and nothing disagrees. Every value is bounded by its declaration: a name carries no markup or quote, an address is a same-origin absolute path or an https URL (`javascript:`, `data:` and protocol-relative values are refused at the write), a colour is six hex digits, and a stored value that no longer fits falls back to the product's own. Every https branding image origin an operator stores is added to `img-src` of the content security policy for that deployment, so the icon, the logo and the screen's own preview load. A logo is an address, never an upload, and there is no per-workspace branding, no colour theme and no custom CSS. An application whose own entry predates this and renders none of it, or still declares a static icon or theme colour beside the rendered one, is reported by `pnpm flowdular doctor` as the `platform.branding` check.
32
32
 
33
33
  **Module activation.** The composed module set is CLI-owned and baked at build; what an owner changes from Administration, Modules is per-workspace activation of the modules the application already composes. `system.core` keeps it in `system_module_activations` (a composed module without a row is active), lists it through `GET /api/system/modules` (every catalog row with `active`, `optional` and `dependents`) and `GET /api/system/modules/active` (the active composed ids, for any member holding `system.workspace.access`), and changes it through `POST /api/system/modules/activate` and `/api/system/modules/deactivate` behind `system.settings.manage` with CSRF first. `system.core`, `auth.core`, `users.core` and `profile.core` (`REQUIRED_MODULE_IDS` in `@flowdular/sdk/contracts`) are never deactivated; a module another active module depends on, through a declared module dependency or a required capability, is refused with 409 `MODULE_HAS_ACTIVE_DEPENDENTS` naming the dependents, a required one with 409 `MODULE_REQUIRED`, and activating a module whose dependency is inactive with 409 `MODULE_DEPENDENCY_INACTIVE`. Every change appends one `system.module.activated` or `system.module.deactivated` auth audit event. The state is one per-tenant snapshot memoised for 30 seconds and published as the public capability `system.modules.v1` (`isActive(tenantId, moduleId)`, `activeIds(tenantId)`, `modules/system/src/server/capability.ts`). Enforcement costs a module nothing: the generated composition binds every route to its module id (`bindModuleCompositions`, `packages/server/src/module-activation.ts`), `defineEndpoint` answers 403 `MODULE_INACTIVE` after the permission check for an endpoint of an inactive module in the principal's workspace (`EndpointIdentity.tenantId` comes from `endpointIdentityFromContext`), and the application shell reads the active ids before it renders and hides the navigation, views, widgets and command search of an inactive module (`contributionsForActiveModules`, `packages/client/src/shell/modules.ts`; a failed read shows everything). Not covered yet: the agent tools of an inactive module are still offered, because the harness registry has no per-tenant module hook.
34
34
 
@@ -54,7 +54,9 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
54
54
 
55
55
  **Business agents.** `defineAgent` (`modules/agents/src/server/define-agent.ts`) declares `moduleId`, `key`, `definitionRevision`, `name`, `description`, `instructions`, `allowedTools` (at most 32, exact ids, no wildcards) and `limits` (`maxSteps` 1 to 32, `timeoutMs`, `temperature`, `maxOutputTokens`). Registered with `context.agentDefinitions.register(...)`. The definition owns behaviour and the maximum tool ceiling; provider, model, credentials, active state and the reduced enabled tools are tenant binding data.
56
56
 
57
- **Workflows.** `workflows.core` owns typed DAGs with exactly nine node kinds (`modules/workflows/src/domain/types.ts`): `input`, `agent`, `agent-decision`, `gate`, `validator`, `action`, `merge`, `output` and `human-approval`, which opens an approvals.core request with the run as subject, parks the run in `waiting-approval` and resumes on approval or fails it with `WORKFLOW_APPROVAL_REJECTED`, `WORKFLOW_APPROVAL_EXPIRED` or `WORKFLOW_APPROVAL_CANCELLED`; publishing such a node needs approvals.core present and the named role defined in the workspace. A module starts a run through the public `WORKFLOW_EXECUTION_CAPABILITY`; workflow trigger sources are `manual`, `module`, `schedule` and `webhook`.
57
+ **Workflows.** `workflows.core` owns typed DAGs with ten node kinds (`modules/workflows/src/domain/types.ts`): `input`, `agent`, `agent-decision`, `typed-decision`, `gate`, `validator`, `action`, `human-approval`, `merge` and `output`. A `human-approval` node opens an approvals.core request with the run as subject, parks the run in `waiting-approval` and resumes on approval or fails it with `WORKFLOW_APPROVAL_REJECTED`, `WORKFLOW_APPROVAL_EXPIRED` or `WORKFLOW_APPROVAL_CANCELLED`; publishing such a node needs approvals.core present and the named role defined in the workspace. A module starts a run through the public `WORKFLOW_EXECUTION_CAPABILITY`; workflow trigger sources are `manual`, `module`, `schedule` and `webhook`.
58
+
59
+ **Module-owned workflow templates.** A business module registers one ordinary versioned `AgentTool` through `context.agentTools` and may add static `workflowTemplate: { label, description, effect }` to present that action as a named palette choice. `agents.actions.v2` exposes the metadata and complete executable action descriptor from the same registry and ledger as `agents.actions.v1`; the older capability keeps its response shape. Selecting a template creates an ordinary `action` node pinned to the action id, contract version, schemas, permissions, risk, idempotency protection, timeout, cancellation and effect. The metadata has a label of at most 80 characters, description of at most 240, and effect `local` or `connector-egress`; it grants no permission and contains no code, credential or tenant value. The action requires bounded input and output JSON Schemas, read or workspace-write risk, required idempotency, and a code-level validator in the owning module. Writes require a durable target ledger. A template cannot require a raw input marked `writeOnly` or `x-flowdular-secret`; graph input uses a nonsecret opaque reference resolved by the module. Connector egress goes through `connectors.calls.v1` with `allowWorkflows` consent and a stable side-effect key. A replay exposes only its stable call id and outcome, and `CALL_OUTCOME_UNKNOWN` is terminal until a separate remote recovery contract exists. In Sandbox, a business manager updates the owning module's specification, the operator approves its exact hash, and backend and agentic specialists implement and test their owned files before host preview, review and eject. No new workflow node kind or workflow-node registry is involved.
58
60
 
59
61
  **Automations triggers.** Two mechanisms in `automations.core`. A schedule uses a cadence string in one of two forms (`modules/automations/src/domain/cadence.ts`): `every:N`, where `N` is whole minutes from 1 to 10080 and the unit is implicit, or `cron:<minute> <hour> <day of month> <month> <day of week>`, five fields with no seconds, at most 100 characters, each field `*`, a number, a three letter month or weekday name, a list, a range, or any of those with a step (`*/15`, `9-17/4`); both day fields restricted means either matches. A cron slot is a wall clock time in the workspace zone from the shared `system.core.timeZone` setting, stored in UTC; a wall time a daylight saving change removes is skipped and one that occurs twice fires at the first of the two. An expression the module cannot honour is refused at save with `INVALID_CADENCE`. Missed slots are skipped, never replayed. An inbound webhook posts to `POST /api/automations/triggers/:id/fire`, authenticated by an HMAC signature in `x-flowdular-signature` with `x-flowdular-timestamp` inside a 5 minute window and a 16 KB body cap, answering `202 { accepted, runId }`. Targets are pluggable through `automations.targets.v1`; the shipped kinds are `agent` and `workflow`.
60
62
 
@@ -68,15 +70,15 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
68
70
 
69
71
  **Notifications.** `notifications.core` (optional, enabled by default) owns a per-member in-app inbox with preferences, per-tenant outbound webhook subscriptions signed with the inbound automations scheme, and e-mail delivery of inbox items, all three sharing one delivery ledger with bounded retry and a dead letter. A module publishes through the public capability `notifications.publish.v1` obtained lazily with `context.capabilities.get` and tolerates its absence; the input is `{ tenantId, kind, sourceModule, sourceRef, title, body?, recipients }` with the kinds `agent-run-completed`, `agent-run-failed`, `workflow-run-completed`, `workflow-run-failed`, `webhook-dead-letter` (`modules/notifications/src/domain/publish.ts`). A new kind is a `notifications.core` spec edit, not a publisher's decision. A publisher never chooses a channel: the member does, with the per-kind switch that decides whether an item exists at all and the `emailDelivery` switch (off by default) that also mails the items they receive, through the platform mail port and the address `auth.core` holds. `ToastHost` and `toasts` remain the in-screen confirmation for what the reader just did.
70
72
 
71
- **Mail.** A module sends through `context.mail` (`packages/server/src/mail/`) and never selects a transport, a relay or a provider SDK. `send({ to, subject, text, html?, locale?, headers? })` takes at most 16 recipients, a 200 character single-line subject, 64 KB of text, 256 KB of HTML and 16 extra headers whose names the envelope does not own; CR and LF are refused everywhere a header could be opened, so header injection is the port's problem and not each sender's. It rejects with a `MailError` carrying `MAIL_NOT_CONFIGURED` (no transport composed), `MAIL_MESSAGE_REJECTED` (a bound) or `MAIL_DELIVERY_FAILED` (the relay, whose words never travel with it); `mail.configured` is the flag a feature gates on instead of provoking a refusal. `renderMailTemplate({ subject, text, html? }, values)` fills `{{ name }}` holes in one pass and escapes every value for the HTML part. The deployment picks the adapter with `FD_MAIL_TRANSPORT`: `none` (refuses), `development` (an in-memory outbox of the last 100 messages, refused in production) or `smtp` (`FD_MAIL_SMTP_URL`, `FD_MAIL_FROM`; the retired `FD_AUTH_MAIL_*` names still work with a deprecation line). An installation may instead store the relay in the platform-scoped `auth.core` settings `mailTransport`, `mailSmtpUrl` (secret), `mailFrom`, `mailRequireTls` and `mailRejectUnauthorized`; a stored transport of `none` or `smtp` wins over the environment for every sender and is resolved per message, while the default `environment` leaves `FD_MAIL_*` in effect and is the only way to reach the development adapter. `auth.core` sends invitations, resets and confirmations through it, `notifications.core` mails inbox items; a module that wants to reach a person publishes a notification rather than composing mail of its own.
73
+ **Mail.** A module sends through `context.mail` (`packages/server/src/mail/`) and never selects a transport, a relay or a provider SDK. `send({ to, subject, text, html?, locale?, headers? })` takes at most 16 recipients, a 200 character single-line subject, 64 KB of text, 256 KB of HTML and 16 extra headers whose names the envelope does not own; CR and LF are refused everywhere a header could be opened, so header injection is the port's problem and not each sender's. It rejects with a `MailError` carrying `MAIL_NOT_CONFIGURED` (no transport composed), `MAIL_MESSAGE_REJECTED` (a bound) or `MAIL_DELIVERY_FAILED` (the relay, whose words never travel with it); `mail.configured` is the flag a feature gates on instead of provoking a refusal. `renderMailTemplate({ subject, text, html? }, values)` fills `{{ name }}` holes in one pass and escapes every value for the HTML part. The deployment picks the adapter with `FD_MAIL_TRANSPORT`: `none` (refuses), `development` (an in-memory outbox of the last 100 messages, refused in production) or `smtp` (`FD_MAIL_SMTP_URL`, `FD_MAIL_FROM`; the retired `FD_AUTH_MAIL_*` names still work with a deprecation line). The operator workspace may instead store the relay in the platform-scoped `auth.core` settings `mailTransport`, `mailSmtpUrl` (secret), `mailFrom`, `mailRequireTls` and `mailRejectUnauthorized`; a stored transport of `none` or `smtp` wins over the environment for every sender and is resolved per message, while the default `environment` leaves `FD_MAIL_*` in effect and is the only way to reach the development adapter. `auth.core` sends invitations, resets and confirmations through it, `notifications.core` mails inbox items; a module that wants to reach a person publishes a notification rather than composing mail of its own.
72
74
 
73
- **Object storage.** A module writes files through `context.storage` (`packages/storage/src/index.ts`) and never sees an adapter, a bucket or a path. `put`, `get`, `delete`, `stat` and `readUrl` take `{ tenantId, moduleId, objectId }`; the key is `<tenantId>/<moduleId>/<objectId>` and the tenant id comes from the principal, never from the request, because no row-level security reaches an object store. Development and test use a local directory, a deployment uses an S3-compatible bucket (`FD_STORAGE_ADAPTER=local|s3`, `local` refused in production). Every object is encrypted with AES-256-GCM under `FD_STORAGE_ENCRYPTION_KEY` before it is written, with the key id and the metadata authenticated alongside it. An object is at most 25 MB (`FD_STORAGE_MAX_OBJECT_BYTES`) and must be one of PDF, PNG, JPEG, GIF, WebP, plain text, CSV, `.docx`, `.xlsx`, `.pptx`, `application/msword` or `application/vnd.ms-excel`, verified against the bytes; archives and executables are refused. A malware scanner is a deployment seam, so the stored verdict is `clean`, `infected` (refused) or `unscanned` (the default). `readUrl` returns `/api/storage/objects/<token>`, a signed platform route that expires in at most an hour and streams the decrypted body as an attachment, never a presigned URL to the ciphertext. Upload, metadata and the attachment table belong to `documents.core`, described next; a module never puts bytes of its own through `context.storage` when a document fits.
75
+ **Object storage.** A module writes files through `context.storage` (`packages/storage/src/index.ts`) and never sees an adapter, a bucket or a path. `put`, `get`, `delete`, `stat` and `readUrl` take `{ tenantId, moduleId, objectId }`; the key is `<tenantId>/<moduleId>/<objectId>` and the tenant id comes from the principal, never from the request, because no row-level security reaches an object store. Development and test use a local directory, a deployment uses an S3-compatible bucket or a private Vercel Blob store (`FD_STORAGE_ADAPTER=local|s3|vercel-blob`, `local` refused in production). Every object is encrypted with AES-256-GCM under `FD_STORAGE_ENCRYPTION_KEY` before it is written, with the key id and the metadata authenticated alongside it. An object is at most 25 MB (`FD_STORAGE_MAX_OBJECT_BYTES`) and must be one of PDF, PNG, JPEG, GIF, WebP, plain text, CSV, `.docx`, `.xlsx`, `.pptx`, `application/msword` or `application/vnd.ms-excel`, verified against the bytes; archives and executables are refused. A malware scanner is a deployment seam, so the stored verdict is `clean`, `infected` (refused) or `unscanned` (the default). `readUrl` returns `/api/storage/objects/<token>`, a signed platform route that expires in at most an hour and streams the decrypted body as an attachment, never a presigned URL to the ciphertext. Upload, metadata and the attachment table belong to `documents.core`, described next; a module never puts bytes of its own through `context.storage` when a document fits.
74
76
 
75
77
  **Connectors.** `connectors.core` (optional) is the governed way out to an external system; its egress policy is also the public capability `connectors.egress.v1` for a module that opens one public URL of its own (`modules/connectors/src/domain/egress.ts`). A module ships a connector definition through the public capability `connectors.definitions.v1` (`modules/connectors/src/domain/definitions.ts`): `register({ key, moduleId, label, authKinds, operations: [{ key, label, method, path, inputSchema, outputSchema }], defaultAllowedHosts, allowedPorts? })` (ports default to 443 only), and the platform ships `http-json`. An owner creates an instance behind `connectors.instances.manage` with a base URL, sealed credentials under `FD_CONNECTORS_SECRET_KEY`, a host allowlist and two consent flags, `allowWorkflows` and `allowAgents`, both off. A call goes through `connectors.calls.v1`: `call({ tenantId, instanceId, operation, input, caller: 'test' | 'workflow' | 'agent', callerRef? })`, which enforces status, consent for that caller kind, the egress policy (https, no private addresses, no redirects, timeout and size caps) and logs the call without any body, answering `retryAfterMs` from a 429 or 503 `Retry-After` header; `consented(tenantId, instanceId, caller)` answers admission alone. A module keeps its own instance of its own definition through `connectors.instances.v1` (`modules/connectors/src/domain/instances.ts`): `upsertModuleInstance({ tenantId, moduleId, key, definition, baseUrl, credentials?, allowedHosts, allowAgents, allowWorkflows, actor })` creates or updates the one instance per workspace, module and key (absent credentials keep the sealed one; a definition of another module answers `DEFINITION_FOREIGN`), and `describeModuleInstance({ tenantId, moduleId, key })` answers it with `hasCredentials` and never a credential. The agent tool `connectors.call` is declared `workspace-write` with the harness consent gate `connectors.instance-consent` (`AgentToolConsent` in `packages/harness/src/runtime.ts`), carries the full action contract (`idempotency: 'required'` backed by a per-call key ledger) so workflows may use it, has no HTTP route of its own, and dials only the addresses the egress policy verified, so the ceiling is raised per instance by the owner's consent, never by a declaration.
76
78
 
77
79
  **Research.** `research.core` (optional) searches the web and reads public pages for agents, workflows and members, and keeps what they read as citeable evidence. A search runs an ordered chain of adapters: `model-native` (reached only by an Anthropic or OpenAI model inside an agent run through the native tool `research.web-search`), `searxng` (a self-hosted SearXNG, `GET /search` with `format=json`), `firecrawl` (the Firecrawl API, `POST /v2/search`), `connector` (the `connectors.core` instance named by `connectorInstanceId`, operation `search`, input `{ q, limit }`) and `recorded` (a `research-fixtures.json` file `{ queries: { [query]: ResearchResult[] }, pages: { [url]: { title, text } } }` at the absolute or workspace-relative `recordedFixturesPath`, for tests and the sandbox). The owner orders and switches them on the Search adapters tab behind `research.settings.manage` (settings `searchOrder` and `<key>Enabled`; while `searchOrder` is empty the single setting `research.core.adapter`, default `model-native`, decides), with `<key>MaxAttempts` (1 to 5), `<key>TimeoutMs`, full jitter backoff `retryBackoffMs` capped at 5 s, `Retry-After` honoured on a 429, `fallback` (`next-adapter` or `fail`), `fallbackOnEmpty`, and a per-workspace circuit breaker (`circuitFailureThreshold`, `circuitCooldownMs`, one half open probe); every attempt is a `research_attempts` row. A fetch runs `fetchOrder` over `direct` (the module's own reader) and `firecrawl` (`POST /v2/scrape`, JavaScript rendered to markdown), and a domain rule, robots.txt, the egress policy or the size cap never falls back. A provider that reports the native web search unsupported marks `model-native` Unsupported on the tab and makes its read back a permanent failure the chain passes over. SearXNG and Firecrawl are reached only through module-owned `connectors.core` instances (definitions `research-searxng` and `research-firecrawl`) whose credentials connectors.core seals and whose consent follows `allowAgents`. A module resolves `research.search.v1` (`search({ tenantId, query, limit?, freshness?, site?, caller, callerRef? })` answering `{ results, adapter, attempts }`, the adapter being the one that answered), `research.fetch.v1` (`fetch({ tenantId, url, caller, callerRef? })` answering `{ evidenceId, title, text, truncated, contentSha256, retrievedAt }`) and `research.evidence.v1` (`attach(tenantId, ownerModule, recordRef, evidenceIds)`, `list(tenantId, ownerModule, recordRef)`, `get(tenantId, id)`), all in `modules/research/src/domain/capability.ts`. Every answered search counts one unit, whatever the chain tried, against `monthlyQueryBudget` under a per-workspace lock and the meter `research.core.queries`, and past it answers `RESEARCH_BUDGET_EXCEEDED` (429); the allow and deny domain lists filter results and refuse fetches with `RESEARCH_DOMAIN_DENIED`. A fetch reaches the network only through `connectors.egress.v1` (`check(url)` answering the verified addresses and a lookup pinned to them), honours `robots.txt` (cached per host for an hour), follows one redirect, stops at `fetchMaxBytes` and `fetchTimeoutMs`, turns HTML into text with its own extractor, reads a PDF the direct reader downloaded through `documents.text.v1` `extractBytes` when documents.core is composed and answers `RESEARCH_CONTENT_UNSUPPORTED` for it otherwise, caches pages for 24 hours and allows 64 fetches per run. Every kept result and fetched page is a `research_evidence` row with the sha256 and a 4 KB excerpt, the full text only while `storeFullText` is on. The agent tools `research.search` and `research.fetch` are `workspace-write` behind the consent gate `research.consent` (setting `allowAgents`, off by default, refusing with `TOOL_NOT_CONSENTED`). The Research screen (Administration, section compliance) lists Evidence and Queries (a query opens its attempts) and, for owners, Search adapters, and another module links to one piece of evidence with `workspaceViewHref('research-evidence') + '?id=' + id`. Not covered yet: full text stored as a document.
78
80
 
79
- **Approvals.** `approvals.core` (optional) turns a policy's `requiresApproval` into a request people decide. A module opens one through the public capability `approvals.requests.v1` (`modules/approvals/src/domain/capability.ts`): `open({ tenantId, subjectModule, subjectRef, permission, action, title, summary?, requesterAccountId, requirement, onResolved? })`, plus `get`, `list` and `cancel`. Eligible deciders are resolved at open from the requirement's role key and scope (both, when both are named; the requester never decides), re-read at decision time, and a request needs `decisions` approvals before `expiresInDays` runs out. Open is idempotent per subject while a request is pending, and a requirement that resolves to nobody, to too few or to more than 200 deciders is refused with a stable code. Decisions are an append-only ledger behind `approvals.requests.read`, `approvals.requests.decide` and `approvals.requests.manage`; deciders and requesters are notified through the kinds `approval-requested` and `approval-decided`. The subject module learns the outcome from `onResolved`, which runs once per terminal state after the deciding transaction commits, or by reading the request back. A request whose `subjectRef` is `encodeCapabilitySubjectRef({ capabilityId, inputDigest })` yields, once approved, a signed token through `grant(tenantId, id, subjectModule)`, handed only to the module that opened it; the CLI runner takes it as `--grant` (valid until expiry) and the harness as `AgentExecutionRequest.grants` (one tool call per grant), both bound to the tenant, the capability id and `approvalInputDigest` of the input.
81
+ **Approvals.** `approvals.core` (optional) turns a policy's `requiresApproval` into a request people decide. A module opens one through the public capability `approvals.requests.v1` (`modules/approvals/src/domain/capability.ts`): `open({ tenantId, subjectModule, subjectRef, permission, action, title, summary?, requesterAccountId, requirement, onResolved? })`, plus `get`, `list` and `cancel`. Eligible deciders are resolved at open from the requirement's role key and scope (both, when both are named; the requester never decides), re-read at decision time, and a request needs `decisions` approvals before `expiresInDays` runs out. Open is idempotent per subject while a request is pending, and a requirement that resolves to nobody, to too few or to more than 200 deciders is refused with a stable code. Decisions are an append-only ledger behind `approvals.requests.read`, `approvals.requests.decide` and `approvals.requests.manage`; deciders and requesters are notified through the kinds `approval-requested` and `approval-decided`. The subject module learns the outcome from `onResolved`, which runs once per terminal state after the deciding transaction commits, or by reading the request back. The request `get` answers and the one `onResolved` receives both carry `deciderAccountIds`, the accounts whose ledger rows settled the terminal state in ledger order: every approver of an approved request, the account that rejected or cancelled it, none while pending or after an expiry; account ids only, never comments. A request whose `subjectRef` is `encodeCapabilitySubjectRef({ capabilityId, inputDigest })` yields, once approved, a signed token through `grant(tenantId, id, subjectModule)`, handed only to the module that opened it; the CLI runner takes it as `--grant` (valid until expiry) and the harness as `AgentExecutionRequest.grants` (one tool call per grant), both bound to the tenant, the capability id and `approvalInputDigest` of the input.
80
82
 
81
83
  **Documents.** `documents.core` (optional) owns file attachments of any record. A screen uploads through `POST /api/documents/upload` with the raw body and the headers `x-document-filename`, `x-document-owner-module`, `x-document-record-ref` and `x-document-description`, lists with `GET /api/documents?ownerModule=&recordRef=`, opens through `POST /api/documents/read-url` (a short-lived storage URL) and deletes through `POST /api/documents/delete`, all behind `documents.files.read` or `documents.files.manage` with CSRF first. A module reads its own records' attachments through the public capability `documents.attachments.v1` (`modules/documents/src/domain/attachments.ts`): `list(tenantId, ownerModule, recordRef)`, `open(tenantId, ownerModule, recordRef, id)` answering `{ contentType, bytes, filename, body }` or null for anything not readable (unknown, another pair, deleted, infected) and `delete(tenantId, ownerModule, recordRef, id)`; the reference pair is a scope, the caller's permission on its own record is the authorization, and the storage key never leaves documents.core. Checksums, scan verdicts and the object limits come from the storage port. The text of a document comes from the public capability `documents.text.v1` (`modules/documents/src/domain/text.ts`): `extract(tenantId, ownerModule, recordRef, id, { pages? })` for a document of the caller's reference pair (null for every reference `open` answers null for) and `extractBytes({ contentType, bytes, pages?, signal? })` for bytes that are not stored, both answering `{ status: 'ok' | 'unscanned' | 'unsupported' | 'too-large' | 'pending', reason, text, pages, from, to, truncated, contentSha256 }` with the pages of the text separated by a form feed and `pages` a 1-based `{ from, to }` range. A PDF is read from its text layer page by page (pdf.js through `unpdf`, nothing executed or fetched), a PPTX slide by slide, an XLSX as one block per sheet with tab separated rows, and a DOCX, a CSV or a plain text file in pages of at most 10000 characters; the legacy `.doc` and `.xls` answer `unsupported`. At most 200 pages and 2 MiB of text are kept (`truncated` beyond), input over 25 MiB answers `too-large`, and an OOXML package stops at 32 MiB of bytes actually inflated. A PDF without a text layer and an image answer `unscanned` unless the deployment sets `FD_DOCUMENTS_OCR_URL` (and `FD_DOCUMENTS_OCR_TOKEN`), which receives the bytes through the `connectors.egress.v1` address rules. The text of a stored document is kept in `documents_text` by document and checksum, so a second read parses nothing; a document over 2 MiB or one sent to OCR answers `pending` until the text runner settles it. `POST /api/documents/text` (behind `documents.files.read`) and `POST /api/documents/text/retry` (behind `documents.files.manage`, unscanned text while OCR is available) serve the Text tab of the document details, and the agent tool `documents.read-text` (risk `read`, `documents.files.read`) answers a page range of a record's document cut to 20000 UTF-8 bytes. Documents from templates come from the public capability `documents.templates.v1` (`modules/documents/src/domain/templates.ts`): a module calls `register(moduleId, [{ key, title, format, body, inputSchema, locale, layout }])` while it composes (`key` is `<module id>.<name>`, `format` `pdf` or `docx`, `locale` `en` or `pl`, at most 256 templates, every template validated at once and refused at boot with `TEMPLATE_REGISTRATION_INVALID` naming the line, the catalogue sealed when documents.core starts), then `render({ tenantId, principal: { accountId, scopes }, ownerModule, recordRef, templateKey, input, format? })` for its own record after checking its own record permission, answering `{ jobId, status, documentId, errorCode, templateKey, version, format }` with `status` `queued`, `running`, `succeeded` or `failed`, and `status(tenantId, jobId)` later; `render` refuses a principal without `documents.files.manage` with `FORBIDDEN`. A body is Markdown restricted to headings 1 to 3, paragraphs with bold, italic, inline code and links (printed as the text and the URL in parentheses), bulleted and numbered lists one level deep, block quotes, a rule, the line `---pagebreak---` and pipe tables, plus HTML comments on lines of their own, which are dropped; HTML, images, footnotes, reference links, code blocks, setext and level 4 headings are refused with a stable code and the line. The body is parsed first and `{{ path }}` is substituted into text nodes after, so an input value is printed as text and never read as Markdown. The formatters are `money: currency` (minor units and an ISO 4217 field or quoted code), `number: 0..6`, `date` and `datetime` (the workspace zone from `system.core.timeZone` and the template locale; a `YYYY-MM-DD` date is not shifted), `upper` and `yesno`, one per placeholder; `{{#each path}}` repeats table rows or blocks with `this`, `@index` (from 0) and `@number` (from 1), `{{#if path}} {{else}} {{/if}}` keeps rows or blocks, each block tag on a line of its own, and a path is searched from the innermost item outwards through own properties of the input only. The input schema is a JSON Schema subset (an object root; object, string with `maxLength` and `enum`, number, integer, boolean, array of an object or a scalar; `required`, `title`, `description`; at most 4 levels and 64 properties per object), every placeholder is checked against it at validation (`TEMPLATE_FIELD_UNKNOWN`, `TEMPLATE_FIELD_TYPE`), and a render validates the input first (`TEMPLATE_INPUT_INVALID` with up to 20 issues naming the path). `templateInputSchemaFromFields(fields)` builds the schema from a spec entity. A layout is the page size `A4` or `Letter`, margins of 5 to 60 mm, a one-line header and footer with placeholders plus `{{page}}` and `{{pages}}`, and a title that also names the stored file. Bounds: a body of 65536 characters and 8 nested blocks, 2000 repeated rows or blocks (`TEMPLATE_ROWS_EXCEEDED`), 1000000 printed characters (`TEMPLATE_OUTPUT_TOO_LARGE`), 200 PDF pages (`TEMPLATE_PAGES_EXCEEDED`), an input of 256 KiB, strings of 10000 characters and arrays of 2000 items. A workspace uses the module default until it renders or edits a template; then `document_template_versions` keeps the default as version 1 and `document_templates` names the current version. A save appends an immutable version (`TEMPLATE_VERSION_CONFLICT` on a stale expected version, `TEMPLATE_UNCHANGED` when nothing changed), a restore appends a copy of an earlier version and a revert a copy of the module default; a workspace whose current version came from the default or a revert follows a changed default on its next render, an edited one keeps its edit. A render is a `document_renders` row unique by workspace, template key, version, owner module, record reference, format and input digest, so a repeated render answers the same row, a failed one is queued again and one whose document was deleted renders again under the next generation; the input is kept until the render settles. A render of at most 50 repeated rows and 16 KiB of input runs within the call, any other on the job runner `documents.core.render` (heartbeat, five minute stale takeover, `CLAIM_LOST`, three attempts before `TEMPLATE_RENDER_FAILED`); the bytes go through the storage port under an object id derived from the render id and the `documents_files` row of the owning record is inserted in the transaction that marks the render succeeded while the claim holds, so a process that dies mid render repeats it without a second document, and the workspace quota applies (`QUOTA_EXCEEDED`). PDF is rendered by pdfmake 0.3.11 over pdfkit with the Roboto family embedded (Latin Extended, so Polish renders), tables repeating their header row across pages and a header and footer on every page, with every URL and file access refused; DOCX by docx 9.5.1 with a repeated header row and page number fields; both behind a renderer interface, server side only. `GET /api/documents/templates`, `/detail`, `/versions` (keyset paged) and `/version` and `POST /api/documents/templates/preview` (the draft rendered to bytes, nothing stored, at most two at a time per process, `TEMPLATE_PREVIEW_BUSY` 429 beyond) sit behind `documents.templates.read` (members), `POST /api/documents/templates/save` and `/revert` behind `documents.templates.manage` (owners), every POST CSRF first. The agent tool `documents.render` (risk `workspace-write`, `idempotency: 'required'`, `target-ledger`) requires `documents.templates.read` and `documents.files.manage`; its harness key is bound in `document_render_keys` to the render it first reached with the request digest, so a retry answers that render even after the template gained a version and a key reused for another request is refused with `TEMPLATE_RENDER_KEY_REUSED`, and `documents.render-status` (risk `read`, `documents.templates.read`) reads a job by id. Versions are the data class `documents.core.templates` (kept) and renders `documents.core.renders` (90 days, settled rows only, exported without the input). The Templates screen (Administration, section platform) lists the registered templates and opens an editor with line numbers, layout fields, a sample input, a preview in the browser's own PDF viewer or a DOCX download, and the version history with a diff, Restore and Revert.
82
84