create-flowdular 0.3.1 → 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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +25 -0
  3. package/agent-template/.agents/skills/integration-adapter/SKILL.md +116 -0
  4. package/agent-template/.agents/skills/module-new/SKILL.md +17 -2
  5. package/agent-template/.agents/skills/module-update/SKILL.md +14 -2
  6. package/agent-template/.agents/skills/release-eject-pr/SKILL.md +23 -26
  7. package/agent-template/.agents/skills/spec-interview/SKILL.md +16 -0
  8. package/agent-template/.agents/skills/translations-i18n/SKILL.md +2 -1
  9. package/agent-template/.agents/skills/ux-design/SKILL.md +4 -4
  10. package/agent-template/.ai/agents/sandbox/backend-engineer.md +9 -0
  11. package/agent-template/.ai/blueprints/agentic-module/README.md +15 -0
  12. package/agent-template/.ai/blueprints/agentic-module/allowed-paths.yaml +14 -0
  13. package/agent-template/.ai/blueprints/agentic-module/blueprint.json +19 -0
  14. package/agent-template/.ai/blueprints/agentic-module/gates.yaml +24 -0
  15. package/agent-template/.ai/blueprints/agentic-module/input.schema.json +18 -0
  16. package/agent-template/.ai/blueprints/agentic-module/plan.schema.json +35 -0
  17. package/agent-template/.ai/blueprints/agentic-module/required-files.yaml +28 -0
  18. package/agent-template/.ai/blueprints/agentic-module/spec-requirements.yaml +33 -0
  19. package/agent-template/.ai/blueprints/agentic-module/steps.yaml +68 -0
  20. package/agent-template/.ai/platform-capabilities.md +16 -11
  21. package/agent-template/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  22. package/agent-template/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  23. package/agent-template/.ai/references/catalog/module.json +12 -2
  24. package/agent-template/.ai/references/catalog/package.json +2 -2
  25. package/agent-template/.ai/references/catalog/spec/module.yaml +26 -5
  26. package/agent-template/.ai/references/catalog/src/agent/tools.ts +19 -10
  27. package/agent-template/.ai/references/catalog/src/api/endpoints.ts +150 -10
  28. package/agent-template/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  29. package/agent-template/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  30. package/agent-template/.ai/references/catalog/src/client/api.ts +124 -36
  31. package/agent-template/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  32. package/agent-template/.ai/references/catalog/src/client/state.ts +169 -3
  33. package/agent-template/.ai/references/catalog/src/domain/lists.ts +7 -0
  34. package/agent-template/.ai/references/catalog/src/domain/types.ts +20 -0
  35. package/agent-template/.ai/references/catalog/src/platform.ts +20 -0
  36. package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  37. package/agent-template/.ai/references/catalog/src/services/database-repository.ts +104 -17
  38. package/agent-template/.ai/references/catalog/src/services/item-export.ts +81 -0
  39. package/agent-template/.ai/references/catalog/src/services/migration.ts +27 -1
  40. package/agent-template/.ai/references/catalog/src/services/repository.ts +31 -2
  41. package/agent-template/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  42. package/agent-template/.ai/references/catalog/tests/client-state.test.ts +124 -0
  43. package/agent-template/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  44. package/agent-template/.ai/references/catalog/tests/export.test.ts +134 -0
  45. package/agent-template/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  46. package/agent-template/.ai/references/catalog/tests/list.test.ts +217 -0
  47. package/agent-template/.ai/references/catalog/tests/migrations.test.ts +58 -2
  48. package/agent-template/.ai/references/catalog/tests/module.test.ts +2 -1
  49. package/agent-template/.ai/references/catalog/tests/support/database.ts +14 -0
  50. package/agent-template/.ai/references/catalog/translations/en.json +35 -4
  51. package/agent-template/.ai/references/catalog/translations/pl.json +35 -4
  52. package/agent-template/.ai/references/catalog.provenance.json +34 -26
  53. package/agent-template/.ai/rules/flowdular.md +2 -1
  54. package/agent-template/.ai/skills/README.md +1 -0
  55. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +25 -0
  56. package/agent-template/.ai/skills/integration-adapter/SKILL.md +121 -0
  57. package/agent-template/.ai/skills/module-new/SKILL.md +17 -2
  58. package/agent-template/.ai/skills/module-update/SKILL.md +14 -2
  59. package/agent-template/.ai/skills/release-eject-pr/SKILL.md +23 -26
  60. package/agent-template/.ai/skills/spec-interview/SKILL.md +16 -0
  61. package/agent-template/.ai/skills/translations-i18n/SKILL.md +2 -1
  62. package/agent-template/.ai/skills/ux-design/SKILL.md +4 -4
  63. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +25 -0
  64. package/agent-template/.claude/skills/integration-adapter/SKILL.md +116 -0
  65. package/agent-template/.claude/skills/module-new/SKILL.md +17 -2
  66. package/agent-template/.claude/skills/module-update/SKILL.md +14 -2
  67. package/agent-template/.claude/skills/release-eject-pr/SKILL.md +23 -26
  68. package/agent-template/.claude/skills/spec-interview/SKILL.md +16 -0
  69. package/agent-template/.claude/skills/translations-i18n/SKILL.md +2 -1
  70. package/agent-template/.claude/skills/ux-design/SKILL.md +4 -4
  71. package/agent-template/AGENTS.md +2 -1
  72. package/agent-template/CLAUDE.md +2 -1
  73. package/agent-template/docs/agent-contract.md +1 -1
  74. package/agent-template/docs/cli.md +8 -3
  75. package/agent-template/docs/configuration.md +29 -0
  76. package/agent-template/docs/design-system.md +112 -13
  77. package/agent-template/docs/module-distribution.md +10 -3
  78. package/agent-template/docs/modules.md +51 -1
  79. package/agent-template/docs/operations.md +2 -0
  80. package/agent-template/docs/sandbox.md +76 -1
  81. package/assets/flowdular-banner.webp +0 -0
  82. package/package.json +1 -1
  83. package/template/default/flowdular.json +2 -0
  84. package/template/default/modules/example/package.json +1 -1
  85. package/template/default/modules/example/src/client/NotesView.tsrx +12 -16
  86. package/template/default/modules/example/tests/module.test.ts +3 -2
  87. package/template/default/modules/example/translations/pl.json +3 -1
  88. package/template/default/package.json +1 -1
  89. package/template/default/platform/package.json +1 -1
  90. package/template/default/platform/src/generated/modules.client.ts +4 -0
  91. package/template/default/platform/src/generated/modules.server.ts +68 -8
  92. package/assets/flowdular-banner.png +0 -0
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "required": ["blueprint", "moduleId", "specDigest", "steps", "gates"],
6
+ "properties": {
7
+ "blueprint": { "const": "agentic-module@1.0.0" },
8
+ "moduleId": {
9
+ "type": "string",
10
+ "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
11
+ },
12
+ "specDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
13
+ "steps": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "items": { "type": "string" }
17
+ },
18
+ "gates": {
19
+ "type": "array",
20
+ "minItems": 1,
21
+ "items": {
22
+ "enum": [
23
+ "spec-schema",
24
+ "module-schema",
25
+ "dependencies",
26
+ "typecheck",
27
+ "tests",
28
+ "format",
29
+ "auto-review"
30
+ ]
31
+ },
32
+ "uniqueItems": true
33
+ }
34
+ }
35
+ }
@@ -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
 
