create-flowdular 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  2. package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/cli-extension/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/deploy-operate/SKILL.md +114 -0
  5. package/agent-template/.agents/skills/module-new/SKILL.md +29 -0
  6. package/agent-template/.agents/skills/module-update/SKILL.md +9 -1
  7. package/agent-template/.agents/skills/spec-interview/SKILL.md +114 -0
  8. package/agent-template/.agents/skills/ux-design/SKILL.md +34 -3
  9. package/agent-template/.ai/README.md +2 -1
  10. package/agent-template/.ai/agents/sandbox/business-manager.md +5 -1
  11. package/agent-template/.ai/blueprints/author-spec/README.md +1 -1
  12. package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +44 -0
  13. package/agent-template/.ai/blueprints/author-spec/steps.yaml +5 -5
  14. package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +99 -3
  15. package/agent-template/.ai/blueprints/edit-module/gates.yaml +4 -0
  16. package/agent-template/.ai/blueprints/edit-module/required-files.yaml +9 -0
  17. package/agent-template/.ai/blueprints/new-module/gates.yaml +4 -0
  18. package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +2 -2
  19. package/agent-template/.ai/blueprints/release/gates.yaml +4 -0
  20. package/agent-template/.ai/platform-capabilities.md +128 -0
  21. package/agent-template/.ai/policies/capabilities.yaml +130 -3
  22. package/agent-template/.ai/policies/path-ownership.yaml +5 -2
  23. package/agent-template/.ai/policies/task-budgets.yaml +5 -3
  24. package/agent-template/.ai/references/catalog/module.json +4 -4
  25. package/agent-template/.ai/references/catalog/package.json +2 -2
  26. package/agent-template/.ai/references/catalog/spec/module.yaml +5 -3
  27. package/agent-template/.ai/references/catalog/src/platform.ts +2 -0
  28. package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +89 -1
  29. package/agent-template/.ai/references/catalog/src/services/data-classes.ts +47 -0
  30. package/agent-template/.ai/references/catalog/src/services/database-repository.ts +98 -1
  31. package/agent-template/.ai/references/catalog/src/services/repository.ts +22 -1
  32. package/agent-template/.ai/references/catalog/tests/data-classes.test.ts +157 -0
  33. package/agent-template/.ai/references/catalog.provenance.json +12 -10
  34. package/agent-template/.ai/rules/flowdular.md +4 -0
  35. package/agent-template/.ai/skills/README.md +10 -0
  36. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -2
  37. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  38. package/agent-template/.ai/skills/business-agent-design/SKILL.md +0 -1
  39. package/agent-template/.ai/skills/cli-extension/SKILL.md +1 -1
  40. package/agent-template/.ai/skills/deploy-operate/SKILL.md +119 -0
  41. package/agent-template/.ai/skills/module-new/SKILL.md +29 -3
  42. package/agent-template/.ai/skills/module-update/SKILL.md +9 -3
  43. package/agent-template/.ai/skills/perf-audit/SKILL.md +0 -1
  44. package/agent-template/.ai/skills/release-eject-pr/SKILL.md +0 -1
  45. package/agent-template/.ai/skills/spec-interview/SKILL.md +120 -0
  46. package/agent-template/.ai/skills/test-hardening/SKILL.md +1 -0
  47. package/agent-template/.ai/skills/ux-design/SKILL.md +34 -3
  48. package/agent-template/.ai/skills/variables/SKILL.md +0 -2
  49. package/agent-template/.ai/skills/workflow-development/SKILL.md +0 -1
  50. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  51. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  52. package/agent-template/.claude/skills/cli-extension/SKILL.md +1 -1
  53. package/agent-template/.claude/skills/deploy-operate/SKILL.md +114 -0
  54. package/agent-template/.claude/skills/module-new/SKILL.md +29 -0
  55. package/agent-template/.claude/skills/module-update/SKILL.md +9 -1
  56. package/agent-template/.claude/skills/spec-interview/SKILL.md +114 -0
  57. package/agent-template/.claude/skills/ux-design/SKILL.md +34 -3
  58. package/agent-template/AGENTS.md +4 -0
  59. package/agent-template/CLAUDE.md +4 -0
  60. package/agent-template/docs/adr/0003-module-settings.md +1 -1
  61. package/agent-template/docs/adr/0006-agentic-workflows.md +24 -21
  62. package/agent-template/docs/agent-contract.md +2 -2
  63. package/agent-template/docs/cli-extensions.md +82 -0
  64. package/agent-template/docs/cli.md +195 -0
  65. package/agent-template/docs/configuration.md +593 -36
  66. package/agent-template/docs/design-system.md +185 -31
  67. package/agent-template/docs/getting-started.md +118 -0
  68. package/agent-template/docs/module-distribution.md +96 -0
  69. package/agent-template/docs/module-web-surfaces.md +221 -0
  70. package/agent-template/docs/modules.md +216 -0
  71. package/agent-template/docs/operations.md +545 -0
  72. package/agent-template/docs/sandbox.md +212 -0
  73. package/agent-template/platform/scripts/build.mjs +11 -0
  74. package/dist/bin.js +29 -0
  75. package/package.json +1 -1
  76. package/template/default/.dockerignore +14 -0
  77. package/template/default/.env.example +96 -0
  78. package/template/default/README.md +37 -1
  79. package/template/default/flowdular.json +15 -4
  80. package/template/default/infra/README.md +116 -0
  81. package/template/default/infra/docker/Dockerfile +37 -0
  82. package/template/default/infra/docker/compose.yaml +158 -0
  83. package/template/default/infra/docker/postgres/10-roles.sh +31 -0
  84. package/template/default/infra/docker/postgres/tls-init.sh +28 -0
  85. package/template/default/infra/kubernetes/database-secret.example.yaml +15 -0
  86. package/template/default/infra/kubernetes/deployment.yaml +211 -0
  87. package/template/default/infra/kubernetes/kustomization.yaml +9 -0
  88. package/template/default/infra/kubernetes/secrets.example.yaml +52 -0
  89. package/template/default/infra/kubernetes/service.yaml +13 -0
  90. package/template/default/modules/example/module.json +2 -1
  91. package/template/default/modules/example/package.json +1 -1
  92. package/template/default/modules/example/spec/module.yaml +1 -1
  93. package/template/default/modules/example/src/services/database-repository.ts +2 -12
  94. package/template/default/package.json +3 -2
  95. package/template/default/platform/octane.config.ts +99 -9
  96. package/template/default/platform/package.json +1 -1
  97. package/template/default/platform/src/generated/modules.client.ts +26 -2
  98. package/template/default/platform/src/generated/modules.server.ts +241 -10
  99. package/template/default/platform/src/server/health.ts +47 -0
  100. package/template/default/platform/src/server/metrics.ts +100 -0
  101. package/template/default/platform/src/server/storage.ts +172 -0
  102. package/template/default/platform/src/server/tracing.ts +85 -0
  103. package/template/default/specs/application.yaml +15 -0
