create-flowdular 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
- package/agent-template/.agents/skills/module-update/SKILL.md +1 -1
- package/agent-template/.agents/skills/perf-audit/SKILL.md +1 -1
- package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.ai/agents/README.md +1 -1
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -1
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -1
- package/agent-template/.ai/agents/sandbox/business-manager.md +2 -4
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -1
- package/agent-template/.ai/agents/sandbox/ux-designer.md +1 -1
- package/agent-template/.ai/platform-capabilities.md +4 -4
- package/agent-template/.ai/policies/model-routing.yaml +2 -1
- package/agent-template/.ai/policies/task-budgets.yaml +1 -1
- package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.up.sql +2 -2
- package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.up.sql +2 -2
- package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +2 -2
- package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +2 -2
- package/agent-template/.ai/references/catalog/module.json +3 -3
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +3 -3
- package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +2 -2
- package/agent-template/.ai/references/catalog/src/services/migration.ts +8 -8
- package/agent-template/.ai/references/catalog/tests/migrations.test.ts +1 -1
- package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
- package/agent-template/.ai/skills/module-update/SKILL.md +1 -1
- package/agent-template/.ai/skills/perf-audit/SKILL.md +1 -1
- package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
- package/agent-template/.claude/skills/module-update/SKILL.md +1 -1
- package/agent-template/.claude/skills/perf-audit/SKILL.md +1 -1
- package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/docs/adr/0003-module-settings.md +2 -0
- package/agent-template/docs/agent-contract.md +1 -1
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +16 -0
- package/agent-template/docs/configuration.md +32 -4
- package/agent-template/docs/database-adapters.md +10 -2
- package/agent-template/docs/design-system.md +6 -2
- package/agent-template/docs/getting-started.md +5 -1
- package/agent-template/docs/module-distribution.md +1 -2
- package/agent-template/docs/modules.md +5 -3
- package/agent-template/docs/sandbox.md +64 -7
- package/package.json +1 -1
- package/template/default/.env.example +5 -0
- package/template/default/infra/docker/.env.example +5 -0
- package/template/default/infra/docker/Dockerfile +5 -1
- package/template/default/infra/docker/compose.yaml +4 -0
- package/template/default/infra/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/build.mjs +9 -0
- package/template/default/modules/example/package.json +1 -1
- package/template/default/package.json +2 -3
- package/template/default/platform/octane.config.ts +53 -15
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/scripts/dev.mjs +66 -21
- package/template/default/platform/src/server/lifecycle.ts +325 -0
- package/template/default/platform/src/server/setup/modules.ts +28 -32
- package/template/default/platform/src/server/setup/page.ts +54 -3
- package/template/default/platform/src/server/setup/routes.ts +1 -0
- package/template/default/platform/src/server/setup/seed.ts +51 -4
- package/agent-template/.ai/references/catalog.provenance.json +0 -65
|
@@ -14,7 +14,7 @@ Two ways to land the same module: the sandbox (a brief, specialist turns, gates
|
|
|
14
14
|
|
|
15
15
|
With an approved `schemaVersion: 2` spec, the specification is the requirement document and you do not go looking for one. Read the spec, the files on this skill's touch list, and `.ai/references/catalog` for shape. Do not scan `modules/` or `packages/`; `.ai/platform-capabilities.md` answers what the platform provides, and the reference module answers what the code looks like.
|
|
16
16
|
|
|
17
|
-
Anything the spec does not say is a spec defect, not a decision you make. A missing field, an unstated conflict behaviour, an undefined state transition, a screen without columns: report it back. In the sandbox that is `
|
|
17
|
+
Anything the spec does not say is a spec defect, not a decision you make. A missing field, an unstated conflict behaviour, an undefined state transition, a screen without columns: report it back. In the sandbox that is a `questions` block the operator answers, and the business manager records an answer that changes the spec for a new approval before you continue; on a host it is a question to the user. Never fill the gap with a plausible guess, and never implement anything listed in `outOfScope[]`.
|
|
18
18
|
|
|
19
19
|
Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A scenario with no test is unfinished work, and the scenario id belongs in the test name so the mapping is readable.
|
|
20
20
|
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
|
|
11
11
|
With an approved `schemaVersion: 2` spec delta, the specification is the requirement document. Read the spec, the module itself, the touch list for the change class below, and `.ai/references/catalog` for shape. Do not scan `modules/` or `packages/`: `.ai/platform-capabilities.md` answers what the platform provides.
|
|
12
12
|
|
|
13
|
-
Anything the delta does not say is a spec defect, not your decision. Report it back (`
|
|
13
|
+
Anything the delta does not say is a spec defect, not your decision. Report it back (a `questions` block in the sandbox, which the business manager turns into a spec delta for a new approval when the answer changes the spec; a question to the user on a host) instead of guessing, and never implement an item the spec parks in `outOfScope[]`. Every new or changed `acceptanceScenarios[]` entry maps to at least one test, with the scenario id in the test name.
|
|
14
14
|
|
|
15
15
|
The spec-element to file mapping is the table in `module-new`; the change classes below are the same mapping arranged by what you are changing.
|
|
16
16
|
|
|
@@ -93,6 +93,6 @@ Complexity to state in the review: for each new data structure and loop on a req
|
|
|
93
93
|
## Pitfalls
|
|
94
94
|
|
|
95
95
|
- `LIKE` or `=` against `lower(column)` cannot use a plain `(tenant_id, column)` index; store a normalized column (`sku_normalized`) as `.ai/references/catalog` does, or add an expression index on `lower(column)`.
|
|
96
|
-
- `ORDER BY lower(name)`
|
|
96
|
+
- `ORDER BY lower(name)` cannot use a `(tenant_id, name, id)` index for the sort; acceptable at small sizes, name it once the table grows.
|
|
97
97
|
- A `Kpi` that shows `items.length` after loading the full list is O(rows) network per dashboard load.
|
|
98
98
|
- Never change behaviour in a performance commit; keep the functional tests green and add none that assert internal call counts.
|
|
@@ -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
|
|
49
|
-
approveSpecification in
|
|
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.
|
|
@@ -8,7 +8,7 @@ Two families of role prompts live here. They share one front matter schema (`id`
|
|
|
8
8
|
|
|
9
9
|
What the front matter does at run time:
|
|
10
10
|
|
|
11
|
-
- `gates`: enforced. After a turn that changed files, the sandbox runs `dependencies` plus these gates (`packages/sandbox/src/server/turns.ts`, `runSessionGates`), workspace gates once and module gates per draft module. Ids must come from `packages/sandbox/src/server/gates.ts`: `spec-schema`, `module-schema`, `dependencies`, `typecheck`, `tests`, `format`. An unknown id is dropped silently.
|
|
11
|
+
- `gates`: enforced. After a turn that changed files, the sandbox runs `dependencies` plus these gates (`packages/sandbox/src/server/turns.ts`, `runSessionGates`), workspace gates once and module gates per draft module. Ids must come from `packages/sandbox/src/server/gates.ts`: `spec-schema`, `module-schema`, `dependencies`, `typecheck`, `tests`, `format`. An unknown id is dropped silently. A gate whose last result did not pass also runs after every turn until it passes. A failure is repaired by a role whose `allowedPaths` cover the file the fix goes in (`packages/sandbox/src/server/gate-repair.ts`), with the reported errors in the fix prompt.
|
|
12
12
|
- `allowedPaths`: enforced after every turn. Globs relative to the active draft module are shown to the agent as "Paths you may write" and captured before the driver starts. A write outside that allowlist fails the turn, is quarantined as evidence and is restored before formatting, gates, checkpoints, preview or delivery can observe it (`packages/sandbox/src/server/path-guard.ts`, `turns.ts`).
|
|
13
13
|
- `handoff`: enforced. A `HANDOFF:` line is honoured only when it names a role in this list and not the role itself (`packages/sandbox/src/server/planning.ts`); otherwise the state routing decides and the transcript says why. The team list in the instruction is built from this list.
|
|
14
14
|
- `id`, `name`, `purpose`: composed into the instruction after `SANDBOX_AGENT_CONTRACT`, before the session facts.
|
|
@@ -29,4 +29,4 @@ The allowed tool list is a maximum. Effective authority also requires tenant bin
|
|
|
29
29
|
|
|
30
30
|
Module-owned agents require a tenant provider/model binding, retained definition revisions and an exact tool ceiling. Never pin provider credentials or use wildcard tools. Procedures stored by agents.core are business data, unrelated to coding skills.
|
|
31
31
|
|
|
32
|
-
If the requested surface needs a missing endpoint/service, hand off to backend. If a permission or acceptance scenario
|
|
32
|
+
If the requested surface needs a missing endpoint/service, hand off to backend. If a permission, tool or acceptance scenario the work needs is not in the approved spec, ask for it with a questions block (Session); never add it yourself. Never edit another module or platform package from this session.
|
|
@@ -43,4 +43,4 @@ Test observable behavior: successful operations, validation bounds, 401/403, uni
|
|
|
43
43
|
|
|
44
44
|
Operator sample data comes from the sample-data tool or reference/sample-data.json. Derive tests/fixtures/\*.json and preview/seed.json from its shape with invented names, contacts and identifiers. src/preview.ts exports an idempotent seed({ tenantId, accountId, data, databases }) that writes through the module repository. A spec research section reads research-fixtures.json and each adapter its adapters/<id>.recorded.json; never declare a live adapter.
|
|
45
45
|
|
|
46
|
-
Leave client files to the frontend engineer and tools/business-agent definitions to the agentic engineer. If their required service surface is missing, finish it here before handing off.
|
|
46
|
+
Leave client files to the frontend engineer and tools/business-agent definitions to the agentic engineer. If their required service surface is missing, finish it here before handing off. Implement only what the approved spec states. Never add an error code, field, permission, state or behaviour it does not define, not even as the cautious choice: ask for it with a questions block (Session) and leave it unbuilt.
|
|
@@ -14,9 +14,7 @@ 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.
|
|
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.
|
|
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.
|
|
20
18
|
|
|
21
19
|
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
20
|
|
|
@@ -24,4 +22,4 @@ State actors, records, ownership, permissions, uniqueness, failure behavior and
|
|
|
24
22
|
|
|
25
23
|
Put the primary entity's read/manage permissions first: the scaffold builds that entity, while later permissions only become constants. Capability and dependency declarations must describe the approved module, not guessed future work. Define matching terminology for each declared locale.
|
|
26
24
|
|
|
27
|
-
Do not write TypeScript, module.json or package.json. Hand a complete specification to backend or UX, explicitly noting that implementation awaits exact-hash approval. If a business decision is missing, end with HANDOFF: none
|
|
25
|
+
Do not write TypeScript, module.json or package.json. Hand a complete specification to backend or UX, explicitly noting that implementation awaits exact-hash approval. If a business decision is missing, ask it with a questions block (Session) and end with HANDOFF: none.
|
|
@@ -25,4 +25,4 @@ Use the canonical createClientContribution entry. Navigation must point at an ex
|
|
|
25
25
|
|
|
26
26
|
Records own the page, with create/edit in a Drawer. Reuse TableCard and Table, including widths, loading and empty states. Use translated copy, all five states, and no hardcoded design values. Inspect the rendered screen before handoff.
|
|
27
27
|
|
|
28
|
-
For TSRX, loop keys can read only the loop item: precompute a key on each item if it needs props or local state. Test pure mapping/filtering logic in .ts helpers. Ask the backend engineer for
|
|
28
|
+
For TSRX, loop keys can read only the loop item: precompute a key on each item if it needs props or local state. Test pure mapping/filtering logic in .ts helpers. Ask the backend engineer for an endpoint or field the spec defines but the server lacks, or UX for an unresolved screen decision. Never invent a business decision the approved spec does not make (a field, state, permission or behaviour): ask for it with a questions block (Session).
|
|
@@ -20,4 +20,4 @@ Specify loading, empty, error, populated and denied states. Use TableCard with a
|
|
|
20
20
|
|
|
21
21
|
Use shared primitives and tokens. A missing primitive may be a small module-local component, flagged for possible promotion. Do not restyle ui-\* classes. Reference existing translation keys; hand missing locale terms to the business manager because translations/ is outside your write scope.
|
|
22
22
|
|
|
23
|
-
Inspect the rendered result for overflow, alignment and duplicate labels. Hand the skeleton to frontend for data wiring
|
|
23
|
+
Inspect the rendered result for overflow, alignment and duplicate labels. Hand the skeleton to frontend for data wiring. Ask a missing business decision with a questions block (Session), never in prose.
|
|
@@ -24,11 +24,11 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
24
24
|
|
|
25
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. 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.
|
|
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
|
|
|
@@ -70,7 +70,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
70
70
|
|
|
71
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.
|
|
72
72
|
|
|
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).
|
|
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.
|
|
74
74
|
|
|
75
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.
|
|
76
76
|
|
|
@@ -78,7 +78,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
78
78
|
|
|
79
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.
|
|
80
80
|
|
|
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. 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.
|
|
82
82
|
|
|
83
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.
|
|
84
84
|
|
|
@@ -50,7 +50,8 @@ profiles:
|
|
|
50
50
|
# Who takes the first sandbox turn (planning.ts classifyByRules) and how the
|
|
51
51
|
# next role is chosen (planHandoff): the HANDOFF line when it names a
|
|
52
52
|
# registered role, otherwise routeRole by module state, and a failed gate
|
|
53
|
-
#
|
|
53
|
+
# goes to the role whose write paths cover the file its fix goes in
|
|
54
|
+
# (gate-repair.ts).
|
|
54
55
|
routing:
|
|
55
56
|
new-module: business-manager
|
|
56
57
|
edit-module: business-manager
|
|
@@ -33,7 +33,7 @@ sandbox:
|
|
|
33
33
|
messageLength: 1 to 20000 characters (packages/sandbox/src/server/turns.ts)
|
|
34
34
|
briefLength: at least 8 characters (planning.ts assertBrief)
|
|
35
35
|
gateTimeout: 5 minutes per gate, output capped at 12000 characters (gates.ts)
|
|
36
|
-
gateFailure: the same role
|
|
36
|
+
gateFailure: a role whose write paths cover the failing file repairs, a repair that changed nothing is not repeated by the same role, and a gate that failed runs after every turn until it passes; at most maxRepairLoops consecutive repair turns per chain, then the chain returns to the operator
|
|
37
37
|
byokReads: 400 listed files, 128 KB per read (packages/coding-agent/src/drivers/byok.ts)
|
|
38
38
|
review:
|
|
39
39
|
chainedTurns: stop and read the transcript after four automatic handoffs on one brief
|
|
@@ -17,5 +17,5 @@ CREATE INDEX IF NOT EXISTS catalog_items_tenant_sku_idx
|
|
|
17
17
|
ALTER TABLE catalog_items ENABLE ROW LEVEL SECURITY;
|
|
18
18
|
ALTER TABLE catalog_items FORCE ROW LEVEL SECURITY;
|
|
19
19
|
CREATE POLICY catalog_items_tenant_policy ON catalog_items
|
|
20
|
-
USING (tenant_id = current_setting('
|
|
21
|
-
WITH CHECK (tenant_id = current_setting('
|
|
20
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
21
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
@@ -16,5 +16,5 @@ CREATE UNIQUE INDEX IF NOT EXISTS catalog_items_history_tenant_record_version_id
|
|
|
16
16
|
ALTER TABLE catalog_items_history ENABLE ROW LEVEL SECURITY;
|
|
17
17
|
ALTER TABLE catalog_items_history FORCE ROW LEVEL SECURITY;
|
|
18
18
|
CREATE POLICY catalog_items_history_tenant_policy ON catalog_items_history
|
|
19
|
-
USING (tenant_id = current_setting('
|
|
20
|
-
WITH CHECK (tenant_id = current_setting('
|
|
19
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
20
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql
CHANGED
|
@@ -32,5 +32,5 @@ ON CONFLICT DO NOTHING;
|
|
|
32
32
|
ALTER TABLE catalog_items_history_v2 ENABLE ROW LEVEL SECURITY;
|
|
33
33
|
ALTER TABLE catalog_items_history_v2 FORCE ROW LEVEL SECURITY;
|
|
34
34
|
CREATE POLICY catalog_items_history_v2_tenant_policy ON catalog_items_history_v2
|
|
35
|
-
USING (tenant_id = current_setting('
|
|
36
|
-
WITH CHECK (tenant_id = current_setting('
|
|
35
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
36
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql
CHANGED
|
@@ -15,5 +15,5 @@ CREATE INDEX IF NOT EXISTS catalog_idempotency_ledger_tenant_operation_idx
|
|
|
15
15
|
ALTER TABLE catalog_idempotency_ledger ENABLE ROW LEVEL SECURITY;
|
|
16
16
|
ALTER TABLE catalog_idempotency_ledger FORCE ROW LEVEL SECURITY;
|
|
17
17
|
CREATE POLICY catalog_idempotency_ledger_tenant_policy ON catalog_idempotency_ledger
|
|
18
|
-
USING (tenant_id = current_setting('
|
|
19
|
-
WITH CHECK (tenant_id = current_setting('
|
|
18
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
19
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
"dependencies": [
|
|
14
14
|
{
|
|
15
15
|
"id": "system.core",
|
|
16
|
-
"range": "^0.
|
|
16
|
+
"range": "^0.9.0"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"id": "auth.core",
|
|
20
|
-
"range": "^0.
|
|
20
|
+
"range": "^0.14.0"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"id": "exports.core",
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"tenancy": "required",
|
|
34
34
|
"locales": ["en", "pl"],
|
|
35
35
|
"stability": "experimental",
|
|
36
|
-
"platformApi": "^0.
|
|
36
|
+
"platformApi": "^0.2.0"
|
|
37
37
|
}
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/Flowdular/
|
|
34
|
-
"directory": "
|
|
33
|
+
"url": "git+https://github.com/Flowdular/flowdular.git",
|
|
34
|
+
"directory": ".ai/references/catalog"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"src",
|
|
@@ -12,9 +12,9 @@ capabilities:
|
|
|
12
12
|
- translations
|
|
13
13
|
dependencies:
|
|
14
14
|
- id: system.core
|
|
15
|
-
range: ^0.
|
|
15
|
+
range: ^0.9.0
|
|
16
16
|
- id: auth.core
|
|
17
|
-
range: ^0.
|
|
17
|
+
range: ^0.14.0
|
|
18
18
|
- id: exports.core
|
|
19
19
|
range: ^0.2.0
|
|
20
20
|
requires:
|
|
@@ -26,7 +26,7 @@ locales:
|
|
|
26
26
|
- pl
|
|
27
27
|
invariants:
|
|
28
28
|
- Persistence runs on the shared asynchronous database contract. The module receives a leased PostgreSQL handle from platform composition, never a driver or a connection string, and declares explicit PostgreSQL SQL for every statement and migration.
|
|
29
|
-
- PostgreSQL item, history, and idempotency ledger tables enable and force row-level security with USING and WITH CHECK policies bound to transaction-local
|
|
29
|
+
- PostgreSQL item, history, and idempotency ledger tables enable and force row-level security with USING and WITH CHECK policies bound to transaction-local flowdular.tenant_id, and the runtime role holds neither SUPERUSER nor BYPASSRLS. Migrations use a separate lease.
|
|
30
30
|
- Every catalog item is owned by exactly one tenant and every query uses the trusted tenant identifier.
|
|
31
31
|
- SKU is unique inside a tenant and is never used as tenancy authority.
|
|
32
32
|
- Monetary values are stored as integer minor units with an explicit ISO currency code.
|
|
@@ -47,7 +47,7 @@ export function CatalogItemForm(props: CatalogItemFormProps) @{
|
|
|
47
47
|
name="name"
|
|
48
48
|
value={name}
|
|
49
49
|
onInput={(event) => setName(event.currentTarget.value)}
|
|
50
|
-
|
|
50
|
+
minLength={2}
|
|
51
51
|
maxLength={160}
|
|
52
52
|
autoFocus
|
|
53
53
|
required
|
|
@@ -113,7 +113,7 @@ export function CatalogItemForm(props: CatalogItemFormProps) @{
|
|
|
113
113
|
name="currency"
|
|
114
114
|
value={currency}
|
|
115
115
|
onInput={(event) => setCurrency(event.currentTarget.value)}
|
|
116
|
-
|
|
116
|
+
minLength={3}
|
|
117
117
|
maxLength={3}
|
|
118
118
|
required
|
|
119
119
|
/>
|
|
@@ -26,8 +26,8 @@ CREATE INDEX IF NOT EXISTS catalog_items_tenant_sku_idx
|
|
|
26
26
|
ALTER TABLE catalog_items ENABLE ROW LEVEL SECURITY;
|
|
27
27
|
ALTER TABLE catalog_items FORCE ROW LEVEL SECURITY;
|
|
28
28
|
CREATE POLICY catalog_items_tenant_policy ON catalog_items
|
|
29
|
-
USING (tenant_id = current_setting('
|
|
30
|
-
WITH CHECK (tenant_id = current_setting('
|
|
29
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
30
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
31
31
|
`;
|
|
32
32
|
|
|
33
33
|
export const CATALOG_MIGRATION_002_HISTORY = `CREATE TABLE IF NOT EXISTS catalog_items_history (
|
|
@@ -48,8 +48,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS catalog_items_history_tenant_record_version_id
|
|
|
48
48
|
ALTER TABLE catalog_items_history ENABLE ROW LEVEL SECURITY;
|
|
49
49
|
ALTER TABLE catalog_items_history FORCE ROW LEVEL SECURITY;
|
|
50
50
|
CREATE POLICY catalog_items_history_tenant_policy ON catalog_items_history
|
|
51
|
-
USING (tenant_id = current_setting('
|
|
52
|
-
WITH CHECK (tenant_id = current_setting('
|
|
51
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
52
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
53
53
|
`;
|
|
54
54
|
|
|
55
55
|
export const CATALOG_MIGRATION_003_HISTORY_SERVICE_ACTORS = `CREATE TABLE IF NOT EXISTS catalog_items_history_v2 (
|
|
@@ -86,8 +86,8 @@ ON CONFLICT DO NOTHING;
|
|
|
86
86
|
ALTER TABLE catalog_items_history_v2 ENABLE ROW LEVEL SECURITY;
|
|
87
87
|
ALTER TABLE catalog_items_history_v2 FORCE ROW LEVEL SECURITY;
|
|
88
88
|
CREATE POLICY catalog_items_history_v2_tenant_policy ON catalog_items_history_v2
|
|
89
|
-
USING (tenant_id = current_setting('
|
|
90
|
-
WITH CHECK (tenant_id = current_setting('
|
|
89
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
90
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
91
91
|
`;
|
|
92
92
|
|
|
93
93
|
export const CATALOG_MIGRATION_004_IDEMPOTENCY_LEDGER = `CREATE TABLE IF NOT EXISTS catalog_idempotency_ledger (
|
|
@@ -107,8 +107,8 @@ CREATE INDEX IF NOT EXISTS catalog_idempotency_ledger_tenant_operation_idx
|
|
|
107
107
|
ALTER TABLE catalog_idempotency_ledger ENABLE ROW LEVEL SECURITY;
|
|
108
108
|
ALTER TABLE catalog_idempotency_ledger FORCE ROW LEVEL SECURITY;
|
|
109
109
|
CREATE POLICY catalog_idempotency_ledger_tenant_policy ON catalog_idempotency_ledger
|
|
110
|
-
USING (tenant_id = current_setting('
|
|
111
|
-
WITH CHECK (tenant_id = current_setting('
|
|
110
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
111
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
112
112
|
`;
|
|
113
113
|
|
|
114
114
|
export const CATALOG_MIGRATION_005_LIST_INDEXES = `ALTER TABLE catalog_items ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0;
|
|
@@ -90,7 +90,7 @@ describe('catalog migrations', () => {
|
|
|
90
90
|
if (!sql.includes('CREATE TABLE')) continue;
|
|
91
91
|
expect(sql).toContain('ENABLE ROW LEVEL SECURITY');
|
|
92
92
|
expect(sql).toContain('FORCE ROW LEVEL SECURITY');
|
|
93
|
-
expect(sql).toContain("current_setting('
|
|
93
|
+
expect(sql).toContain("current_setting('flowdular.tenant_id', true)");
|
|
94
94
|
expect(sql).toContain('WITH CHECK');
|
|
95
95
|
}
|
|
96
96
|
});
|
|
@@ -20,7 +20,7 @@ Two ways to land the same module: the sandbox (a brief, specialist turns, gates
|
|
|
20
20
|
|
|
21
21
|
With an approved `schemaVersion: 2` spec, the specification is the requirement document and you do not go looking for one. Read the spec, the files on this skill's touch list, and `.ai/references/catalog` for shape. Do not scan `modules/` or `packages/`; `.ai/platform-capabilities.md` answers what the platform provides, and the reference module answers what the code looks like.
|
|
22
22
|
|
|
23
|
-
Anything the spec does not say is a spec defect, not a decision you make. A missing field, an unstated conflict behaviour, an undefined state transition, a screen without columns: report it back. In the sandbox that is `
|
|
23
|
+
Anything the spec does not say is a spec defect, not a decision you make. A missing field, an unstated conflict behaviour, an undefined state transition, a screen without columns: report it back. In the sandbox that is a `questions` block the operator answers, and the business manager records an answer that changes the spec for a new approval before you continue; on a host it is a question to the user. Never fill the gap with a plausible guess, and never implement anything listed in `outOfScope[]`.
|
|
24
24
|
|
|
25
25
|
Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A scenario with no test is unfinished work, and the scenario id belongs in the test name so the mapping is readable.
|
|
26
26
|
|
|
@@ -17,7 +17,7 @@ when: A brief names an existing module, or a sandbox session is labelled edit-mo
|
|
|
17
17
|
|
|
18
18
|
With an approved `schemaVersion: 2` spec delta, the specification is the requirement document. Read the spec, the module itself, the touch list for the change class below, and `.ai/references/catalog` for shape. Do not scan `modules/` or `packages/`: `.ai/platform-capabilities.md` answers what the platform provides.
|
|
19
19
|
|
|
20
|
-
Anything the delta does not say is a spec defect, not your decision. Report it back (`
|
|
20
|
+
Anything the delta does not say is a spec defect, not your decision. Report it back (a `questions` block in the sandbox, which the business manager turns into a spec delta for a new approval when the answer changes the spec; a question to the user on a host) instead of guessing, and never implement an item the spec parks in `outOfScope[]`. Every new or changed `acceptanceScenarios[]` entry maps to at least one test, with the scenario id in the test name.
|
|
21
21
|
|
|
22
22
|
The spec-element to file mapping is the table in `module-new`; the change classes below are the same mapping arranged by what you are changing.
|
|
23
23
|
|
|
@@ -99,6 +99,6 @@ Complexity to state in the review: for each new data structure and loop on a req
|
|
|
99
99
|
## Pitfalls
|
|
100
100
|
|
|
101
101
|
- `LIKE` or `=` against `lower(column)` cannot use a plain `(tenant_id, column)` index; store a normalized column (`sku_normalized`) as `.ai/references/catalog` does, or add an expression index on `lower(column)`.
|
|
102
|
-
- `ORDER BY lower(name)`
|
|
102
|
+
- `ORDER BY lower(name)` cannot use a `(tenant_id, name, id)` index for the sort; acceptable at small sizes, name it once the table grows.
|
|
103
103
|
- A `Kpi` that shows `items.length` after loading the full list is O(rows) network per dashboard load.
|
|
104
104
|
- Never change behaviour in a performance commit; keep the functional tests green and add none that assert internal call counts.
|
|
@@ -51,8 +51,12 @@ is a new spec-authoring step and needs approval after that edit.
|
|
|
51
51
|
## 3. Sandbox path
|
|
52
52
|
|
|
53
53
|
In the sandbox, use the operator approval action for the selected session module.
|
|
54
|
-
The live route is POST /sandbox/api/sessions/:id/approve
|
|
55
|
-
approveSpecification in
|
|
54
|
+
The live route is POST /sandbox/api/sessions/:id/approve with
|
|
55
|
+
{ "module", "specHash" }, exposed by approveSpecification in
|
|
56
|
+
packages/sandbox/src/client/api.ts. specHash is the SHA-256 the review card
|
|
57
|
+
shows for the text it renders. The route refuses with 409 SPEC_CHANGED when the
|
|
58
|
+
current text has another hash, and with 409 QUESTIONS_PENDING while the module
|
|
59
|
+
has unanswered questions; it records nothing then.
|
|
56
60
|
|
|
57
61
|
The route changes the status presentation and records the SHA-256 hash of the
|
|
58
62
|
exact approved text in the session. Do not patch the session workspace file to
|
|
@@ -74,7 +74,7 @@ In the sandbox, end the reply with exactly one fenced block tagged `questions`,
|
|
|
74
74
|
```
|
|
75
75
|
````
|
|
76
76
|
|
|
77
|
-
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.
|
|
77
|
+
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.
|
|
78
78
|
|
|
79
79
|
When the answers come back, copy each one into `decisions[]` with `decidedBy: user` and the answer text, and update whatever the answer changed.
|
|
80
80
|
|
|
@@ -92,7 +92,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
|
|
|
92
92
|
|
|
93
93
|
`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:
|
|
94
94
|
|
|
95
|
-
- `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`.
|
|
95
|
+
- `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`.
|
|
96
96
|
- `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
|
|
97
97
|
- `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.
|
|
98
98
|
- `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
|
|
@@ -14,7 +14,7 @@ Two ways to land the same module: the sandbox (a brief, specialist turns, gates
|
|
|
14
14
|
|
|
15
15
|
With an approved `schemaVersion: 2` spec, the specification is the requirement document and you do not go looking for one. Read the spec, the files on this skill's touch list, and `.ai/references/catalog` for shape. Do not scan `modules/` or `packages/`; `.ai/platform-capabilities.md` answers what the platform provides, and the reference module answers what the code looks like.
|
|
16
16
|
|
|
17
|
-
Anything the spec does not say is a spec defect, not a decision you make. A missing field, an unstated conflict behaviour, an undefined state transition, a screen without columns: report it back. In the sandbox that is `
|
|
17
|
+
Anything the spec does not say is a spec defect, not a decision you make. A missing field, an unstated conflict behaviour, an undefined state transition, a screen without columns: report it back. In the sandbox that is a `questions` block the operator answers, and the business manager records an answer that changes the spec for a new approval before you continue; on a host it is a question to the user. Never fill the gap with a plausible guess, and never implement anything listed in `outOfScope[]`.
|
|
18
18
|
|
|
19
19
|
Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A scenario with no test is unfinished work, and the scenario id belongs in the test name so the mapping is readable.
|
|
20
20
|
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
|
|
11
11
|
With an approved `schemaVersion: 2` spec delta, the specification is the requirement document. Read the spec, the module itself, the touch list for the change class below, and `.ai/references/catalog` for shape. Do not scan `modules/` or `packages/`: `.ai/platform-capabilities.md` answers what the platform provides.
|
|
12
12
|
|
|
13
|
-
Anything the delta does not say is a spec defect, not your decision. Report it back (`
|
|
13
|
+
Anything the delta does not say is a spec defect, not your decision. Report it back (a `questions` block in the sandbox, which the business manager turns into a spec delta for a new approval when the answer changes the spec; a question to the user on a host) instead of guessing, and never implement an item the spec parks in `outOfScope[]`. Every new or changed `acceptanceScenarios[]` entry maps to at least one test, with the scenario id in the test name.
|
|
14
14
|
|
|
15
15
|
The spec-element to file mapping is the table in `module-new`; the change classes below are the same mapping arranged by what you are changing.
|
|
16
16
|
|
|
@@ -93,6 +93,6 @@ Complexity to state in the review: for each new data structure and loop on a req
|
|
|
93
93
|
## Pitfalls
|
|
94
94
|
|
|
95
95
|
- `LIKE` or `=` against `lower(column)` cannot use a plain `(tenant_id, column)` index; store a normalized column (`sku_normalized`) as `.ai/references/catalog` does, or add an expression index on `lower(column)`.
|
|
96
|
-
- `ORDER BY lower(name)`
|
|
96
|
+
- `ORDER BY lower(name)` cannot use a `(tenant_id, name, id)` index for the sort; acceptable at small sizes, name it once the table grows.
|
|
97
97
|
- A `Kpi` that shows `items.length` after loading the full list is O(rows) network per dashboard load.
|
|
98
98
|
- Never change behaviour in a performance commit; keep the functional tests green and add none that assert internal call counts.
|
|
@@ -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
|
|
49
|
-
approveSpecification in
|
|
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.
|