@@ -24,7 +24,9 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
24
24
 
25
25
  **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.
26
26
 
27
- **Navigation groups.** A navigation contribution picks exactly one group (`NavigationGroup`, `packages/client/src/contributions.ts`):
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
+
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.
28
30
 
29
31
  <!-- capabilities:navigation-groups -->
30
32
 
@@ -42,7 +44,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
42
44
 
43
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`.
44
46
 
45
- **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.
46
48
 
47
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.
48
50
 
@@ -52,7 +54,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
52
54
 
53
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`.
54
56
 
55
- **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`.
56
58
 
57
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.
58
60
 
@@ -64,17 +66,21 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
64
66
 
65
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.
66
68
 
67
- **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.
68
72
 
69
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.
70
74
 
71
- **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.
72
76
 
73
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.
74
78
 
75
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`.
76
80
 
77
- **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.
78
84
 
79
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.
80
86
 
@@ -98,13 +104,13 @@ Every value exported from `packages/ui/src/index.ts`:
98
104
 
99
105
  <!-- capabilities:ui-exports -->
100
106
 
101
- `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`
102
108
 
103
109
  <!-- /capabilities:ui-exports -->
104
110
 
105
- `ICON_PATHS`, `MARK_*`, `DATE_PRESET_IDS`, `summarizeScopes`, `initials`, `pageRange`, `keysetPage`, `formatDateValue`, `dateRangeReversed`, `datePresetRange`, `fileRefusal`, `createToastStore` and `toasts` 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.
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.
106
112
 
