create-flowdular 0.3.2 → 0.4.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/agent-template/.agents/skills/agent-tool-design/SKILL.md +25 -0
- package/agent-template/.agents/skills/integration-adapter/SKILL.md +116 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +17 -2
- package/agent-template/.agents/skills/module-update/SKILL.md +14 -2
- package/agent-template/.agents/skills/release-eject-pr/SKILL.md +23 -26
- package/agent-template/.agents/skills/spec-interview/SKILL.md +16 -0
- package/agent-template/.agents/skills/translations-i18n/SKILL.md +2 -1
- package/agent-template/.agents/skills/ux-design/SKILL.md +4 -4
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +9 -0
- package/agent-template/.ai/blueprints/agentic-module/README.md +15 -0
- package/agent-template/.ai/blueprints/agentic-module/allowed-paths.yaml +14 -0
- package/agent-template/.ai/blueprints/agentic-module/blueprint.json +19 -0
- package/agent-template/.ai/blueprints/agentic-module/gates.yaml +24 -0
- package/agent-template/.ai/blueprints/agentic-module/input.schema.json +18 -0
- package/agent-template/.ai/blueprints/agentic-module/plan.schema.json +35 -0
- package/agent-template/.ai/blueprints/agentic-module/required-files.yaml +28 -0
- package/agent-template/.ai/blueprints/agentic-module/spec-requirements.yaml +33 -0
- package/agent-template/.ai/blueprints/agentic-module/steps.yaml +68 -0
- package/agent-template/.ai/platform-capabilities.md +14 -11
- package/agent-template/.ai/references/catalog/module.json +2 -2
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +2 -2
- package/agent-template/.ai/references/catalog.provenance.json +6 -6
- package/agent-template/.ai/rules/flowdular.md +2 -1
- package/agent-template/.ai/skills/README.md +1 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +25 -0
- package/agent-template/.ai/skills/integration-adapter/SKILL.md +121 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +17 -2
- package/agent-template/.ai/skills/module-update/SKILL.md +14 -2
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +23 -26
- package/agent-template/.ai/skills/spec-interview/SKILL.md +16 -0
- package/agent-template/.ai/skills/translations-i18n/SKILL.md +2 -1
- package/agent-template/.ai/skills/ux-design/SKILL.md +4 -4
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +25 -0
- package/agent-template/.claude/skills/integration-adapter/SKILL.md +116 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +17 -2
- package/agent-template/.claude/skills/module-update/SKILL.md +14 -2
- package/agent-template/.claude/skills/release-eject-pr/SKILL.md +23 -26
- package/agent-template/.claude/skills/spec-interview/SKILL.md +16 -0
- package/agent-template/.claude/skills/translations-i18n/SKILL.md +2 -1
- package/agent-template/.claude/skills/ux-design/SKILL.md +4 -4
- package/agent-template/AGENTS.md +2 -1
- package/agent-template/CLAUDE.md +2 -1
- package/agent-template/docs/agent-contract.md +1 -1
- package/agent-template/docs/cli.md +8 -3
- package/agent-template/docs/configuration.md +29 -0
- package/agent-template/docs/design-system.md +112 -13
- package/agent-template/docs/module-distribution.md +10 -3
- package/agent-template/docs/modules.md +38 -1
- package/agent-template/docs/operations.md +2 -0
- package/agent-template/docs/sandbox.md +76 -1
- package/package.json +1 -1
- package/template/default/flowdular.json +2 -0
- package/template/default/modules/example/package.json +1 -1
- package/template/default/modules/example/src/client/NotesView.tsrx +12 -16
- package/template/default/modules/example/tests/module.test.ts +3 -2
- package/template/default/modules/example/translations/pl.json +3 -1
- package/template/default/package.json +1 -1
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/src/generated/modules.client.ts +4 -0
- package/template/default/platform/src/generated/modules.server.ts +61 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
schemaVersion: 1
|
|
2
|
+
# In addition to the new-module file set; the scaffold writes the first three
|
|
3
|
+
# kinds from the spec sections.
|
|
4
|
+
conditional:
|
|
5
|
+
research:
|
|
6
|
+
whenSpecSection: research
|
|
7
|
+
required:
|
|
8
|
+
- src/research.ts
|
|
9
|
+
- research-fixtures.json
|
|
10
|
+
- the evidence attach in the service that stores a finding
|
|
11
|
+
- tests for a finding without evidence and for another tenant's evidence
|
|
12
|
+
adapters:
|
|
13
|
+
whenSpecSection: adapters
|
|
14
|
+
required:
|
|
15
|
+
- src/adapters/<name>.ts for every adapter
|
|
16
|
+
- the recorded fixture, adapters/<name>.recorded.json unless the entry names another path
|
|
17
|
+
- tests replaying the recorded fixture
|
|
18
|
+
templates:
|
|
19
|
+
whenSpecSection: templates
|
|
20
|
+
required:
|
|
21
|
+
- templates/<name>.md for every template body
|
|
22
|
+
- templates in package.json files
|
|
23
|
+
agent:
|
|
24
|
+
whenSpecSection: agentTools
|
|
25
|
+
required:
|
|
26
|
+
- src/agent/tools.ts with evidenceIds on every tool that stores a finding
|
|
27
|
+
- src/agent/agents.ts
|
|
28
|
+
- tests/agent-tools.test.ts
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
schemaVersion: 1
|
|
2
|
+
# Every key below exists in packages/contracts/schemas/module-spec.schema.json.
|
|
3
|
+
specSchemaVersion: 2
|
|
4
|
+
status:
|
|
5
|
+
allowed:
|
|
6
|
+
- approved
|
|
7
|
+
requiredKeys:
|
|
8
|
+
- entities
|
|
9
|
+
- actions
|
|
10
|
+
- agentTools
|
|
11
|
+
- acceptanceScenarios
|
|
12
|
+
- outOfScope
|
|
13
|
+
- decisions
|
|
14
|
+
sections:
|
|
15
|
+
research:
|
|
16
|
+
evidenceOwner: the case entity, the record findings attach to
|
|
17
|
+
adapter: recorded in a sandbox session; the adapter the owner picks after delivery is a decision
|
|
18
|
+
adapters:
|
|
19
|
+
port: a source writes through an import port of this module, <module id>.<key>
|
|
20
|
+
recorded: required in a sandbox session, whose spec-schema gate refuses a live adapter with SANDBOX_LIVE_ADAPTER_REFUSED
|
|
21
|
+
templates:
|
|
22
|
+
inputEntity: the case entity
|
|
23
|
+
render: the module registers the body through documents.templates.v1 and renders it for the case record
|
|
24
|
+
actions:
|
|
25
|
+
compute: one custom action per computed figure, idempotent, naming the rule version it applies
|
|
26
|
+
acceptanceScenarios:
|
|
27
|
+
mustInclude:
|
|
28
|
+
- a finding without evidence is refused before any write
|
|
29
|
+
- a finding citing evidence of another tenant is refused
|
|
30
|
+
- the computed figure is the same for the same inputs and names its rule version
|
|
31
|
+
- an agent run without research consent is refused with TOOL_NOT_CONSENTED
|
|
32
|
+
- nothing leaves the workspace before the approval is granted
|
|
33
|
+
- tenant isolation
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
schemaVersion: 1
|
|
2
|
+
steps:
|
|
3
|
+
- id: spec
|
|
4
|
+
role: business-manager
|
|
5
|
+
action: Interview the brief with spec-interview, including outside sources, other systems and documents; write research, adapters and templates with recorded fixtures in a sandbox session; stop for approval.
|
|
6
|
+
inputs: [brief]
|
|
7
|
+
outputs: [spec/module.yaml]
|
|
8
|
+
gates: [spec-schema]
|
|
9
|
+
- id: approve
|
|
10
|
+
role: operator
|
|
11
|
+
action: Approve the exact specification. No agent does this.
|
|
12
|
+
inputs: [spec/module.yaml]
|
|
13
|
+
outputs: [spec/module.yaml]
|
|
14
|
+
- id: scaffold
|
|
15
|
+
role: orchestrator
|
|
16
|
+
action: pnpm flowdular module new <id> --spec modules/<dir>/spec/module.yaml --apply, which also writes src/research.ts, research-fixtures.json, src/adapters/<name>.ts, adapters/<name>.recorded.json and templates/<name>.md.
|
|
17
|
+
inputs: [spec/module.yaml]
|
|
18
|
+
outputs:
|
|
19
|
+
[
|
|
20
|
+
src/research.ts,
|
|
21
|
+
research-fixtures.json,
|
|
22
|
+
src/adapters/*.ts,
|
|
23
|
+
adapters/*.recorded.json,
|
|
24
|
+
templates/*.md,
|
|
25
|
+
]
|
|
26
|
+
- id: server
|
|
27
|
+
role: backend-engineer
|
|
28
|
+
action: The case entity, the compute actions with their rule version, and the service that stores findings with evidence ids, per module-new.
|
|
29
|
+
inputs: [spec/module.yaml]
|
|
30
|
+
outputs:
|
|
31
|
+
[
|
|
32
|
+
src/services/*.ts,
|
|
33
|
+
src/api/endpoints.ts,
|
|
34
|
+
migrations/*.sql,
|
|
35
|
+
tests/*.test.ts,
|
|
36
|
+
]
|
|
37
|
+
gates: [module-schema, dependencies, typecheck, tests, format]
|
|
38
|
+
- id: adapters
|
|
39
|
+
role: backend-engineer
|
|
40
|
+
action: Every adapters[] entry per integration-adapter, on its recorded fixture.
|
|
41
|
+
inputs: [spec/module.yaml, src/adapters/*.ts]
|
|
42
|
+
outputs: [src/adapters/*.ts, adapters/*.recorded.json, tests/*.test.ts]
|
|
43
|
+
gates: [dependencies, typecheck, tests, format]
|
|
44
|
+
- id: agent-surface
|
|
45
|
+
role: agentic-engineer
|
|
46
|
+
action: Tools that store findings with evidence ids and call the compute actions, and the business agent that lists research.search and research.fetch, per agent-tool-design and business-agent-design.
|
|
47
|
+
inputs: [spec/module.yaml, src/api/endpoints.ts]
|
|
48
|
+
outputs:
|
|
49
|
+
[src/agent/tools.ts, src/agent/agents.ts, tests/agent-tools.test.ts]
|
|
50
|
+
gates: [spec-schema, module-schema, dependencies, typecheck, tests]
|
|
51
|
+
- id: workflow
|
|
52
|
+
role: agentic-engineer
|
|
53
|
+
action: The case workflow ending in a human-approval node before anything leaves the workspace, per workflow-development.
|
|
54
|
+
inputs: [src/agent/agents.ts]
|
|
55
|
+
outputs: [tests/*.test.ts]
|
|
56
|
+
gates: [typecheck, tests]
|
|
57
|
+
- id: client
|
|
58
|
+
role: frontend-engineer
|
|
59
|
+
action: The case record screen with its evidence list and the five states, per ux-design.
|
|
60
|
+
inputs: [src/api/endpoints.ts]
|
|
61
|
+
outputs: [src/client/*.tsrx, src/client/*.ts, translations/*.json]
|
|
62
|
+
gates: [dependencies, typecheck, tests, format]
|
|
63
|
+
- id: review
|
|
64
|
+
role: reviewer
|
|
65
|
+
action: auto-review against the spec, with the evidence, computation and approval scenarios checked first.
|
|
66
|
+
inputs: [diff, gate evidence]
|
|
67
|
+
outputs: [review report]
|
|
68
|
+
gates: [auto-review]
|
|
@@ -14,7 +14,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
14
14
|
|
|
15
15
|
**List pagination.** A list endpoint pages with `readPageQuery`, `encodeCursor`, `decodeCursor`, `pageResponse` and `keysetWhere` (`packages/server/src/pagination.ts`). `readPageQuery(url, { maxLimit, defaultLimit })` answers `{ limit, cursor }`, defaults to 50, refuses a limit outside 1 to the endpoint bound with 400 `INVALID_INPUT` and a malformed cursor with 400 `CURSOR_INVALID`; the platform ceiling is 200 rows. A cursor is the keyset of the last row of the page, HMAC signed with a 32 byte secret the module owns, bounded to 1 KB, and `decodeCursor` refuses anything this server did not sign with the same `CURSOR_INVALID`. The body shape is `{ items, page: { nextCursor, limit, total? } }`, `nextCursor` is null on the last page, and `total` is only for a count that is cheap. `keysetWhere(['created_at', 'id'], values, { direction, parameterOffset })` builds the row-order predicate with bound parameters; offset paging over a tenant's rows is not a platform capability. New list endpoints use these; the list endpoints written before them still carry their own paging until their module retrofits it.
|
|
16
16
|
|
|
17
|
-
**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. `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.
|
|
17
|
+
**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.
|
|
18
18
|
|
|
19
19
|
**PostgreSQL with forced row-level security.** A module receives a `DatabaseProvider` as `context.databases` and acquires a lease per purpose (`runtime`, `migration`, `background`, `preview`, `test`); it never sees a DSN or a pool. Every statement runs inside `database.transaction(fn, { tenantId, access })` with `access: 'read' | 'write'`; a runtime lease without a `tenantId` throws `TENANT_CONTEXT_REQUIRED`. The adapter sets `coreloom.tenant_id` transaction-locally, and each tenant table must `ENABLE` and `FORCE ROW LEVEL SECURITY` with a `USING` and `WITH CHECK` policy against it. Migrations are numbered, immutable once applied, mirrored byte for byte in `databaseMigrations`, and recorded in the `_coreloom_migrations_v2` ledger by checksum. The runtime role holds neither `SUPERUSER` nor `BYPASSRLS`. (`packages/database/src/{contracts,postgresql,migrations,provider}.ts`.)
|
|
20
20
|
|
|
@@ -26,7 +26,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
26
26
|
|
|
27
27
|
**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.
|
|
28
28
|
|
|
29
|
-
**Navigation groups.** A navigation contribution picks exactly one group (`NavigationGroup`, `packages/client/src/contributions.ts`):
|
|
29
|
+
**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.
|
|
30
30
|
|
|
31
31
|
<!-- capabilities:navigation-groups -->
|
|
32
32
|
|
|
@@ -44,7 +44,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
44
44
|
|
|
45
45
|
**Account menu.** A view reached from the avatar menu rather than the sidebar, for anything owned by the person instead of the workspace: `accountMenu` entries carry `id`, `viewId`, `label`, `description`, `glyph`, `scope`, `order`.
|
|
46
46
|
|
|
47
|
-
**Agent tools registry.** A module registers tools during `createServerComposition` with `context.agentTools.register(tools)`. A tool is built by `defineApiAgentTool` (wraps one `endpointId`) or `defineCliAgentTool` (wraps one CLI `capability`) from `packages/harness/src/tool-adapters.ts` and declares `id`, `description`, `requiredPermissions`, `inputSchema`, `outputSchema`, `contractVersion`, `risk: 'read' | 'workspace-write' | 'external' | 'destructive'`, `idempotency`, `idempotencyProtection: 'target-ledger'`, `cancellation`, `timeoutMs`. A CLI capability with risk `external` or `destructive` cannot become an unattended agent tool.
|
|
47
|
+
**Agent tools registry.** A module registers tools during `createServerComposition` with `context.agentTools.register(tools)`. A tool is built by `defineApiAgentTool` (wraps one `endpointId`) or `defineCliAgentTool` (wraps one CLI `capability`) from `packages/harness/src/tool-adapters.ts` and declares `id`, `description`, `requiredPermissions`, `inputSchema`, `outputSchema`, `contractVersion`, `risk: 'read' | 'workspace-write' | 'external' | 'destructive'`, `idempotency`, `idempotencyProtection: 'target-ledger'`, `cancellation`, `timeoutMs`. A CLI capability with risk `external` or `destructive` cannot become an unattended agent tool. A tool the model provider executes itself, such as a web search, is registered with `context.agentTools.registerNative({ id, kind: 'web-search', config, requiredPermissions, consent?, resolveConfig?, record? })` (`AgentNativeTool`, `packages/harness/src/runtime.ts`): it shares the tool id space, a definition allows and a run grants it by exact id, the harness asks its consent gate once when the run starts and hands the admitted ones to the provider as `AgentProviderContext.nativeTools`, and the provider reports citations through `reportNative`, which the harness bounds, records on the run as a `tool.native` event and passes to `record`; a `NATIVE_TOOL_UNSUPPORTED` report reaches `record` too, as `{ query: null, results: [], unsupported: { code, detail } }` after the same live permission check, and a refused check only withholds it, never failing the run. The local simulation provider answers a native web search from the `research-fixtures.json` named by `AgentExecutionRequest.nativeFixturesPath`. The Vercel AI SDK provider passes one granted web search to the model provider's own tool (`packages/ai-provider/src/web-search.ts`): Anthropic `web_search_20250305` with `maxUses` from the config (default 5, at most 16) and OpenAI `web_search` with `max_tool_calls` set the same way, both with `allowedDomains` sent alone or else `blockedDomains`. It reports each search with its URLs, titles, cited text and page age (`source` is the provider kind) when the model call's stream ends, before any tool the model called in that call runs, at most 16 reports per run with the overflow merged into the last. Azure, OpenAI-compatible and Vercel AI Gateway models report `NATIVE_TOOL_UNSUPPORTED` and the run goes on. So does an Anthropic or OpenAI request the provider refuses over the web search tool itself (a 400 or 403 request or permission error whose message names web search): the model call is retried once without the tool, the tool stays out for the rest of the run, and the `tool.native` event carries `detail: PROVIDER_WEB_SEARCH_DISABLED`; any other provider error fails the run as before.
|
|
48
48
|
|
|
49
49
|
**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.
|
|
50
50
|
|
|
@@ -54,7 +54,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
54
54
|
|
|
55
55
|
**CLI extensions.** A module contributes `pnpm flowdular <namespace> <action>` through `src/cli/commands.json` plus `defineCliExtension` (`packages/cli-protocol/src/index.ts`), declared in `module.json` under `cli`. The first path segment must equal the module id's first segment and may not be a reserved group (`help`, `doctor`, `capability`, `spec`, `blueprint`, `module`, `migration`, `setup`). Each command carries a `CapabilityDescriptor` with `id`, `version`, `summary`, `risk: 'read' | 'workspace-write' | 'process' | 'external' | 'destructive'`, `requiresApprovedSpec`, `supportsDryRun`, optional `localOnly` and `confirmation`. The runner refuses `external` and non-local `destructive` commands unless `--grant <token> --tenant <id>` carries a verified approval grant for the capability and the invocation digest, gates `localOnly` to `development` and `test`, and treats every write command as a dry run without `--apply`.
|
|
56
56
|
|
|
57
|
-
**Translations.** The shipped locales are `en` and `pl`. A module keeps a flat bundle per locale in `translations/<locale>.json`, imports them in `src/client/contribution.tsrx` and returns `translations: { en, pl }`. Keys resolve fully qualified as `<first module segment>.<key>` through `t()`. `flowdular module validate` reports `TRANSLATION_FILE_MISSING`, `TRANSLATION_PARSE_ERROR`, `TRANSLATION_KEYS_MISMATCH` and `TRANSLATION_KEY_MISSING`.
|
|
57
|
+
**Translations.** The shipped locales are `en` and `pl`. A module keeps a flat bundle per locale in `translations/<locale>.json`, imports them in `src/client/contribution.tsrx` and returns `translations: { en, pl }`. Keys resolve fully qualified as `<first module segment>.<key>` through `t()`. A count is a plural family (`<key>.one`/`.other` in `en`, `<key>.one`/`.few`/`.many`/`.other` in `pl`) read as `t(key, { count })`. `flowdular module validate` reports `TRANSLATION_FILE_MISSING`, `TRANSLATION_PARSE_ERROR`, `TRANSLATION_KEYS_MISMATCH`, `TRANSLATION_PLURAL_INCOMPLETE` and `TRANSLATION_KEY_MISSING`.
|
|
58
58
|
|
|
59
59
|
**Web surfaces.** A module may own public, server-rendered website pages separate from the authenticated shell (`packages/server/src/web.ts`). `defineWebSurface` declares `{ id, pages }` with at most 128 pages; a page has `id`, `path`, `entry`, optional `layout`, a `load` function and `access`, which is one of `{ kind: 'public' }`, `{ kind: 'authenticated' }` or `{ kind: 'permission', permission }`. The operator, not the module, mounts a surface at a tenant-scoped path; `setup`, `health`, `ready`, `assets`, `app`, `api`, `auth`, `sign-in`, `sign-up`, `forgot-password`, `reset-password` and `accept-invitation` are reserved.
|
|
60
60
|
|
|
@@ -66,17 +66,21 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
66
66
|
|
|
67
67
|
**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.
|
|
68
68
|
|
|
69
|
-
**Connectors.** `connectors.core` (optional) is the governed way out to an external system. 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; `consented(tenantId, instanceId, caller)` answers admission alone. 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.
|
|
69
|
+
**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.
|
|
70
|
+
|
|
71
|
+
**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.
|
|
70
72
|
|
|
71
73
|
**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.
|
|
72
74
|
|
|
73
|
-
**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.
|
|
75
|
+
**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.
|
|
74
76
|
|
|
75
77
|
**Directory and SCIM.** `directory.core` (optional) gives a workspace a SCIM 2.0 endpoint at `/api/scim/v2/:workspace` (Users and Groups) authenticated by a workspace SCIM token an owner creates through `POST /api/directory/tokens`, group mappings paged through `GET /api/directory/groups` with the role keys from `GET /api/directory/groups/context`, and a provisioning ledger at `GET /api/directory/provisioning-events`. Deprovisioning disables the membership through users.core; no password ever crosses SCIM. A module does not talk to SCIM; it reads memberships through users.core as before.
|
|
76
78
|
|
|
77
79
|
**Metering.** `metering.core` (optional) counts what a workspace consumes. A module declares meters at composition through the public capability `metering.meters.v1` (`modules/metering/src/domain/meters.ts`): `declare(moduleId, [{ key, label, unit, kind }])`, and at run time `record({ tenantId, meter, amount, at?, sourceRef? })`, idempotent per source reference and written by metering.core in its own tenant transaction, and `check({ tenantId, meter, amount })` answering `allowed`, `warning` or `refused` against the operator's monthly limit; a refusal carries the stable code `METER_LIMIT_EXCEEDED` and the caller records it in its own trail. Usage screens are behind `metering.usage.read`; limits are set only by the operator with `pnpm flowdular metering limits set`.
|
|
78
80
|
|
|
79
|
-
**Import.** `import.core` (optional) brings CSV rows into a module's records through a port the module registers at composition with the public capability `import.ports.v1` (`modules/import/src/domain/ports.ts`, re-exported from `@flowdular/sdk/modules/import`): `register(moduleId, [{ key, label, permission, fields: [{ id, label, required, type }], naturalKey, validate({ tenantId, principal, rows }), write({ tenantId, principal, rows, mode }) }])`. Rows arrive as `{ row, values }` with unmapped columns absent; `validate` returns refusals only, `write` answers one outcome per row (`created`, `updated`, `skipped`, `failed`) under the mode `create-only`, `update-existing` or `skip-existing`, in batches of at most `batchSize` inside the port's own tenant transaction, and a row it says nothing about is failed with `PORT_SILENT`. The CSV is a document stored through `documents.core`; jobs, per-row outcomes and saved mappings live behind `import.jobs.read` and `import.jobs.manage`, the target's own manage permission is checked on the live principal at start and continue, and the poll loop parses and writes a job in bounded batches. `users.core.members` is the first target.
|
|
81
|
+
**Import.** `import.core` (optional) brings CSV rows into a module's records through a port the module registers at composition with the public capability `import.ports.v1` (`modules/import/src/domain/ports.ts`, re-exported from `@flowdular/sdk/modules/import`): `register(moduleId, [{ key, label, permission, fields: [{ id, label, required, type }], naturalKey, validate({ tenantId, principal, rows }), write({ tenantId, principal, rows, mode }) }])`. Rows arrive as `{ row, values }` with unmapped columns absent; `validate` returns refusals only, `write` answers one outcome per row (`created`, `updated`, `skipped`, `failed`) under the mode `create-only`, `update-existing` or `skip-existing`, in batches of at most `batchSize` inside the port's own tenant transaction, and a row it says nothing about is failed with `PORT_SILENT`. The CSV is a document stored through `documents.core`; jobs, per-row outcomes and saved mappings live behind `import.jobs.read` and `import.jobs.manage`, the target's own manage permission is checked on the live principal at start and continue, and the poll loop parses and writes a job in bounded batches. `users.core.members` is the first target. A module that already holds rows writes them without a CSV or a job through `import.write.v1` (`modules/import/src/domain/write.ts`): `describe(moduleId, portKey)` answers the port's fields, natural key, permission and the platform `batchSize`, and `validate` and `write({ tenantId, principal, moduleId, portKey, rows, mode, sourceRef })` take at most one batch, refuse a principal of another workspace (`PRINCIPAL_TENANT_MISMATCH`) or without the port's permission (`TARGET_FORBIDDEN`) before any port code runs, check the field shapes (`FIELD_UNKNOWN`, `FIELD_REQUIRED`, `FIELD_TYPE`) and a natural key repeated in the call, run the port's `validate` and then its `write`, and answer one outcome per row in the order given (`created`, `updated`, `skipped`, `invalid`, `failed`); nothing is recorded by import.core.
|
|
82
|
+
|
|
83
|
+
**Adapters.** `adapters.core` (optional) runs the source and sink adapters a module's spec declares, so a module pulls records from another system into an import port or pushes a list export out to it without a run table, job or timer of its own. A module registers them while it composes through `adapters.sources.v1` or `adapters.sinks.v1` (`modules/adapters/src/domain/registry.ts`): `register(moduleId, [{ id, direction, label, connector, operation, port, schedule?, mapping, recorded?, input?, items?, paging?, mode?, batchSize? }])`, where `id` starts with the module id, `input` is the operation input every call starts from, `items` the answer path of a source's record array or the input path of a sink's batch, `paging` either `{ kind: 'cursor', param, next }` or `{ kind: 'page', param, start? }`, `mode` a source's import mode (`update-existing` by default), `batchSize` a sink's rows per push (1 to 200, default 50), and `recorded` the parsed `adapters/<name>.recorded.json` `{ adapter, operation, calls: [{ input, body }] }`; a sink pushes a list of the registering module only, anything malformed throws `ADAPTER_REGISTRATION_INVALID` at boot, and the registry is sealed when adapters.core starts. An owner binds each adapter on the Data adapters screen (Administration, section integrations) behind `adapters.runs.manage` (owners; `adapters.runs.read` to read): a `connectors.core` instance of the declared definition, the enabled switch, a schedule override (the declared five-field cron, on demand, or another cron in the workspace zone) and a mapping override stored as data, checked against the port's fields or the list's columns. Mapping rules are `rename`, `constant`, `format` (`trim`, `lower`, `upper`, `integer`, `decimal`, `boolean`, `iso-date`, `date:<layout>`) and `lookup` through a table stored with the rule (the port contract has no lookup of its own), refusing a row with `MAPPING_VALUE_INVALID`, `MAPPING_FORMAT_INVALID` or `MAPPING_LOOKUP_UNMATCHED`. `POST /api/adapters/dry-run` reads the first page, maps up to 20 rows and runs the port's `validate` without writing. A run (`POST /api/adapters/runs/start`, a schedule, or `resume` of the latest failed or cancelled run from its cursor) is an `adapter_runs` row claimed by the shared job runner with a renewed lease: every call goes through `connectors.calls.v1` with caller `workflow` after `consented`, as the account that started it or last saved the binding, resolved live, a source writes through `import.write.v1` and a sink walks `exports.lists.v1` under that account, each page is tried three times with full jitter and `Retry-After`, the outcomes, counts and next cursor commit once per page fenced by the claim, a dead process is taken over from the stored cursor, a sink stores its row position rather than the list's own cursor (a list signs cursors per process) and pushes under an idempotency key per run chain, row position and slot, and every record ends as one `adapter_run_rows` outcome. One run per adapter is queued or running at a time (`ADAPTER_RUN_ACTIVE`), a due schedule while one is active is skipped and recorded, runs stop at 1000 pages and pages at 1000 records. An unbound adapter answers from its recorded fixture outside production (exact input first, then a contained one) and is `ADAPTER_NOT_BOUND` in production. Binding, enabling, run start, resume, cancel and a skipped schedule append `adapter_audit_events`; the meter `adapters.core.rows` counts rows written; data classes `runs` (90 days), `run-rows` (30 days), `bindings` and `audit` (kept); the run list is the export `adapters.core.runs`. Not covered: an adapter caller kind of its own in connectors.core, a lookup against the target module's records, and a sink over a query rather than a list export.
|
|
80
84
|
|
|
81
85
|
**Search.** `search.core` (optional) merges record hits from providers each module registers at composition through the public capability `search.providers.v1` (`modules/search/src/domain/providers.ts`): `register(moduleId, [{ key, label, permission, search({ tenantId, principal, query, limit, cursor?, signal? }) }])`, where `search` runs over the owner's own tables inside the owner's own tenant transaction and answers `{ hits: [{ ref, title, snippet, viewId, route, score }], nextCursor }`. `GET /api/search?q=` behind `search.records.read` fans out with a per-provider time budget, drops the providers whose permission the principal lacks, names the ones that timed out, answers a signed `page.nextCursor` a client sends back to page further, keeps the query in the member's recall only when the request carries `remember=1` (a deliberate submit, never a debounced prefix), and the command palette shows hits below navigation through the `commandSearch` client contribution (`@flowdular/sdk/client`), whose optional `onOpen(hit)` the shell calls on the contribution that answered with the opened hit, once, without awaiting it and with a throw or a rejection isolated, so search.core keeps that hit's query in recall while the record is already opening. search.core holds no index; a provider owns its index or its bounded scan.
|
|
82
86
|
|
|
@@ -100,13 +104,13 @@ Every value exported from `packages/ui/src/index.ts`:
|
|
|
100
104
|
|
|
101
105
|
<!-- capabilities:ui-exports -->
|
|
102
106
|
|
|
103
|
-
`BrandMark`, `MARK_VIEWBOX`, `MARK_WARP`, `MARK_WEFT`, `Icon`, `ICON_PATHS`, `Button`, `FormField`, `Select`, `DateField`, `DateRangeField`, `dateRangeReversed`, `formatDateValue`, `DatePicker`, `DATE_PRESET_IDS`, `datePresetRange`, `FileUpload`, `fileRefusal`, `SearchField`, `CheckGrid`, `Drawer`, `ScopeSummary`, `summarizeScopes`, `SettingRow`, `Tag`, `Table`, `TableCard`, `Pagination`, `pageRange`, `keysetPage`, `Kpi`, `Chart`, `PageHeader`, `EmptyState`, `Alert`, `Avatar`, `Switch`, `ConfirmDialog`, `focusableElements`, `trapFocus`, `Filters`, `Tabs`, `ToastHost`, `createToastStore`, `toasts`, `VariableTextarea`, `VariableInput`, `VariableSelect`, `initials`
|
|
107
|
+
`BrandMark`, `MARK_VIEWBOX`, `MARK_WARP`, `MARK_WEFT`, `Icon`, `ICON_PATHS`, `Button`, `FormField`, `Select`, `DateField`, `DateRangeField`, `dateRangeReversed`, `formatDateValue`, `DatePicker`, `DATE_PRESET_IDS`, `datePresetRange`, `FileUpload`, `fileRefusal`, `SearchField`, `CheckGrid`, `Drawer`, `ScopeSummary`, `summarizeScopes`, `SettingRow`, `Tag`, `CellCode`, `CellMuted`, `CellNumber`, `CellStack`, `CellTag`, `CellText`, `CellTime`, `Table`, `DEFAULT_TABLE_LOCALE`, `TableLocaleContext`, `TableCard`, `Pagination`, `pageRange`, `keysetPage`, `Kpi`, `Chart`, `PageHeader`, `EmptyState`, `Alert`, `Avatar`, `Switch`, `ConfirmDialog`, `focusableElements`, `trapFocus`, `Filters`, `Tabs`, `SortableList`, `ToastHost`, `createToastStore`, `toasts`, `VariableTextarea`, `VariableInput`, `VariableSelect`, `initials`
|
|
104
108
|
|
|
105
109
|
<!-- /capabilities:ui-exports -->
|
|
106
110
|
|
|
107
|
-
`ICON_PATHS`, `MARK_*`, `DATE_PRESET_IDS`, `summarizeScopes`, `initials`, `pageRange`, `keysetPage`, `formatDateValue`, `dateRangeReversed`, `datePresetRange`, `fileRefusal`, `createToastStore` and `
|
|
111
|
+
`ICON_PATHS`, `MARK_*`, `DATE_PRESET_IDS`, `summarizeScopes`, `initials`, `pageRange`, `keysetPage`, `formatDateValue`, `dateRangeReversed`, `datePresetRange`, `fileRefusal`, `createToastStore`, `toasts`, `DEFAULT_TABLE_LOCALE` and `TableLocaleContext` are constants and helpers, not components. There is no other shared component: a screen that needs one builds it module-locally on tokens and flags it as a promotion candidate.
|
|
108
112
|
|
|
109
|
-
`Drawer` and `ConfirmDialog` trap Tab inside the open panel and return focus to whatever opened it; `trapFocus(root)` is that trap for a module-owned modal (install on open, call the returned release on close) and `focusableElements(root)` lists its Tab stops. `Table` takes `mode="server"` for a list the module already sorted, narrowed and paged in SQL, and a `reason` on a `TableAction` that is read as the refused action's accessible description. `Table` also takes `selection` (`TableSelection`: controlled `selectedKeys`, `onSelectionChange`, `label`, `rowLabel`, `selectable`) for a leading checkbox column whose header toggles the rows on screen, `bulkActions` (`TableBulkAction`, `onSelect(keys)`, same `reason` pattern) in a bar above the table while something is selected, and `selectionSummary(count)` for the polite announcement.
|
|
113
|
+
`Drawer` and `ConfirmDialog` trap Tab inside the open panel and return focus to whatever opened it; `trapFocus(root)` is that trap for a module-owned modal (install on open, call the returned release on close) and `focusableElements(root)` lists its Tab stops. `Table` takes `mode="server"` for a list the module already sorted, narrowed and paged in SQL, and a `reason` on a `TableAction` that is read as the refused action's accessible description. `Table` also takes `selection` (`TableSelection`: controlled `selectedKeys`, `onSelectionChange`, `label`, `rowLabel`, `selectable`) for a leading checkbox column whose header toggles the rows on screen, `bulkActions` (`TableBulkAction`, `onSelect(keys)`, same `reason` pattern) in a bar above the table while something is selected, and `selectionSummary(count)` for the polite announcement. A table cell is one of the typed cells (`CellText`, `CellStack`, `CellTime`, `CellCode`, `CellTag`, `CellNumber`, `CellMuted`), which never break inside a word and end a long value in an ellipsis with the value as its title; a `TableColumn` takes `priority` 1, 2 or 3, a narrow card hides columns one at a time (priority 3 before 2, the last declared first) before the table scrolls, and each row then expands to list the hidden columns. More than two row actions, or two in a narrow card, fold into one More menu, and a clickable row opens from a button in its first cell. The shell provides the table copy and locale through `TableLocaleContext`; a screen passes neither (`docs/design-system.md`, section Tables). `SortableList` is the reorderable list (`items`, `onReorder(ids)` once per committed drop, `handleLabel`, `instructions`, `announce(event)`, `renderItem`): a grip per item drags with a pointer or, from the keyboard, Space picks up, the arrows move, Space drops and Escape cancels, each step announced in a polite live region.
|
|
110
114
|
|
|
111
115
|
Every visual class is defined once in `packages/ui/src/styles/components.css` and prefixed `ui-` (about 180 names in `ui-block__element--modifier` form; `.num` for tabular figures is the only exception). Tokens live in `tokens.css`. A module composes `ui-*` classes and never restyles one. The writable and component-owned subsets are listed in `.ai/skills/ux-design/SKILL.md`.
|
|
112
116
|
|
|
@@ -117,7 +121,6 @@ None of the following is available. A specification that needs one records it un
|
|
|
117
121
|
- E-mail templates as a platform feature. `context.mail` sends a message a module composed itself, with `renderMailTemplate` for the substitution and the escaping; there is no template registry, no per-tenant editing, no layout and no attachment.
|
|
118
122
|
- Outbound HTTP calls from a module of its own. Outbound delivery is `notifications.core`'s signed webhook and a `connectors.core` instance the owner configured and consented to; `risk: 'external'` is refused by the CLI runner and by the harness, so a spec must not declare an `external` action and a module that needs an external system registers a connector definition instead.
|
|
119
123
|
- Multi-row server actions. `Table` selects rows and offers `bulkActions` with the selected keys; the endpoint that acts on many keys at once, its permission and its audit are still the module's own.
|
|
120
|
-
- PDF generation.
|
|
121
124
|
- A tracing or metrics backend of our own. The span API, the trace context, the module-owned counters and histograms and the OTLP exporter exist and are described above; the collector, the dashboards and the alerts are the operator's, and there is no gauge, no span event, no baggage header and no OTLP logs or metrics exporter. Error reporting is one bounded webhook, not an incident tool.
|
|
122
125
|
- An event bus. Modules integrate through the public capabilities registry, synchronously.
|
|
123
126
|
- SAML and LDAP. Authentication is password plus the configured OIDC providers; provisioning is SCIM through `directory.core`.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"id": "catalog.core",
|
|
5
5
|
"package": "@flowdular/module-catalog",
|
|
6
|
-
"version": "0.8.
|
|
6
|
+
"version": "0.8.1",
|
|
7
7
|
"profile": "full",
|
|
8
8
|
"capabilities": ["api", "database", "client", "translations"],
|
|
9
9
|
"platform": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"dependencies": [
|
|
14
14
|
{
|
|
15
15
|
"id": "system.core",
|
|
16
|
-
"range": "^0.
|
|
16
|
+
"range": "^0.8.0"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"id": "auth.core",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowdular/module-catalog",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"octane": "0.1.51",
|
|
18
18
|
"segment-state": "0.2.0",
|
|
19
|
-
"@flowdular/sdk": "0.3.
|
|
19
|
+
"@flowdular/sdk": "0.3.2"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@tsrx/typescript-plugin": "0.3.120",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
schemaVersion: 1
|
|
2
2
|
id: catalog.core
|
|
3
|
-
specVersion: 0.8.
|
|
3
|
+
specVersion: 0.8.1
|
|
4
4
|
status: approved
|
|
5
5
|
name: Product Catalog Core
|
|
6
6
|
description: Provides tenant-scoped products and services with stable SKUs, units, prices, currencies, and lifecycle state.
|
|
@@ -12,7 +12,7 @@ capabilities:
|
|
|
12
12
|
- translations
|
|
13
13
|
dependencies:
|
|
14
14
|
- id: system.core
|
|
15
|
-
range: ^0.
|
|
15
|
+
range: ^0.8.0
|
|
16
16
|
- id: auth.core
|
|
17
17
|
range: ^0.13.0
|
|
18
18
|
- id: exports.core
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "catalog.core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"repository": "Flowdular/official-modules",
|
|
5
|
-
"sourceCommit": "
|
|
6
|
-
"artifactSha256": "
|
|
5
|
+
"sourceCommit": "6565e2067b029264de98f59cfc9fa7eeb5645b54",
|
|
6
|
+
"artifactSha256": "5c32abd8f6df0c1d3ec0f694d270de3b24ef2e62888454bc89564a67f59c7828",
|
|
7
7
|
"files": {
|
|
8
8
|
"LICENSE": "155d722071bad9d0d832482e06fd5a47f7034389cb63aabcb39f4282aa3499fc",
|
|
9
9
|
"migrations/0001_catalog_core.down.sql": "0962a1edf0bde959cd4facccf77bd5b634d6cc25f125a0c79a61770c8871093f",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"migrations/0005_catalog_list_indexes.down.sql": "3dd547d738e75513abef502afc4bf9b51851ec9405b6b719c6134b897c5b1776",
|
|
18
18
|
"migrations/0005_catalog_list_indexes.up.sql": "dc3ece30b93fba0c91be58f772d1ae421cfbba49439e8c72a06aead6720f5db5",
|
|
19
19
|
"migrations/README.md": "2a15fd001713c2552a3c82b186246aa5fec136f43143cc2d9aa15bd491a09d41",
|
|
20
|
-
"module.json": "
|
|
21
|
-
"package.json": "
|
|
22
|
-
"spec/module.yaml": "
|
|
20
|
+
"module.json": "a4ac79385b396842f96d6d190b615565d09a9ea7a4f761de589ee3312b0b8639",
|
|
21
|
+
"package.json": "716dd452c7eaf14b5eeb80c1dba98e71c6f43ee33bd5fd03a2bf90801571d5f5",
|
|
22
|
+
"spec/module.yaml": "6a2cf7d9df6d2cf5cf2ea6517a79faab7bf184fab917485811fbfaad5b31787b",
|
|
23
23
|
"src/acl/permissions.ts": "3375521a5a229736ffac5a948140ec347dc7a7e4fba80e778c0baaf9e0622590",
|
|
24
24
|
"src/agent/tools.ts": "3403db7954906dc2c79227ba0420b5cb4bfb57063b7cfc285e6e556f16be9266",
|
|
25
25
|
"src/api/endpoints.ts": "205ef36d058f246f99b77336e7246a23aaca611c696a1f6f09758f66e8a93a29",
|
|
@@ -62,7 +62,8 @@ Do not load the whole skill catalog into the task context.
|
|
|
62
62
|
network/git, or touch a DB outside their module tests.
|
|
63
63
|
12. Keep handoffs short and factual. No AI attribution footers or em/en dashes.
|
|
64
64
|
Sandbox final line: `HANDOFF: <allowed-role> - <why>` or
|
|
65
|
-
`HANDOFF: none - <why>`, never your own role.
|
|
65
|
+
`HANDOFF: none - <why>`, never your own role. Branches, commits, PR body,
|
|
66
|
+
labels: `release-eject-pr` section 4.
|
|
66
67
|
13. Flow: request, `spec-interview`, approval, `module-new`/`module-update`,
|
|
67
68
|
`auto-review`. Implement from the approved spec and its touch list; do not
|
|
68
69
|
scan `modules/` or `packages/`. First lookup is `.ai/platform-capabilities.md`.
|
|
@@ -21,6 +21,7 @@ Flowdular is an agentic foundation framework: the platform is the foundation, an
|
|
|
21
21
|
| `cli-extension` | Module CLI commands through `commands.json` and `defineCliExtension`, with the runner's approval rules. |
|
|
22
22
|
| `agent-tool-design` | Register module tools through the live composition registry with tenant, permission, input, output, and audit bounds. |
|
|
23
23
|
| `business-agent-design` | Ship a module-owned business agent with an exact tool ceiling, tenant binding, revisions, and access tests. |
|
|
24
|
+
| `integration-adapter` | Add a source or sink adapter for a named service: connector, port, mapping, recorded fixture, consent, call log. |
|
|
24
25
|
| `variables` | Variable-aware fields and templates: the `{{ }}` contract, the scope mask, server-side resolution, adding a source. |
|
|
25
26
|
| `workflow-development` | Build, publish, invoke, simulate, and test typed durable workflows and their module integration capability. |
|
|
26
27
|
| `release-eject-pr` | Sandbox eject sequence, repository gates, branch and PR conventions, post-merge scope grant. |
|
|
@@ -198,6 +198,31 @@ nothing.
|
|
|
198
198
|
|
|
199
199
|
Declare `settings: defineModuleSettings({...})` (from `@flowdular/sdk/kernel`) by returning it from the composition, keep a reference to `PlatformServerContext.settings` in the tool factory, and read it per call as `settings.get<number>(context.tenantId, '<module>.core', 'key')` at request time, never at boot. Declared settings render in the module's drawer under Administration, Modules automatically.
|
|
200
200
|
|
|
201
|
+
## 6. Research and evidence
|
|
202
|
+
|
|
203
|
+
When the spec declares `research`, agents gather outside facts through `research.core`, and a module's own tools record what the agent concluded. Two rules decide the design.
|
|
204
|
+
|
|
205
|
+
**Evidence ids travel with findings.** `research.search` and `research.fetch` belong to `research.core` (`risk: 'workspace-write'` like `connectors.call`, because `external` asks for a signed grant on every call; `idempotency: 'none'`, behind the harness consent gate `research.consent`, which refuses with `TOOL_NOT_CONSENTED` until an owner turns on `research.core.allowAgents`). Every result the run keeps and every page it reads becomes an evidence row carrying the run id, and `research.fetch` answers its `evidenceId`. A module never registers a tool that opens a URL. The module tool that stores a finding on the `evidenceOwner` record takes the ids in its input:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
required: ['recordId', 'finding', 'evidenceIds'],
|
|
212
|
+
properties: {
|
|
213
|
+
recordId: { type: 'string' },
|
|
214
|
+
finding: { type: 'string' },
|
|
215
|
+
evidenceIds: { type: 'array', items: { type: 'string' } },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The service it calls bounds the list (at least one id, at most a small fixed number), resolves each id with `get(tenantId, id)` on `research.evidence.v1` so an id of another tenant or an invented one refuses the whole call, calls `attach(tenantId, '<module id>', recordId, evidenceIds)`, and only then commits the finding, so a failed attach never leaves a finding without its sources. The tool output echoes the ids, and an agent's `outputSchema` carries them beside each finding, so a reviewer, an approval and a later document can all reach the source.
|
|
221
|
+
|
|
222
|
+
**The model never computes.** A score, a premium, a total or a price per square metre is a module action or tool that runs deterministic code over stored inputs and a versioned rule or table, and answers the value with that version. The agent passes references (record ids, evidence ids, the inputs it read) and never a figure of its own; a tool never stores a number the model supplied as the result, and an `outputSchema` field for a computed value is filled from the action's answer, not from generation.
|
|
223
|
+
|
|
224
|
+
Tests for this section: a finding without evidence and a finding citing another tenant's evidence are refused before any write; the computation answers the same value for the same inputs and names its rule version; a run without research consent sees `TOOL_NOT_CONSENTED` and writes nothing.
|
|
225
|
+
|
|
201
226
|
## Pitfalls
|
|
202
227
|
|
|
203
228
|
- A tool id equal to an endpoint id is a convention, not a requirement; keep them parallel for traceability. A read-by-id tool with no dedicated endpoint reuses the read endpoint id under the same permission.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: integration-adapter
|
|
3
|
+
description: >-
|
|
4
|
+
Add a source or sink adapter for a named external service from its API
|
|
5
|
+
documentation: the connector definition, the port, the mapping, the recorded
|
|
6
|
+
fixture, the consent check and the call and row records.
|
|
7
|
+
roles:
|
|
8
|
+
- backend-engineer
|
|
9
|
+
- module-executor
|
|
10
|
+
when: An approved spec declares adapters[], or a brief asks to pull records from or push records to a named service.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Add an integration adapter
|
|
14
|
+
|
|
15
|
+
An adapter moves records between this module and a service the business already runs (an accounting package, a CRM, a bank feed, a listing portal). A **source** pulls pages from the service and writes them through an import port; a **sink** pushes the pages of a list export to the service. Every call leaves through `connectors.core`, so the egress policy, the sealed credentials, the owner's consent and the call log apply without code of your own.
|
|
16
|
+
|
|
17
|
+
## 1. Read exactly this
|
|
18
|
+
|
|
19
|
+
1. The approved spec: its `adapters[]` entry (`id`, `direction`, `connector`, `operation`, `port`, `schedule`, `mapping`, `recorded`) and the entity the port writes. `pnpm flowdular spec validate` already checked that the id starts with the module id, that a source port belongs to this module or a declared dependency and that `schedule` is a five-field cron; in a sandbox session the `spec-schema` gate also refuses an adapter without `recorded` (`SANDBOX_LIVE_ADAPTER_REFUSED`).
|
|
20
|
+
2. The service's API documentation the brief or the session attachments supply: base URL, authentication, the list or push endpoint, its paging parameters, one example response.
|
|
21
|
+
3. `.ai/platform-capabilities.md`, the Connectors, Import, List export and Background work entries.
|
|
22
|
+
4. `src/adapters/<name>.ts` and the recorded fixture stub, which the scaffold wrote from the spec entry.
|
|
23
|
+
|
|
24
|
+
Anything the documentation does not settle (which field is the natural key, what a missing value means, how deep paging goes) is a spec defect: hand it back, never guess.
|
|
25
|
+
|
|
26
|
+
## 2. The connector definition
|
|
27
|
+
|
|
28
|
+
Use the shipped `http-json` definition (operations `get`, `post`, `put`, `patch`, `delete`, the whole path from the call input) unless the spec names a definition of this module. A definition of your own is registered while the module composes, through `connectors.definitions.v1` (`modules/connectors/src/domain/definitions.ts`):
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
context.capabilities
|
|
32
|
+
.get<ConnectorDefinitionRegistry>(CONNECTORS_DEFINITIONS_CAPABILITY)
|
|
33
|
+
?.register({
|
|
34
|
+
key: 'erp-vendors', // the spec's connector, ^[a-z][a-z0-9-]{0,95}$
|
|
35
|
+
moduleId: 'vendors.core',
|
|
36
|
+
label: 'ERP vendors',
|
|
37
|
+
authKinds: ['bearer'], // what the documentation offers
|
|
38
|
+
operations: [
|
|
39
|
+
{
|
|
40
|
+
key: 'list-vendors', // the spec's operation
|
|
41
|
+
label: 'List vendors',
|
|
42
|
+
method: 'GET',
|
|
43
|
+
path: '/api/v2/vendors', // {name} expands one segment, {+name} a whole path
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
properties: { query: { type: 'object' } },
|
|
48
|
+
},
|
|
49
|
+
outputSchema: { type: 'object' },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
defaultAllowedHosts: ['erp.example.com'], // the API host only
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Declare `connectors.definitions.v1` and `connectors.calls.v1` under `requires` (optional when the module works without the connector) and `connectors.core` with its range under `dependencies` in the spec and `module.json`, plus `@flowdular/sdk/modules/connectors` in `package.json` for the types. The base URL, the credential and the host allowlist are the owner's instance, created after delivery; no key, token or URL of a tenant ever sits in code, a fixture or a log line.
|
|
57
|
+
|
|
58
|
+
## 3. The port
|
|
59
|
+
|
|
60
|
+
- **Source.** The rows land through an import port (`modules/import/src/domain/ports.ts`): `fields`, a `naturalKey` that makes a repeated pull idempotent, per-row outcomes under `create-only`, `update-existing` or `skip-existing`. The spec's `port` is `<module id>.<key>` of this module or a declared dependency. `adapters.core` writes through `import.write.v1` (`modules/import/src/domain/write.ts`), which checks the port's permission on the run's principal and calls the port's own `validate` and `write`; the module never calls its port for an adapter itself.
|
|
61
|
+
- **Sink.** The spec's `port` is a list export id of this module (`defineListExport`, `packages/server/src/export/`, reference `modules/users/src/services/member-export.ts`). `adapters.core` finds it through `exports.lists.v1` and walks its `page` under the run's principal, which must hold the list's permission.
|
|
62
|
+
|
|
63
|
+
## 4. The registration and the mapping
|
|
64
|
+
|
|
65
|
+
Register the adapter while the module composes, sources through `adapters.sources.v1` and sinks through `adapters.sinks.v1` (`modules/adapters/src/domain/registry.ts`), and add both with `optional: true` under `requires`, calling again from `start` when the capability was not there yet (the `exports.lists.v1` pattern in `.ai/references/catalog/src/platform.ts`):
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import fixture from '../adapters/erp-vendors.recorded.json' with { type: 'json' };
|
|
69
|
+
|
|
70
|
+
sources.register('vendors.core', [
|
|
71
|
+
{
|
|
72
|
+
...ERP_VENDORS_ADAPTER, // the scaffolded declaration: id, direction, connector, operation, port, schedule, mapping
|
|
73
|
+
label: 'ERP vendors',
|
|
74
|
+
recorded: fixture, // the parsed fixture, not its path
|
|
75
|
+
input: { path: '/api/v2/vendors', query: { limit: 100 } }, // every call starts from this
|
|
76
|
+
items: 'data', // the record array in the answer; '' is the answer itself
|
|
77
|
+
paging: { kind: 'cursor', param: 'query.cursor', next: 'meta.next_cursor' }, // or { kind: 'page', param: 'query.page', start: 1 }
|
|
78
|
+
mode: 'update-existing',
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A sink names `items` as the input path a batch goes to (`body.records`) and `batchSize` (1 to 200, default 50) instead of `paging` and `mode`. The id must start with the module id, a sink port must be this module's own list, and a malformed cron, mapping, path, paging or fixture throws `ADAPTER_REGISTRATION_INVALID` at boot.
|
|
84
|
+
|
|
85
|
+
The mapping is data: the spec's rules as registered, or the override an owner saves on the Data adapters screen. `from` is a dotted path into the service record (a list column key for a sink), `to` a port field id (a dotted path of the pushed record for a sink):
|
|
86
|
+
|
|
87
|
+
- `rename`: copy the value; a missing or null value leaves the field absent.
|
|
88
|
+
- `constant`: write `value`.
|
|
89
|
+
- `format`: parse with `value` set to `trim`, `lower`, `upper`, `integer`, `decimal`, `boolean`, `iso-date` or `date:<layout>` over `YYYY`, `MM` and `DD`; a value that does not parse refuses the row with `MAPPING_FORMAT_INVALID`.
|
|
90
|
+
- `lookup`: replace the value through the rule's `table`, which the owner fills on the screen; an unmatched value refuses the row with `MAPPING_LOOKUP_UNMATCHED`. The import port contract has no lookup of its own, so a lookup against the target's records is not available.
|
|
91
|
+
|
|
92
|
+
A value that is not text, a number or a boolean, or is longer than 2000 characters, refuses the row with `MAPPING_VALUE_INVALID`.
|
|
93
|
+
|
|
94
|
+
## 5. The run
|
|
95
|
+
|
|
96
|
+
`adapters.core` runs the adapter; the module keeps no run table, job or timer. An owner binds a `connectors.core` instance of the declared definition, checks the mapping with a dry run and enables the adapter. A run is a row claimed by the shared job runner: every call goes through `connectors.calls.v1` with caller `workflow` and `callerRef` set to the run id after `consented` admitted it, each page is tried three times with full jitter and `Retry-After`, the outcomes and the next cursor commit once per page, a process that dies is taken over from the stored cursor, and a failed page keeps its cursor for Resume. A sink stores its row position, re-walks the list to it after a restart (so the list's order must be stable) and pushes under an idempotency key per run chain, row position and slot. A `schedule` runs on its cron in the workspace zone through `adapters.core` itself.
|
|
97
|
+
|
|
98
|
+
## 6. The recorded fixture
|
|
99
|
+
|
|
100
|
+
`recorded` names `adapters/<name>.recorded.json`: `{ adapter, operation, calls: [{ input, body }] }`, where `adapter` and `operation` equal the registration's. A call answers the `body` of the first recorded call whose `input` equals the call input, else of the first whose `input` is contained in it (every key it names, at every depth, with the same value), and `ADAPTER_RECORDED_CALL_MISSING` otherwise. Record one call per page with the exact input the paging produces (`{ path, query: { limit } }`, then `{ path, query: { limit, cursor } }`), and a sink push by the keys that matter (`{ path: '/import' }`). The fixture answers only while the adapter is bound to no instance and the platform is not in production. Write it from the documentation's example responses or the session's sample data, trimmed to a few rows that exercise every mapping rule, including one row each rule refuses. It never holds a credential, a live tenant's data or a response recorded from a production system. In a sandbox session it is the only way the adapter runs.
|
|
101
|
+
|
|
102
|
+
## 7. What the records must show
|
|
103
|
+
|
|
104
|
+
- Consent: a run without the instance's `allowWorkflows` fails with `ADAPTER_CONSENT_MISSING` and calls nothing.
|
|
105
|
+
- Calls: one `connectors.core` call log row per call with the instance, the operation, the caller `workflow`, `callerRef` set to the run id, the outcome, the status, the error class, the duration and the byte counts; never a body.
|
|
106
|
+
- Rows: `adapter_runs` with the adapter, the trigger, the cursor and the counts, and one `adapter_run_rows` outcome per record with its natural key and reason, so a person can answer which record came from where.
|
|
107
|
+
|
|
108
|
+
## 8. Tests
|
|
109
|
+
|
|
110
|
+
Against the recorded fixture, never the network:
|
|
111
|
+
|
|
112
|
+
- the registration composes (`adapters.sources.v1` or `adapters.sinks.v1` accepts it with the fixture);
|
|
113
|
+
- the fixture answers every page the paging asks for, and every mapping rule writes or refuses a row as intended (a dry run through `POST /api/adapters/dry-run` shows it);
|
|
114
|
+
- the port refuses what the module refuses, so a repeated pull updates or skips by the natural key.
|
|
115
|
+
|
|
116
|
+
## Pitfalls
|
|
117
|
+
|
|
118
|
+
- `risk: 'external'` is refused by the runner and the harness; an adapter is a registration `adapters.core` runs through a consented connector, never an external action of its own.
|
|
119
|
+
- The egress policy refuses redirects and private addresses; a documentation example on `http://` or a local host will not run.
|
|
120
|
+
- A page answers at most 1000 records and a run reads at most 1000 pages (`ADAPTER_PAGE_TOO_LARGE`, `ADAPTER_PAGES_EXCEEDED`); set the page size in `input` well below that.
|
|
121
|
+
- A connector `outputSchema` of `{ type: 'object' }` checks nothing; the mapping function is where a changed response is refused.
|