@@ -0,0 +1,221 @@
1
+ # Module-owned web surfaces
2
+
3
+ A module can provide workspace administration, HTTP APIs and independently laid
4
+ out web pages in the same installation. Web pages are optional server composition
5
+ contributions. They do not render `ApplicationShell` or `AuthenticationCore`.
6
+ Access and presentation are independent: a standalone portal may still require
7
+ authentication or a permission.
8
+
9
+ ## Module declaration
10
+
11
+ Use the normal approved module specification and `platform.server: true` entry.
12
+ Declare the public interface and its visibility rules in that specification.
13
+ No new manifest capability or separate business module is required.
14
+
15
+ ```ts
16
+ import { defineWebSurface } from '@flowdular/sdk/server';
17
+ import type { ModuleServerComposition } from '@flowdular/sdk/server';
18
+
19
+ export function createServerComposition(): ModuleServerComposition {
20
+ return {
21
+ routes: [], // Existing protected administration APIs can remain here.
22
+ web: [
23
+ defineWebSurface({
24
+ id: 'public',
25
+ pages: [
26
+ {
27
+ id: 'record',
28
+ path: '/records/:slug',
29
+ entry: ['RecordPage', '@example/module/web'],
30
+ layout: '@example/module/web-layout',
31
+ access: { kind: 'public' },
32
+ async load({ site, params, signal }) {
33
+ // Call your module's service with site.tenantId.
34
+ // Read inside a tenant-scoped, read-only transaction.
35
+ // Return 404 if the record is missing or is not public.
36
+ return { title: 'Public record', slug: params.slug ?? '' };
37
+ },
38
+ },
39
+ ],
40
+ }),
41
+ ],
42
+ };
43
+ }
44
+ ```
45
+
46
+ Add browser-safe package exports for the page and optional layout:
47
+
48
+ ```json
49
+ {
50
+ "exports": {
51
+ "./platform": "./src/platform.ts",
52
+ "./web": "./src/web/RecordPage.tsrx",
53
+ "./web-layout": "./src/web/Layout.tsrx"
54
+ }
55
+ }
56
+ ```
57
+
58
+ Use package subpaths, not workspace absolute paths. These entries go through
59
+ Octane's normal development and production SSR/hydration compilation. Keep
60
+ database, credentials and server imports out of both page and layout. Declare
61
+ all imported dependencies in the package manifest. Reuse the shared translation
62
+ runtime and the module's existing bundles when localization is needed.
63
+
64
+ ## Operator configuration
65
+
66
+ Add the optional `web` section in the installation's `flowdular.json`:
67
+
68
+ ```json
69
+ {
70
+ "web": {
71
+ "mounts": [
72
+ {
73
+ "id": "acme-public",
74
+ "moduleId": "example.core",
75
+ "surfaceId": "public",
76
+ "path": "/blog",
77
+ "tenantId": "the-existing-acme-tenant-id",
78
+ "enabled": true
79
+ }
80
+ ]
81
+ }
82
+ }
83
+ ```
84
+
85
+ Run `pnpm flowdular module sync --apply`, then rebuild/redeploy production or
86
+ restart development. Configuration is generated into the server composition;
87
+ changing it requires regeneration. It is not read from a process-local settings
88
+ cache or an anonymous query parameter. Verify the tenant ID before publishing.
89
+ The same module surface can be mounted for multiple tenants under different
90
+ paths. `/records/:slug` above becomes `/blog/records/:slug`.
91
+
92
+ The root `/` can host a public storefront, alongside more specific mounts such as
93
+ `/blog`. Reserved platform prefixes such as `/app`, `/auth`, `/api`, `/setup` and
94
+ the configured backoffice path cannot be claimed by modules. Other overlapping
95
+ mounts and equivalent route patterns are rejected. Custom paths such as `/blog`, `/portal` and `/forms/contact` work without a
96
+ tenant ID in the URL. `/sites/` is an optional convention for multiple sites;
97
+ unknown sites under that prefix return 404. A disabled binding
98
+ or an uninstalled/disabled module leaves its configured address returning 404.
99
+ Keep that disabled binding when retiring an address so it cannot fall through
100
+ to legacy workspace routing. Remove the binding only when releasing the address.
101
+
102
+ A mount owns the addresses its pages declare. A mount under a custom path also
103
+ answers 404 below its own base, so do not put a `platform/public` file there.
104
+ A root mount claims nothing beyond its pages: the host serves built assets,
105
+ `platform/public` files and, in development, Vite's module and transform
106
+ requests from addresses the router leaves unmatched, and answers 404 for the
107
+ rest. An unknown public URL under a root mount therefore still returns 404, and
108
+ a root mount never shadows `/assets/...`, `/favicon.svg` or `/@vite/client`.
109
+ A deployment that wraps the exported handler itself must serve `dist/client`
110
+ in front of it, as the bundled Node server does.
111
+
112
+ ### Backoffice address
113
+
114
+ The first-run setup includes **Backoffice address**, defaulting to `/app` (or the
115
+ installation's configured default). Choose `/backoffice` to leave `/` available
116
+ for a storefront. Setup validates the address, includes it in the review, and
117
+ writes `FD_APPLICATION_PATH=/backoffice` alongside the database settings. Restart
118
+ after setup; no client rebuild is needed for this environment setting. On a
119
+ read-only deployment setup provides the environment block to paste into the
120
+ hosting service. If `FD_APPLICATION_PATH` already exists in the environment, it
121
+ is authoritative; setup cannot silently replace it.
122
+
123
+ For configuration managed in source control, add:
124
+
125
+ ```json
126
+ {
127
+ "application": { "path": "/backoffice" },
128
+ "web": {
129
+ "mounts": [
130
+ {
131
+ "id": "store",
132
+ "moduleId": "example.core",
133
+ "surfaceId": "public",
134
+ "path": "/",
135
+ "tenantId": "the-existing-tenant-id"
136
+ }
137
+ ]
138
+ }
139
+ }
140
+ ```
141
+
142
+ Run module sync and rebuild after changing `flowdular.json`. At startup,
143
+ `FD_APPLICATION_PATH` overrides `application.path`; `/app` is the fallback.
144
+ The value is installation-wide, one lowercase path segment of at most 64
145
+ characters. Navigation, authentication redirects and dashboard deep links use
146
+ it. When changed from `/app`, old `/app/...` bookmarks redirect permanently to
147
+ the configured address, preserving the suffix and query. Authentication pages
148
+ remain under `/auth/...`. A root mount removes the legacy slug-first dashboard
149
+ aliases; unknown public URLs return 404 instead of displaying the dashboard.
150
+
151
+ This release supports path mounts. Custom hostname binding, DNS ownership and
152
+ TLS provisioning are a separate deployment feature. It does not download or
153
+ activate executable modules at request time.
154
+
155
+ ## Page and hydration
156
+
157
+ ```tsx
158
+ import { Head, Seo } from '@octanejs/seo';
159
+ import { webPageData } from '@flowdular/sdk/client/web';
160
+ import type { RenderRouteProps } from '@octanejs/vite-plugin';
161
+
162
+ export function RecordPage(props: RenderRouteProps) @{
163
+ const record = webPageData<{ title: string; slug: string }>(props);
164
+ <Head>
165
+ <Seo title={record.title} description="A public record" />
166
+ <main><h1>{record.title}</h1><p>{record.slug}</p></main>
167
+ </Head>
168
+ }
169
+ ```
170
+
171
+ `load` runs before rendering, so it can return an actual 404 or redirect before
172
+ streaming starts. Its JSON result is the only data serialized into the page;
173
+ the authentication state, database handles and the rest of `Context.state` are
174
+ never serialized. `webPageData` reads that exact DTO on the server and during
175
+ hydration, with no second fetch. A layout receives the usual Octane page props
176
+ and `children`. Pages own their metadata, styling and UI states.
177
+
178
+ For an explicit refresh or client navigation, use
179
+ `loadWebPage<T>(url, abortSignal)` from the same client subpath. It requests the
180
+ same URL with `Accept: application/vnd.flowdular.page+json`, which runs the same
181
+ tenant, access and loader checks. Native links and deep-link refresh also work.
182
+ The page loader and data representation accept only GET/HEAD, never mutations.
183
+ Existing module endpoints remain the mechanism for writes; they must retain
184
+ their permission, CSRF and input-validation rules.
185
+
186
+ ## Access and safety
187
+
188
+ - `public`: the loader's identity is always null, even if the visitor has a
189
+ dashboard session for another tenant. Data ownership comes from `site.tenantId`.
190
+ - `authenticated`: requires an authenticated principal whose active tenant
191
+ matches the configured site tenant. Otherwise returns 401 or 403.
192
+ - `permission`: additionally requires the declared permission, for example
193
+ `{ kind: 'permission', permission: 'example.records.read' }`.
194
+
195
+ The module must enforce record visibility inside its own service. Tenant RLS
196
+ alone does not hide private records from a public visitor to that same tenant.
197
+ Never reuse an unrestricted administration query as a public loader. Return
198
+ `new Response('Not found', { status: 404 })` for inaccessible content. Use the
199
+ request signal to cancel database or external work and bound query inputs.
200
+
201
+ All responses use `Cache-Control: no-store`; the HTML and JSON representations
202
+ vary on `Accept`. Shared/CDN caching is deliberately disabled until an explicit
203
+ tenant-aware invalidation policy is configured in a future extension. Page JSON
204
+ is bounded to 1 MiB. Unexpected failures return safe errors without raw secrets.
205
+ The host lifecycle drains response streams on completion, cancellation, errors
206
+ and request abort before releasing module resources.
207
+
208
+ Installed modules remain trusted code in the host process. Module composition
209
+ types now live in the shared server API; auth exports compatibility aliases for
210
+ existing modules. This extension is not a runtime security sandbox.
211
+
212
+ ## Validation
213
+
214
+ Run module typechecks and tests, `pnpm verify`, and `pnpm build`. Cover public
215
+ and denied access, two tenants using the same slug, draft/private records,
216
+ redirects, 404s, deep links, cancellation and DTO contents. The shared production
217
+ integration test is `node scripts/smoke-web.mjs`; it creates an isolated fixture,
218
+ runs the real CLI composition generator, compiles the page and layout, and
219
+ checks production HTTP behavior. `FD_WEB_SMOKE_KEEP=true` retains its temporary
220
+ server for manual hydration and keyboard checks. No operator module is enabled
221
+ and no real tenant data is used by that test.
@@ -22,6 +22,37 @@ spec is the contract: `permissions[].id` equal the constants in
22
22
  pnpm flowdular spec validate --all
23
23
  ```
24
24
 
25
+ `schemaVersion: 2` adds the domain model, so an implementing agent reads the
26
+ spec instead of scanning the repository. Every section is optional and a
27
+ `schemaVersion: 1` spec stays valid unchanged; version 1 rejects these keys.
28
+
29
+ - `entities[]`: `id`, `name`, `fields[]` (`id`, `type` of `string`, `text`,
30
+ `integer`, `decimal`, `boolean`, `date`, `datetime`, `enum`, `reference` or
31
+ `json`, plus `required`, `unique: tenant|none`, `maxLength`, `values` for an
32
+ enum, `reference` as `<entityId>` or `<moduleId>.<entityId>`), and optional
33
+ `states` (`field`, `values`, `transitions`).
34
+ - `screens[]`: `id`, `kind` of `list`, `record`, `form` or `dashboard`,
35
+ `entity`, `title`, `columns`, `filters`, `navigationGroup`.
36
+ - `actions[]`: `id`, `entity`, `permission`, `kind`, `risk`, `idempotent`,
37
+ `description`. `widgets[]`: `id`, `slot`, `entity`, `description`.
38
+ - `settings[]`: `key`, `type`, `scope`, `default`, `values`, `description`.
39
+ `agentTools[]`: `id`, `permission`, `description`, `risk`.
40
+ - `outOfScope[]` records what is deliberately not built; `decisions[]` records
41
+ each interview question, its answer and whether a user or a default decided.
42
+
43
+ Validation is more than the schema: an action permission must exist in
44
+ `permissions`, every `entity` must name an entity, screen `columns` and
45
+ `filters` must be fields of that entity, a `reference` must resolve in this spec
46
+ or a declared dependency, an enum field or setting needs `values`, `states.field`
47
+ must be the enum that declares exactly `states.values`, a field may not be called
48
+ `id`, `tenantId`, `createdAt` or a PostgreSQL reserved word such as `order` or
49
+ `user` (generated SQL quotes no identifier), and the `database` capability needs
50
+ at least one entity (`SPEC_ACTION_PERMISSION_UNKNOWN`, `SPEC_ENTITY_UNKNOWN`,
51
+ `SPEC_FIELD_UNKNOWN`, `SPEC_FIELD_RESERVED`, `SPEC_STATE_FIELD_INVALID`,
52
+ `SPEC_REFERENCE_UNKNOWN`, `SPEC_ENUM_VALUES_REQUIRED`, `SPEC_ENTITY_REQUIRED`,
53
+ `SPEC_DUPLICATE_ID`). A client without a list screen, or a stored entity with no
54
+ tenant-unique field, is a warning.
55
+
25
56
  ### 2. Scaffold
26
57
 
27
58
  ```bash
@@ -42,6 +73,15 @@ and `migrations/0001_*.up.sql` and `.down.sql` with the tenant table and its
42
73
  forced row-level security block. The contract is in
43
74
  [Database adapters](database-adapters.md) and the `database-adapter` skill.
44
75
 
76
+ From a `schemaVersion: 2` spec one entity replaces the demo record: the one
77
+ whose id matches the permission entity segment, else the first declared. Its
78
+ fields become `src/domain/types.ts`, the `0001` migration columns (with
79
+ `UNIQUE (tenant_id, …)` for a `unique: tenant` field), the repository row
80
+ mapping, the create endpoint's input validation (required fields only; the
81
+ lifecycle field is set by the service), and the columns of the first `list`
82
+ screen become the table view. Further entities are the implementing agent's
83
+ work.
84
+
45
85
  Files are written through the workspace Prettier, so the format gate passes