107
- `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.
108
114
 
109
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`.
110
116
 
@@ -115,7 +121,6 @@ None of the following is available. A specification that needs one records it un
115
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.
116
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.
117
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.
118
- - PDF generation.
119
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.
120
125
  - An event bus. Modules integrate through the public capabilities registry, synchronously.
121
126
  - SAML and LDAP. Authentication is password plus the configured OIDC providers; provisioning is SCIM through `directory.core`.
@@ -0,0 +1,3 @@
1
+ DROP INDEX IF EXISTS catalog_items_tenant_updated_idx;
2
+ DROP INDEX IF EXISTS catalog_items_tenant_name_idx;
3
+ ALTER TABLE catalog_items DROP COLUMN IF EXISTS updated_at;
@@ -0,0 +1,11 @@
1
+ ALTER TABLE catalog_items ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0;
2
+ -- The backfill runs as the migrator, which forced row security keeps out of
3
+ -- every row without a tenant setting; lift the flag for the statement only.
4
+ ALTER TABLE catalog_items NO FORCE ROW LEVEL SECURITY;
5
+ UPDATE catalog_items SET updated_at = created_at WHERE updated_at = 0;
6
+ ALTER TABLE catalog_items FORCE ROW LEVEL SECURITY;
7
+ ALTER TABLE catalog_items ALTER COLUMN updated_at DROP DEFAULT;
8
+ CREATE INDEX IF NOT EXISTS catalog_items_tenant_name_idx
9
+ ON catalog_items (tenant_id, lower(name), id);
10
+ CREATE INDEX IF NOT EXISTS catalog_items_tenant_updated_idx
11
+ ON catalog_items (tenant_id, updated_at, id);
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "catalog.core",
5
5
  "package": "@flowdular/module-catalog",
6
- "version": "0.7.1",
6
+ "version": "0.8.1",
7
7
  "profile": "full",
8
8
  "capabilities": ["api", "database", "client", "translations"],
