create-flowdular 0.4.3 → 0.6.0
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/README.md +16 -10
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.agents/skills/bug-hunt/SKILL.md +1 -1
- package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
- package/agent-template/.agents/skills/spec-interview/SKILL.md +20 -20
- package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.agents/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.ai/README.md +5 -3
- package/agent-template/.ai/agents/README.md +1 -1
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -0
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -0
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -0
- package/agent-template/.ai/blueprints/add-migration/README.md +1 -1
- package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
- package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
- package/agent-template/.ai/examples/bad/client-imports-server/README.md +1 -1
- package/agent-template/.ai/examples/bad/missing-acl/README.md +1 -1
- package/agent-template/.ai/examples/bad/tenant-from-body/README.md +1 -1
- package/agent-template/.ai/guides/application-development.md +7 -5
- package/agent-template/.ai/platform-capabilities.md +9 -5
- package/agent-template/.ai/policies/capabilities.yaml +28 -12
- package/agent-template/.ai/policies/task-budgets.yaml +1 -1
- package/agent-template/.ai/rules/flowdular.md +3 -2
- package/agent-template/.ai/skills/README.md +1 -1
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.ai/skills/bug-hunt/SKILL.md +1 -1
- package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
- package/agent-template/.ai/skills/spec-interview/SKILL.md +20 -20
- package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.ai/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
- package/agent-template/.ai/subagents/module-executor.md +25 -0
- package/agent-template/.ai/subagents/reviewer.md +23 -0
- package/agent-template/.ai/subagents/spec-author.md +23 -0
- package/agent-template/.claude/agents/module-executor.md +22 -0
- package/agent-template/.claude/agents/reviewer.md +24 -0
- package/agent-template/.claude/agents/spec-author.md +20 -0
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +2 -2
- package/agent-template/.claude/skills/bug-hunt/SKILL.md +1 -1
- package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
- package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
- package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
- package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
- package/agent-template/.claude/skills/spec-interview/SKILL.md +20 -20
- package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
- package/agent-template/.claude/skills/ux-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
- package/agent-template/.codex/agents/module-executor.toml +17 -0
- package/agent-template/.codex/agents/reviewer.toml +14 -0
- package/agent-template/.codex/agents/spec-author.toml +15 -0
- package/agent-template/AGENTS.md +3 -2
- package/agent-template/CLAUDE.md +3 -2
- package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli.md +24 -3
- package/agent-template/docs/configuration.md +59 -5
- package/agent-template/docs/database-adapters.md +20 -20
- package/agent-template/docs/design-system.md +3 -3
- package/agent-template/docs/getting-started.md +25 -32
- package/agent-template/docs/module-distribution.md +79 -86
- package/agent-template/docs/module-web-surfaces.md +9 -7
- package/agent-template/docs/modules.md +9 -1
- package/agent-template/docs/sandbox.md +117 -6
- package/agent-template/platform/scripts/build.mjs +7 -0
- package/agent-template/rulesync.jsonc +1 -1
- package/dist/bin.js +12 -6
- package/package.json +2 -2
- package/template/default/.env.example +10 -3
- package/template/default/.prettierignore +2 -0
- package/template/default/.vercelignore +8 -0
- package/template/default/README.md +26 -15
- package/template/default/_gitignore +3 -2
- package/template/default/infra/README.md +86 -65
- package/template/default/infra/docker/.env.example +66 -0
- package/template/default/infra/docker/Dockerfile +24 -10
- package/template/default/infra/docker/app-entrypoint.mjs +5 -0
- package/template/default/infra/docker/compose.yaml +105 -58
- package/template/default/infra/docker/database-urls.mjs +28 -0
- package/template/default/infra/docker/pitr.sh +177 -0
- package/template/default/infra/docker/postgres/10-roles.sh +16 -12
- package/template/default/infra/docker/start.mjs +402 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
- package/template/default/infra/vercel/README.md +262 -0
- package/template/default/infra/vercel/build.mjs +214 -0
- package/template/default/infra/vercel/handler.mjs +100 -0
- package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
- package/template/default/modules/example/module.json +1 -1
- package/template/default/modules/example/package.json +3 -3
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/migration.ts +2 -2
- package/template/default/modules/example/tests/module.test.ts +1 -1
- package/template/default/package.json +3 -2
- package/template/default/platform/index.html +7 -19
- package/template/default/platform/octane.config.ts +252 -156
- package/template/default/platform/package.json +5 -5
- package/template/default/platform/public/favicon.svg +1 -1
- package/template/default/platform/scripts/build.mjs +56 -0
- package/template/default/platform/scripts/dev.mjs +38 -0
- package/template/default/platform/src/App.tsrx +25 -1
- package/template/default/platform/src/generated/modules.server.ts +3 -0
- package/template/default/platform/src/server/database.ts +24 -0
- package/template/default/platform/src/server/runtime-role.ts +33 -0
- package/template/default/platform/src/server/setup/access.ts +160 -0
- package/template/default/platform/src/server/setup/adapters.ts +554 -0
- package/template/default/platform/src/server/setup/environment.ts +154 -0
- package/template/default/platform/src/server/setup/gate.ts +84 -0
- package/template/default/platform/src/server/setup/index.ts +181 -0
- package/template/default/platform/src/server/setup/modules.ts +123 -0
- package/template/default/platform/src/server/setup/page.ts +497 -0
- package/template/default/platform/src/server/setup/routes.ts +787 -0
- package/template/default/platform/src/server/setup/sanitize.ts +111 -0
- package/template/default/platform/src/server/setup/seed.ts +145 -0
- package/template/default/platform/src/server/setup/token.ts +79 -0
- package/template/default/platform/src/server/worker-tick.ts +193 -0
- package/template/default/platform/src/server/workspace-root.ts +16 -0
- package/template/default/render.yaml +70 -0
- package/template/default/vercel.json +5 -0
|
@@ -84,11 +84,13 @@ spec approval or production access.
|
|
|
84
84
|
|
|
85
85
|
## Maintain the agent guidance
|
|
86
86
|
|
|
87
|
-
Edit `.ai/rules` and `.ai/
|
|
88
|
-
`pnpm rules:check`. Codex reads `AGENTS.md
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
Edit `.ai/rules`, `.ai/skills` and `.ai/subagents`, then run `pnpm rules:generate`
|
|
88
|
+
and `pnpm rules:check`. Codex reads `AGENTS.md`, `.agents/skills` and
|
|
89
|
+
`.codex/agents`; Claude Code reads `CLAUDE.md`, `.claude/skills` and
|
|
90
|
+
`.claude/agents`. Keep generated copies synchronized. `.ai/agents` contains
|
|
91
|
+
reusable role instructions, and `.ai/subagents` exposes the root roles as
|
|
92
|
+
delegable subagents; `.ai/blueprints` and `.ai/policies` are project-local inputs
|
|
93
|
+
referenced by `flowdular.json`.
|
|
92
94
|
|
|
93
95
|
Split a skill when it contains independent procedures with different owning
|
|
94
96
|
files, write scopes or verification commands. Keep shared requirements in the
|
|
@@ -20,14 +20,16 @@ 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 `
|
|
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
|
|
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 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.
|
|
32
|
+
|
|
31
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.
|
|
32
34
|
|
|
33
35
|
**Navigation groups.** A navigation contribution picks exactly one group (`NavigationGroup`, `packages/client/src/contributions.ts`): An Administration item also names a section (`NavigationSection`: `people`, `identity`, `compliance`, `integrations`, `platform`), and the panel renders that group as five short labelled lists in that order with unsectioned items last (`navigationSections`, `packages/client/src/shell/navigation.ts`). In the sidebar a sectioned group shows one entry per section (`NAVIGATION_SECTION_ICONS`), and while a sectioned view is open a context rail beside the sidebar lists that section's screens and the workspace makes room for it (`ContextRail`, `sectionOfView`, `sectionItems`); on a phone the same screens nest under the section entry in the drawer instead.
|
|
@@ -52,7 +54,9 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
52
54
|
|
|
53
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.
|
|
54
56
|
|
|
55
|
-
**Workflows.** `workflows.core` owns typed DAGs with
|
|
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.
|
|
56
60
|
|
|
57
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`.
|
|
58
62
|
|
|
@@ -68,7 +72,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
68
72
|
|
|
69
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). 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.
|
|
70
74
|
|
|
71
|
-
**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.
|
|
72
76
|
|
|
73
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.
|
|
74
78
|
|
|
@@ -9,23 +9,38 @@ default: deny
|
|
|
9
9
|
|
|
10
10
|
# packages/cli/src/capabilities.ts
|
|
11
11
|
core:
|
|
12
|
+
deploy.start.local:
|
|
13
|
+
command: pnpm flowdular deploy start docker --apply
|
|
14
|
+
risk: process
|
|
15
|
+
supportsDryRun: true
|
|
16
|
+
effect: runs the existing Docker Compose launcher in the operator's terminal after checking its files and Docker Compose; the launcher creates local secrets on first start and opens setup
|
|
17
|
+
deploy.start.vercel:
|
|
18
|
+
command: pnpm flowdular deploy start vercel --apply
|
|
19
|
+
risk: process
|
|
20
|
+
supportsDryRun: true
|
|
21
|
+
effect: drives the signed-in Vercel CLI in the operator's terminal to link the project, provision Neon PostgreSQL and its runtime and background roles, upload the stable keys from a 0600 local backup through stdin, connect a private Blob store, deploy to Production and print a one-time setup token whose SHA-256 alone is stored in Vercel
|
|
22
|
+
module.source:
|
|
23
|
+
command: pnpm flowdular module source add <name> <location> [--apply]
|
|
24
|
+
risk: workspace-write
|
|
25
|
+
supportsDryRun: true
|
|
26
|
+
note: host-only source configuration; sandbox agents receive no network or Git access
|
|
27
|
+
module.plan:
|
|
28
|
+
command: pnpm flowdular module plan <id[@version]> --source <name> [--apply]
|
|
29
|
+
risk: workspace-write
|
|
30
|
+
supportsDryRun: true
|
|
31
|
+
note: saves exact source, release digests and impact without executing module code
|
|
32
|
+
module.apply:
|
|
33
|
+
command: pnpm flowdular module apply <plan-id> [--apply]
|
|
34
|
+
risk: workspace-write
|
|
35
|
+
supportsDryRun: true
|
|
36
|
+
note: host-only source installation; activation and database changes remain separate
|
|
12
37
|
module.search:
|
|
13
38
|
command: pnpm flowdular module search [query]
|
|
14
39
|
risk: read
|
|
15
|
-
note: host-only
|
|
40
|
+
note: host-only configured source lookup; does not grant sandbox network access
|
|
16
41
|
module.info:
|
|
17
42
|
command: pnpm flowdular module info <id>
|
|
18
43
|
risk: read
|
|
19
|
-
module.install:
|
|
20
|
-
command: pnpm flowdular module install <id[@version]> [--apply]
|
|
21
|
-
risk: workspace-write
|
|
22
|
-
supportsDryRun: true
|
|
23
|
-
note: host-only source installation; no scripts, activation, permissions or database changes
|
|
24
|
-
module.update:
|
|
25
|
-
command: pnpm flowdular module update <id[@version]> [--apply]
|
|
26
|
-
risk: workspace-write
|
|
27
|
-
supportsDryRun: true
|
|
28
|
-
note: rejects local edits, changed historical migrations and downgrades
|
|
29
44
|
module.recover:
|
|
30
45
|
command: pnpm flowdular module recover [--apply]
|
|
31
46
|
risk: workspace-write
|
|
@@ -71,7 +86,7 @@ core:
|
|
|
71
86
|
risk: process
|
|
72
87
|
localOnly: true
|
|
73
88
|
supportsDryRun: true
|
|
74
|
-
effect: applies or adopts the outstanding migrations of one module against the configured PostgreSQL (embedded PGlite outside production) and writes the
|
|
89
|
+
effect: applies or adopts the outstanding migrations of one module against the configured PostgreSQL (embedded PGlite outside production) and writes the _flowdular_migrations_v2 ledger; without --apply it reports the plan and writes nothing
|
|
75
90
|
database.backup:
|
|
76
91
|
command: pnpm flowdular database backup --output <dir> [--apply]
|
|
77
92
|
risk: process
|
|
@@ -109,6 +124,7 @@ commandsWithoutDescriptor:
|
|
|
109
124
|
- pnpm flowdular module enable <id> [--apply] (with --apply also runs auth.scopes.sync; result field scopes, failure code MODULE_SCOPES_SYNC_FAILED)
|
|
110
125
|
- pnpm flowdular module disable <id> [--apply]
|
|
111
126
|
- pnpm flowdular setup check
|
|
127
|
+
- pnpm flowdular deploy targets|plan <docker|kubernetes|render|vercel|cloudflare>
|
|
112
128
|
- pnpm flowdular setup quick (alias of auth.greenfield.reset)
|
|
113
129
|
|
|
114
130
|
# modules/*/src/cli/commands.json, loaded only for modules enabled in flowdular.json
|
|
@@ -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 repairs;
|
|
36
|
+
gateFailure: the same role repairs; 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
|
|
@@ -87,5 +87,6 @@ made in the Flowdular repository and released before this application uses it.
|
|
|
87
87
|
The skills and examples use @flowdular/sdk subpath imports. For pnpm --filter,
|
|
88
88
|
read the actual module package name from its package.json. Use pnpm verify and
|
|
89
89
|
pnpm build for this application. Root .ai files are editable project guidance;
|
|
90
|
-
run pnpm rules:generate after changing rules or
|
|
91
|
-
AGENTS.md, CLAUDE.md, .agents/skills
|
|
90
|
+
run pnpm rules:generate after changing rules, skills or subagents, then pnpm
|
|
91
|
+
rules:check. AGENTS.md, CLAUDE.md, .agents/skills, .claude/skills, .claude/agents
|
|
92
|
+
and .codex/agents are generated copies.
|
|
@@ -23,7 +23,7 @@ Flowdular is an agentic foundation framework: the platform is the foundation, an
|
|
|
23
23
|
| `business-agent-design` | Ship a module-owned business agent with an exact tool ceiling, tenant binding, revisions, and access tests. |
|
|
24
24
|
| `integration-adapter` | Add a source or sink adapter for a named service: connector, port, mapping, recorded fixture, consent, call log. |
|
|
25
25
|
| `variables` | Variable-aware fields and templates: the `{{ }}` contract, the scope mask, server-side resolution, adding a source. |
|
|
26
|
-
| `workflow-development` | Build, publish, invoke, simulate, and test typed durable workflows
|
|
26
|
+
| `workflow-development` | Build, publish, invoke, simulate, and test typed durable workflows, including module-owned custom node templates. |
|
|
27
27
|
| `release-eject-pr` | Sandbox eject sequence, repository gates, branch and PR conventions, post-merge scope grant. |
|
|
28
28
|
| `deploy-operate` | Container build, production env keys, migrations at rollout, health and readiness, backup and restore, rollback. |
|
|
29
29
|
|
|
@@ -24,7 +24,7 @@ also ship a ready business agent through `defineAgent()`, read
|
|
|
24
24
|
## 1. The contract in code
|
|
25
25
|
|
|
26
26
|
- 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`.
|
|
27
|
-
- Ordering is a non-issue: `agents.core` (`modules/agents/src/platform.ts`) passes `tools: () => context.agentTools.list()` into `createAgentRuntime`, and the harness is built
|
|
27
|
+
- 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.
|
|
28
28
|
- 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.
|
|
29
29
|
- 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.
|
|
30
30
|
- 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.
|
|
@@ -63,7 +63,7 @@ Recipe in `modules/auth/tests/endpoints.test.ts`: build the runtime with a `Data
|
|
|
63
63
|
|
|
64
64
|
- 401 without a cookie or token.
|
|
65
65
|
- 403 with a principal that lacks the permission.
|
|
66
|
-
- Cross-tenant read returns an empty list (service level, on the suite's test provider under the non-bypass `
|
|
66
|
+
- Cross-tenant read returns an empty list (service level, on the suite's test provider under the non-bypass `flowdular_runtime` role).
|
|
67
67
|
- Mutation without `x-csrf-token` returns 403 `CSRF_REJECTED`; without `origin` returns 403.
|
|
68
68
|
- Each validation bound returns 400 with its code.
|
|
69
69
|
|
|
@@ -80,7 +80,7 @@ Report each finding as: severity (`blocker`, `should-fix`, `taste`), claim, `fil
|
|
|
80
80
|
```text
|
|
81
81
|
blocker Tenant id read from body modules/inventory/src/api/endpoints.ts:41
|
|
82
82
|
A member of tenant A posts { tenantId: "B" } and creates a location in B.
|
|
83
|
-
AGENTS.md
|
|
83
|
+
AGENTS.md 4. Fix: principalFromContext(octane)!.tenantId; drop the field.
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
## 8. Known platform gaps to keep in mind (not module defects)
|
|
@@ -35,7 +35,7 @@ when: A report, failing gate, wrong status code, blank screen, or unexpected 4xx
|
|
|
35
35
|
| View falls back to the dashboard | `ApplicationShell.tsrx` renders `overview` for a view id that no visible navigation or account menu entry reaches |
|
|
36
36
|
| Icon renders as a grid | `glyph` or `Icon name` is not an `ICON_PATHS` key (`packages/ui/src/icons/Icon.tsrx` falls back to `modules`) |
|
|
37
37
|
| Stale data after a change | each component owns a store instance (`useMemo(() => createXClientState(), [])`); check the `store.act` that should have written it. `store.commits(cb)` and `store.stats()` from `segment-state` show what was committed |
|
|
38
|
-
| Schema error on start | `
|
|
38
|
+
| Schema error on start | `runDatabaseMigrations`: `CHECKSUM_MISMATCH` means applied SQL bytes changed; `PARTIAL_MIGRATION` means only part of a pending migration exists. Never delete or bypass the database to hide either condition; restore the shipped bytes or diagnose the partial schema |
|
|
39
39
|
|
|
40
40
|
## 2b. Reproduction snippets
|
|
41
41
|
|
|
@@ -94,12 +94,12 @@ PostgreSQL tenant-owned tables also use database-enforced isolation:
|
|
|
94
94
|
|
|
95
95
|
- enable and force row-level security on the table;
|
|
96
96
|
- define a policy whose `USING` and `WITH CHECK` clauses compare `tenant_id`
|
|
97
|
-
with `current_setting('
|
|
97
|
+
with `current_setting('flowdular.tenant_id', true)`;
|
|
98
98
|
- run application traffic under a role that is neither a superuser nor granted
|
|
99
99
|
`BYPASSRLS`;
|
|
100
100
|
- use a separate migration role for DDL or policy ownership when required.
|
|
101
101
|
|
|
102
|
-
The adapter sets `
|
|
102
|
+
The adapter sets `flowdular.tenant_id` with parameterized `set_config(..., true)`
|
|
103
103
|
after `BEGIN` on the pinned connection. Never use an unpinned root query.
|
|
104
104
|
Explicit tenant predicates remain required as defense in depth.
|
|
105
105
|
|
|
@@ -139,7 +139,7 @@ transactions without `tenantId`, fail with `TENANT_CONTEXT_REQUIRED`.
|
|
|
139
139
|
|
|
140
140
|
Use `DatabaseMigration` and `runDatabaseMigrations` from `@flowdular/sdk/database`.
|
|
141
141
|
Each migration has one immutable id and its PostgreSQL SQL. The ledger is
|
|
142
|
-
`
|
|
142
|
+
`_flowdular_migrations_v2`, keyed by module namespace and migration id; its
|
|
143
143
|
checksum covers that exact SQL.
|
|
144
144
|
|
|
145
145
|
`inspectExisting(database)` is the only pre-ledger adoption proof. Use
|
|
@@ -163,8 +163,8 @@ constant. A migration-only task uses `migration-authoring` in a separate phase.
|
|
|
163
163
|
## 7. Tests run on a real PostgreSQL
|
|
164
164
|
|
|
165
165
|
`createTestDatabaseProvider()` from `@flowdular/sdk/database-testing` gives a suite its
|
|
166
|
-
own PostgreSQL in process by default, with the same `
|
|
167
|
-
`
|
|
166
|
+
own PostgreSQL in process by default, with the same `flowdular_runtime` and
|
|
167
|
+
`flowdular_background` roles and the same forced row-level security a deployment
|
|
168
168
|
enforces. There is no server to start and no second dialect to keep green, so
|
|
169
169
|
the isolation assertions run on every turn rather than behind an environment
|
|
170
170
|
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 `
|
|
87
|
-
`
|
|
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
|
|
@@ -53,7 +53,7 @@ Set `FD_TRUST_PROXY` behind a load balancer. `FD_DATABASE_BACKGROUND_URL` gives
|
|
|
53
53
|
|
|
54
54
|
## 3. Migrations at rollout
|
|
55
55
|
|
|
56
|
-
Migrations are module-owned, numbered, immutable once applied, and verified by checksum against the `
|
|
56
|
+
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`):
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
59
|
pnpm flowdular migration status [--module <id>] # what the ledger holds
|
|
@@ -32,7 +32,7 @@ Migration SQL is checked in and immutable after release:
|
|
|
32
32
|
## 2. What the v2 runner guarantees
|
|
33
33
|
|
|
34
34
|
`runDatabaseMigrations(database, namespace, databaseMigrations)` uses the
|
|
35
|
-
namespaced `
|
|
35
|
+
namespaced `_flowdular_migrations_v2` ledger. A row records namespace, migration
|
|
36
36
|
id, dialect id, checksum, and applied time. The checksum covers the selected
|
|
37
37
|
dialect's exact SQL.
|
|
38
38
|
|
|
@@ -82,14 +82,14 @@ Every PostgreSQL tenant table includes:
|
|
|
82
82
|
ALTER TABLE inventory_locations ENABLE ROW LEVEL SECURITY;
|
|
83
83
|
ALTER TABLE inventory_locations FORCE ROW LEVEL SECURITY;
|
|
84
84
|
CREATE POLICY inventory_locations_tenant_policy ON inventory_locations
|
|
85
|
-
USING (tenant_id = current_setting('
|
|
86
|
-
WITH CHECK (tenant_id = current_setting('
|
|
85
|
+
USING (tenant_id = current_setting('flowdular.tenant_id', true))
|
|
86
|
+
WITH CHECK (tenant_id = current_setting('flowdular.tenant_id', true));
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
The runtime role is not a superuser and has no `BYPASSRLS`. DDL and policy
|
|
90
90
|
ownership use `purpose: 'migration'`. Runtime repository calls use
|
|
91
91
|
`database.transaction(operation, { tenantId, access })`; the adapter sets
|
|
92
|
-
transaction-local `
|
|
92
|
+
transaction-local `flowdular.tenant_id` on the pinned connection. Queries still
|
|
93
93
|
include `WHERE tenant_id = ...` as defense in depth.
|
|
94
94
|
|
|
95
95
|
## 6. Add one migration
|
|
@@ -148,7 +148,7 @@ export function createServerComposition(
|
|
|
148
148
|
|
|
149
149
|
`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.
|
|
150
150
|
|
|
151
|
-
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('
|
|
151
|
+
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`.
|
|
152
152
|
|
|
153
153
|
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.')`.
|
|
154
154
|
|
|
@@ -29,26 +29,26 @@ For an edit, also read the module's current `spec/module.yaml` and write the sma
|
|
|
29
29
|
|
|
30
30
|
One pass, in this order. For each row, write the default from the card into the spec and record it as a `decisions[]` entry with `decidedBy: default`. Ask only where the answer is a business fact that no default can supply.
|
|
31
31
|
|
|
32
|
-
| Decision | Default to propose
|
|
33
|
-
| ---------------------- |
|
|
34
|
-
| Actors | Owner manages, member reads
|
|
35
|
-
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name
|
|
36
|
-
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none`
|
|
37
|
-
| States and transitions | `active` and `archived`, every transition behind the manage permission
|
|
38
|
-
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope
|
|
39
|
-
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing
|
|
40
|
-
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400
|
|
41
|
-
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency
|
|
42
|
-
| Screens | One `list` screen with the entity's identifying columns
|
|
43
|
-
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it
|
|
44
|
-
| Settings | None. A number the business may change later is `scope: tenant` with a stated default
|
|
45
|
-
| Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant
|
|
46
|
-
| Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write`
|
|
47
|
-
| Outside sources | None. A named public source is `research`, with the entity its findings attach to
|
|
48
|
-
| Other systems | None. A named system is one `source` adapter per record kind, run on demand
|
|
49
|
-
| Documents | None. A named document is one `templates[]` entry on the record it describes
|
|
50
|
-
| Reports |
|
|
51
|
-
| Out of scope | Every item from the card's gap list the request touched, each with its business decision
|
|
32
|
+
| Decision | Default to propose | Lands in |
|
|
33
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
34
|
+
| Actors | Owner manages, member reads | `permissions`, `invariants` |
|
|
35
|
+
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
|
|
36
|
+
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
|
|
37
|
+
| States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
|
|
38
|
+
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
|
|
39
|
+
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
|
|
40
|
+
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
|
|
41
|
+
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
|
|
42
|
+
| Screens | One `list` screen with the entity's identifying columns | `screens[]` |
|
|
43
|
+
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
|
|
44
|
+
| Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
|
|
45
|
+
| Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant | `settings[]` |
|
|
46
|
+
| Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
|
|
47
|
+
| Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
|
|
48
|
+
| Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
|
|
49
|
+
| Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
|
|
50
|
+
| Reports | A named report is one provider on `reports.v1` returning tiles and series; a named list export is one `defineListExport`; named records are findable through `search.providers.v1`. None of the three has a specification key, so each needs a decision naming the screen or the `actions[]` entry that registers it | `actions[]`, `invariants` |
|
|
51
|
+
| Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
|
|
52
52
|
|
|
53
53
|
A default you propose is still a decision: it goes into `decisions[]` so the operator can see and overturn it, and so the next agent never re-derives it.
|
|
54
54
|
|
|
@@ -23,7 +23,7 @@ when: A module has few or tautological tests, a bug escaped the suite, or a revi
|
|
|
23
23
|
|
|
24
24
|
## 2. Repositories on an embedded PostgreSQL
|
|
25
25
|
|
|
26
|
-
`createPgliteTestProvider()` from `@flowdular/sdk/database-testing` runs a real PostgreSQL inside the test process, with the same `
|
|
26
|
+
`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.
|
|
27
27
|
|
|
28
28
|
## 3. Route recipe (from `modules/auth/tests/endpoints.test.ts`)
|
|
29
29
|
|
|
@@ -63,7 +63,7 @@ The auth middleware must have set the principal for `endpointIdentityFromContext
|
|
|
63
63
|
- 403 on a mutation without `x-csrf-token` (`CSRF_REJECTED`) and without `origin` (`ORIGIN_REQUIRED`).
|
|
64
64
|
- 400 with the stable code for each validation bound (`INVALID_INPUT`, module codes such as `INVALID_ITEM_KIND`).
|
|
65
65
|
- 409 for the tenant-scoped uniqueness rule, and success for the same key in another tenant.
|
|
66
|
-
- Tenant isolation: rows created for `tenant-a` are invisible to `list('tenant-b')`. The provider hands the suite the non-bypass `
|
|
66
|
+
- 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`.
|
|
67
67
|
- 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.
|
|
68
68
|
|
|
69
69
|
Assert at the observation boundary: status code, `error.code`, returned record fields. Do not assert internal helper names, call order, or SQL text.
|
|
@@ -86,7 +86,7 @@ Read-only master-detail (runs, playground) keeps `ui-two-col` (+ `--wide-aside`)
|
|
|
86
86
|
- `Alert`: `tone` danger (default), warning, info.
|
|
87
87
|
- `Avatar`: `name`, `square` (organizations), `large`.
|
|
88
88
|
- `Icon`: `name`, `size` (18 default, 16 in controls, 14 in `Button size="sm"`), `strokeWidth`.
|
|
89
|
-
- `BrandMark`: `size`, `
|
|
89
|
+
- `BrandMark`: `size`, `tone`; three bars with a copper accent bar, brand moments only.
|
|
90
90
|
|
|
91
91
|
Icon keys (`ICON_PATHS`, `packages/ui/src/icons/Icon.tsrx`): `dashboard`, `parties`, `catalog`, `user`, `users`, `shield`, `code`, `modules`, `file-text`, `play`, `bot`, `flask`, `activity`, `plug`, `search`, `chevron-down`, `chevron-left`, `chevron-right`, `chevrons-up-down`, `sort`, `calendar`, `plus`, `panel-left`, `check`, `filter`, `download`, `more`, `external`, `alert`, `x`, `sign-out`, `refresh`, `help`, `info`, `key`, `settings`, `braces`, `copy`. An unknown name renders `modules` silently, so check the list.
|
|
92
92
|
|