46
86
  without a rewrite. A directory that already holds `spec/module.yaml` or
47
87
  `translations/**` is extended, not rejected, and a failed run leaves nothing
@@ -107,6 +147,38 @@ A running `pnpm dev` picks the change up live: the octane plugin reloads server
107
147
  routes when the generated composition changes and the client hot-reloads, so no
108
148
  rebuild is needed. `pnpm dev` and `pnpm build` run the sync automatically.
109
149
 
150
+ ## Versions, ranges and capabilities
151
+
152
+ A module carries one version in three places: `module.json` `version`,
153
+ `package.json` `version` and `spec/module.yaml` `specVersion`. Bump them with
154
+ one command; it also rewrites every dependent range that stops accepting the
155
+ new version and keeps the operator (`^0.11.0` becomes `^0.12.0`):
156
+
157
+ ```sh
158
+ pnpm flowdular module version auth.core
159
+ pnpm flowdular module version bump auth.core minor --apply
160
+ ```
161
+
162
+ Dependency ranges use caret (`^0.11.0`). Below 1.0.0 a caret range accepts
163
+ patch releases only, so a minor bump of a core module still moves its
164
+ dependents, which the bump command does. `module validate` reports
165
+ `SPEC_DEPENDENCY_DRIFT` when the spec and manifest ranges differ and
166
+ `PLATFORM_API_MISSING` when a manifest lacks `platformApi`.
167
+
168
+ `platformApi` is the range of the platform contract the module compiles against
169
+ (`^0.1.0`). The contract surface is pinned in
170
+ `packages/kernel/platform-api.snapshot.d.ts`; a change to it without a
171
+ `PLATFORM_API_VERSION` bump fails `pnpm verify`. `module search --compatible`
172
+ lists only releases whose range accepts the running platform.
173
+
174
+ Cross-module services are declared by capability id, not by module version:
175
+ `provides` lists the ids a module registers, `requires` lists the ids it
176
+ resolves (`optional: true` when it handles absence). The kernel orders required
177
+ providers before consumers, refuses a missing provider or two providers of one
178
+ id, and the composition hands each module a registry view that accepts only its
179
+ declared ids. The installer resolves a required capability to the newest
180
+ compatible release that provides it.
181
+
110
182
  ## Files the CLI owns