9
9
  "platform": {
@@ -13,11 +13,21 @@
13
13
  "dependencies": [
14
14
  {
15
15
  "id": "system.core",
16
- "range": "^0.7.0"
16
+ "range": "^0.8.0"
17
17
  },
18
18
  {
19
19
  "id": "auth.core",
20
20
  "range": "^0.13.0"
21
+ },
22
+ {
23
+ "id": "exports.core",
24
+ "range": "^0.2.0"
25
+ }
26
+ ],
27
+ "requires": [
28
+ {
29
+ "id": "exports.lists.v1",
30
+ "optional": true
21
31
  }
22
32
  ],
23
33
  "tenancy": "required",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-catalog",
3
- "version": "0.7.1",
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.0"
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.7.1
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,9 +12,14 @@ capabilities:
12
12
  - translations
13
13
  dependencies:
14
14
  - id: system.core
15
- range: ^0.7.0
15
+ range: ^0.8.0
16
16
  - id: auth.core
17
17
  range: ^0.13.0
18
+ - id: exports.core
19
+ range: ^0.2.0
20
+ requires:
21
+ - id: exports.lists.v1
22
+ optional: true
18
23
  tenancy: required
19
24
  locales:
20
25
  - en
@@ -30,6 +35,10 @@ invariants:
30
35
  - One tenant-scoped durable target ledger is shared by mutating agent tools and workflow actions; each entry binds an idempotency key to the exact tool identifier or versioned workflow action identifier, canonical input digest, and first persisted result in the same transaction as the catalog mutation.
31
36
  - Replaying the same idempotency key for the same operation and canonical input returns the original result without another catalog item or history row, while reuse for another operation or input fails with a stable conflict.
32
37
  - Idempotency ledger entries remain after a catalog item is deleted, and audit metadata records identifiers, input digests, and outcomes without raw tool input, credentials, secrets, or result payloads.
38
+ - The items list is answered one page at a time on the shared signed cursor, at most 200 rows per read and 50 by default, ordered by lower-cased name, by normalized SKU or by the last update time, each order ending in the item id, narrowed in SQL by kind, by status and by a substring of the name or the SKU, and read as one index range over the matching (tenant_id, sort expression, id) index. A cursor is bound to the workspace, the sort, the direction and a digest of the filters, and a cursor of another listing or another workspace is refused with CURSOR_INVALID. No unbounded read of a workspace's items exists; the agent list tool and the dashboard widget read one bounded page.
39
+ - Every item carries the time of its last accepted mutation, set when it is created, edited, archived or restored, so the list can be ordered by recency.
40
+ - The items list export registered with exports.lists.v1 as catalog.core.items declares the SKU, name, kind, unit, base price in minor units, currency and status columns, is behind catalog.items.read, and is walked by exports.core under the principal that started the job through the same paged read the list endpoint answers, in SKU order. A deployment without exports.core still composes catalog.core and registers nothing.
41
+ - The bulk archive and bulk restore routes are siblings of the single-row lifecycle routes under the same permission and CSRF rule. Each names 1 to 100 unique item ids and executes the single-row path per id, so every accepted transition keeps its own history row and the idempotency ledger is untouched; an id that is missing or of another workspace answers not-found and a refused id answers refused with its stable code, and neither fails the other ids.
33
42
  - catalog.core declares three data classes to the platform registry while it composes and performs their export itself on its own lease under its own tenant transaction. catalog.core.items and catalog.core.history are master data, and each export walks every row of one workspace by keyset in bounded pages, and neither class carries a retention period, a sweep, or an erasure, so a row leaves only when a person deletes it. catalog.core.idempotency-ledger is operational, is never swept, and is excluded from the export with a stated reason because the item it points at is already exported.
34
43
  permissions:
35
44
  - id: catalog.items.read
@@ -37,15 +46,27 @@ permissions:
37
46
  - id: catalog.items.manage
38
47
  description: Create and update products and services in the active tenant.
39
48
  dataOwnership:
40
- - catalog.core owns SKU, item identity, kind, unit, base price, currency, and lifecycle status.
49
+ - catalog.core owns SKU, item identity, kind, unit, base price, currency, lifecycle status, and the last update time.
41
50
  - catalog.core owns the idempotency ledger for its mutating agent tools and workflow actions; callers supply the key but do not maintain a second target ledger.
42
51
  - catalog.core declares the data classes catalog.core.items (catalog_items), catalog.core.history (catalog_items_history_v2, which holds every row of the superseded catalog_items_history) and catalog.core.idempotency-ledger (catalog_idempotency_ledger); items and history are exported per workspace, the ledger is excluded with a stated reason.
43
52
  - Sales and purchasing modules reference catalog item identifiers and snapshot commercial terms when required by their specs.
44
53
  acceptanceScenarios:
45
54
  - id: CATALOG-LIST
46
55
  given: Tenant-scoped products and services exist.
47
- when: An authorized principal lists catalog items.
48
- then: Only items owned by the active tenant are returned in deterministic SKU order.
56
+ when: An authorized principal lists catalog items with a sort, a direction, filters and a page size.
57
+ then: Only items owned by the active tenant are returned, in the requested order ending in the item id, narrowed by kind, status and search in SQL, one page at a time with a cursor only on a full page, and two consecutive pages neither overlap nor leave a gap.
58
+ - id: CATALOG-LIST-CURSOR
59
+ given: A page cursor was issued for one listing of one workspace.
60
+ when: It is presented tampered, by another workspace, with other filters or with another sort, or an unknown sort is requested.
61
+ then: The server answers 400 with CURSOR_INVALID for the cursor and INVALID_INPUT for the sort, before any repository read.
62
+ - id: CATALOG-LIST-EXPORT
63
+ given: exports.core composed and registered its list capability.
64
+ when: catalog.core composes and a principal with catalog.items.read starts an export of catalog.core.items.
65
+ then: The catalogue lists the export with its declared columns and the job streams every item of the principal's workspace in SKU order, page by page, and no item of another workspace.
66
+ - id: CATALOG-BULK-LIFECYCLE
67
+ given: A principal with catalog.items.manage selects several items on the list.
68
+ when: The principal archives or restores the selection, naming a missing id and an id of another workspace beside existing ones.
69
+ then: Each existing item transitions with its own history row, the missing and foreign ids answer not-found, the call answers one outcome per id, the screen tells the counts, and a body without ids, with more than 100 ids, with a repeated id or without a valid CSRF token is refused.
49
70
  - id: CATALOG-CREATE
50
71
  given: An authorized principal supplies a unique SKU and valid product or service data.
51
72
  when: The catalog item is created.
@@ -8,7 +8,10 @@ import { agentActor, type Actor } from '@flowdular/sdk/kernel';
8
8
  import { CATALOG_PERMISSIONS } from '../acl/permissions.ts';
9
9
  import type { CatalogItemKind } from '../domain/types.ts';
10
10
  import type { CatalogRuntime } from '../server/runtime.ts';
11
- import { CatalogServiceError } from '../services/catalog-service.ts';
11
+ import {
12
+ CatalogServiceError,
13
+ FIRST_LIST_PAGE,
14
+ } from '../services/catalog-service.ts';
12
15
 
13
16
  const MAX_TOOL_ROWS = 200;
14
17
  const CREATE_OPERATION = 'catalog.item.create@1';
@@ -39,6 +42,7 @@ const CATALOG_ITEM_OUTPUT_SCHEMA = {
39
42
  currency: { type: 'string' },
40
43
  status: { type: 'string', enum: ['active', 'archived'] },
41
44
  createdAt: { type: 'integer' },
45
+ updatedAt: { type: 'integer' },
42
46
  },
43
47
  } as const;
44
48
 
@@ -91,15 +95,20 @@ export function catalogAgentTools(
91
95
  const query = normalized(
92
96
  (input as Record<string, unknown> | null)?.query,
93
97
  );
94
- return (await (await runtime.service()).list(context.tenantId))
95
- .filter(
96
- (item) =>
97
- query === '' ||
98
- item.id.toLocaleLowerCase('en-US') === query ||
99
- item.sku.toLocaleLowerCase('en-US').includes(query) ||
100
- item.name.toLocaleLowerCase('en-US').includes(query),
101
- )
102
- .slice(0, MAX_TOOL_ROWS);
98
+ const service = await runtime.service();
99
+ /* An exact id answers one row before any search, so the variable
100
+ resolver that binds an item id never reads a page. */
101
+ const exact =
102
+ query === '' ? null : await service.get(context.tenantId, query);
103
+ if (exact) return [exact];
104
+ return (
105
+ await service.listPage(context.tenantId, {
106
+ ...FIRST_LIST_PAGE,
107
+ sort: 'sku',
108
+ search: query,
109
+ limit: MAX_TOOL_ROWS,
110
+ })
111
+ ).items;
103
112
  },
104
113
  }),
105
114
  defineApiAgentTool({