111
183
 
112
184
  Never edit these by hand:
@@ -128,6 +200,150 @@ returned as `settings` from the composition, read live with
128
200
  module's drawer under Administration, Modules. Administration, Settings holds
129
201
  only workspace and organization settings.
130
202
 
203
+ ## Record policies
204
+
205
+ A permission says who may act on a kind of record. A policy says whether this
206
+ record allows it: an amount above an approver's limit, a claim the submitter
207
+ owns, a contract outside the reviewer's region. Policies are declared with
208
+ `definePolicy` from `@flowdular/sdk/kernel` and evaluated through a registry the
209
+ module fills while it composes:
210
+
211
+ ```ts
212
+ const amountLimit = definePolicy<Claim>({
213
+ id: 'expenses.claims.amount-limit',
214
+ permission: 'expenses.claims.approve',
215
+ evaluate: ({ record }) =>
216
+ record.amountMinor <= 100_000
217
+ ? { allowed: true }
218
+ : {
219
+ allowed: false,
220
+ reason: 'The claim exceeds the approver limit.',
221
+ requiresApproval: {
222
+ requirement: { roleKey: 'finance-lead', decisions: 2 },
223
+ },
224
+ },
225
+ });
226
+
227
+ const policies = createPolicyRegistry();
228
+ policies.register(amountLimit);
229
+ policies.seal();
230
+
231
+ /* In the endpoint, once the record is loaded and before it is written. */
232
+ const decision = authorizeRecord(
233
+ policies,
234
+ principal,
235
+ 'expenses.claims.approve',
236
+ claim,
237
+ 'approve',
238
+ );
239
+ if (!decision.allowed) {
240
+ throw new HttpProblem('POLICY_DENIED', decision.reason, 403);
241
+ }
242
+ ```
243
+
244
+ `authorizeRecord` checks the permission scope first and evaluates policies only
245
+ after it holds, so a policy never sees a principal that lacks the permission. It
246
+ answers the first denial in registration order; a permission with no policy is
247
+ allowed by the scope alone. A policy that throws denies rather than escaping
248
+ into the endpoint. Register once per id, at most 256 policies, and seal the
249
+ registry before serving requests: a policy registered at request time would
250
+ change a decision under the requests already in flight.
251
+
252
+ `requiresApproval.requirement` carries `roleKey`, `scope`, `decisions` and
253
+ `expiresInDays`, and nothing else. Resolving a role to people, collecting their
254
+ decisions and keeping the receipt belong to the module that owns approvals; the
255
+ kernel owns the shape so a denial can name what would lift it. The platform does
256
+ not compose a shared registry yet, so a module that wants record conditions today
257
+ owns its registry inside its own composition.
258
+
259
+ ## List pagination
260
+
261
+ New list endpoints page with the helpers in `@flowdular/sdk/server`
262
+ (`packages/server/src/pagination.ts`). The endpoints written before them keep
263
+ their own paging until the owning module retrofits it in its own change, so do
264
+ not migrate another module's list while touching yours.
265
+
266
+ ```ts
267
+ const page = readPageQuery(new URL(octane.request.url), { maxLimit: 100 });
268
+ const after = page.cursor ? decodeCursor(page.cursor, cursorSecret) : null;
269
+ const keyset = after
270
+ ? keysetWhere(
271
+ ['created_at', 'id'],
272
+ [Number(after['createdAt']), String(after['id'])],
273
+ { parameterOffset: 1 },
274
+ )
275
+ : null;
276
+
277
+ /* One row more than the page is what says whether another page exists. */
278
+ const rows = await repository.pageClaims(tenantId, keyset, page.limit + 1);
279
+ const items = rows.slice(0, page.limit);
280
+ const last = items[items.length - 1];
281
+ return pageResponse({
282
+ items,
283
+ limit: page.limit,
284
+ nextCursor:
285
+ rows.length > page.limit && last
286
+ ? encodeCursor({ createdAt: last.createdAt, id: last.id }, cursorSecret)
287
+ : null,
288
+ });
289
+ ```
290
+
291
+ `readPageQuery` answers `{ limit, cursor }`, defaults to 50 rows, and refuses
292
+ bad input the way the rest of the input boundary does: 400 `INVALID_INPUT` for a
293
+ limit outside 1 to the endpoint's `maxLimit`, 400 `CURSOR_INVALID` for a cursor
294
+ that is too long or not cursor-shaped. The platform ceiling is 200 rows; an
295
+ endpoint narrows it with `maxLimit` and picks its own `defaultLimit`.
296
+
297
+ A cursor is the keyset of the last row of the page, HMAC signed with a 32 byte
298
+ secret the module owns and bounded to 1 KB, so a caller cannot move the page
299
+ boundary onto a row the query excludes. `decodeCursor` answers the same
300
+ `CURSOR_INVALID` for a forged, edited or retired cursor, which a client treats
301
+ as "start from the first page". Put nothing in a cursor but the ordering keys:
302
+ it is a position, not saved state.
303
+
304
+ The response body is `{ items, page: { nextCursor, limit, total? } }`.
305
+ `nextCursor` is null on the last page, and `total` belongs in the body only
306
+ where the count is cheap, which a count over a tenant's rows usually is not.
307
+ `keysetWhere(columns, cursorValues, { direction, parameterOffset })` builds
308
+ `(created_at < $2 OR (created_at = $2 AND id < $3))` with the values bound and
309
+ the column names checked as plain identifiers; the statement's `ORDER BY` must
310
+ match the columns and direction, ending in a unique column, and the table needs
311
+ an index on them. Offset paging is not a platform capability: `OFFSET` over a
312
+ tenant's rows gets slower with every page and drops rows that move.
313
+ Record history keeps its own narrower request contract, `parseHistoryRequest`
314
+ from `@flowdular/sdk/kernel`.
315
+
316
+ ## Search providers
317
+
318
+ A module that owns searchable records registers a provider with `search.core`
319
+ instead of feeding a central index. Declare `search.core` under `dependencies`
320
+ so it composes first, add `{ id: "search.providers.v1", optional: true }` under
321
+ `requires`, and register in `createServerComposition`:
322
+
323
+ ```ts
324
+ context.capabilities
325
+ .get<SearchProviderRegistry>(SEARCH_PROVIDERS_CAPABILITY)
326
+ ?.register('users.core', [createMemberSearchProvider(context.auth)]);
327
+ ```
328
+
329
+ A provider is `{ key, label, permission, search(input) }`. `search` receives
330
+ `{ tenantId, principal, query, limit, cursor?, signal? }` and answers
331
+ `{ hits, nextCursor }`, where a hit carries only `ref`, `title`, `snippet`,
332
+ `viewId`, `route` and `score`: search.core never opens the provider's tables and
333
+ never fetches the record. The `route` is workspace-relative and starts with `/`;
334
+ `score` orders hits inside one provider and is never compared across providers.
335
+ The query is normalized and bounded to 200 characters before a provider sees it,
336
+ and a query under two characters reaches nobody.
337
+
338
+ `search.core` runs every provider whose `permission` the principal holds in
339
+ parallel under `providerTimeoutMs`, merges provider-major, and pages the merged
340
+ stream with the platform cursor helpers. A provider that fails, times out, or
341
+ answers something unreadable contributes nothing and is named in the response's
342
+ `unavailable`; it never turns the member's search into an error. The registry is
343
+ sealed when search.core starts, so registration happens at composition and never
344
+ at request time. The capability stays optional: resolve it with `get`, register
345
+ behind `?.`, and the module still composes where search.core is not enabled.
346
+
131
347
  ## Adding a CLI command
132
348
 
133
349
  Module commands live in `src/cli/commands.json` and `src/cli/index.ts`,