@flowdular/sdk 0.3.0 → 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 (70) hide show
  1. package/.ai/platform-capabilities.md +2 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/module.json +1 -1
  4. package/.ai/references/catalog/package.json +2 -2
  5. package/.ai/references/catalog/spec/module.yaml +1 -1
  6. package/.ai/references/catalog.provenance.json +6 -6
  7. package/.ai/skills/cli-extension/SKILL.md +1 -1
  8. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  9. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  10. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  11. package/modules/approvals/module.json +1 -1
  12. package/modules/approvals/package.json +1 -1
  13. package/modules/approvals/spec/module.yaml +11 -2
  14. package/modules/approvals/src/domain/capability.ts +12 -0
  15. package/modules/approvals/src/domain/grant.ts +69 -0
  16. package/modules/approvals/src/domain/types.ts +14 -0
  17. package/modules/approvals/src/index.ts +9 -0
  18. package/modules/approvals/src/platform.ts +8 -0
  19. package/modules/approvals/src/server/runtime.ts +4 -0
  20. package/modules/approvals/src/services/approvals-service.ts +80 -0
  21. package/modules/approvals/src/services/database-repository.ts +65 -6
  22. package/modules/approvals/src/services/migration.ts +34 -0
  23. package/modules/approvals/src/services/repository.ts +8 -0
  24. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  25. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  26. package/modules/connectors/module.json +7 -3
  27. package/modules/connectors/package.json +2 -1
  28. package/modules/connectors/spec/module.yaml +2 -1
  29. package/modules/connectors/src/cli/commands.json +17 -0
  30. package/modules/connectors/src/cli/index.ts +126 -0
  31. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  32. package/modules/connectors/src/services/credential-vault.ts +6 -0
  33. package/modules/connectors/src/services/migration.ts +36 -0
  34. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  35. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  36. package/modules/documents/module.json +7 -3
  37. package/modules/documents/package.json +2 -1
  38. package/modules/documents/spec/module.yaml +2 -1
  39. package/modules/documents/src/cli/commands.json +17 -0
  40. package/modules/documents/src/cli/index.ts +145 -0
  41. package/modules/documents/src/services/database-repository.ts +15 -4
  42. package/modules/documents/src/services/documents-service.ts +13 -9
  43. package/modules/documents/src/services/migration.ts +35 -0
  44. package/modules/documents/src/services/repository.ts +12 -2
  45. package/modules/documents/src/services/storage-rotation.ts +157 -0
  46. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  47. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  48. package/modules/exports/module.json +7 -3
  49. package/modules/exports/package.json +2 -1
  50. package/modules/exports/spec/module.yaml +2 -1
  51. package/modules/exports/src/cli/commands.json +17 -0
  52. package/modules/exports/src/cli/index.ts +145 -0
  53. package/modules/exports/src/server/index.ts +0 -1
  54. package/modules/exports/src/services/data-classes.ts +16 -13
  55. package/modules/exports/src/services/database-repository.ts +30 -32
  56. package/modules/exports/src/services/migration.ts +27 -0
  57. package/modules/exports/src/services/repository.ts +9 -10
  58. package/modules/exports/src/services/storage-rotation.ts +138 -0
  59. package/package.json +1 -1
  60. package/packages/contracts/src/index.ts +1 -1
  61. package/packages/database/src/backup.ts +1 -0
  62. package/packages/database/src/migrations.ts +7 -0
  63. package/packages/harness/src/runtime.ts +169 -10
  64. package/packages/harness/src/tool-adapters.ts +6 -13
  65. package/packages/kernel/src/approval-grant.ts +310 -0
  66. package/packages/kernel/src/index.ts +20 -0
  67. package/packages/storage/src/envelope.ts +70 -21
  68. package/packages/storage/src/index.ts +7 -1
  69. package/packages/storage/src/port.ts +12 -1
  70. package/packages/storage/src/reseal.ts +128 -0
@@ -50,7 +50,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
50
50
 
51
51
  **Automations triggers.** Two mechanisms in `automations.core`. A schedule uses a cadence string in one of two forms (`modules/automations/src/domain/cadence.ts`): `every:N`, where `N` is whole minutes from 1 to 10080 and the unit is implicit, or `cron:<minute> <hour> <day of month> <month> <day of week>`, five fields with no seconds, at most 100 characters, each field `*`, a number, a three letter month or weekday name, a list, a range, or any of those with a step (`*/15`, `9-17/4`); both day fields restricted means either matches. A cron slot is a wall clock time in the workspace zone from the shared `system.core.timeZone` setting, stored in UTC; a wall time a daylight saving change removes is skipped and one that occurs twice fires at the first of the two. An expression the module cannot honour is refused at save with `INVALID_CADENCE`. Missed slots are skipped, never replayed. An inbound webhook posts to `POST /api/automations/triggers/:id/fire`, authenticated by an HMAC signature in `x-flowdular-signature` with `x-flowdular-timestamp` inside a 5 minute window and a 16 KB body cap, answering `202 { accepted, runId }`. Targets are pluggable through `automations.targets.v1`; the shipped kinds are `agent` and `workflow`.
52
52
 
53
- **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` always and non-local `destructive` always, gates `localOnly` to `development` and `test`, and treats every write command as a dry run without `--apply`.
53
+ **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
54
 
55
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`.
56
56
 
@@ -66,7 +66,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
66
66
 
67
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.
68
68
 
69
- **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.
69
+ **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
70
 
71
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.
72
72
 
@@ -84,6 +84,12 @@ core:
84
84
  confirmation: restore-database
85
85
  supportsDryRun: true
86
86
  effect: 'replaces the configured database with the backup in <dir> (pg_restore --clean --if-exists, or a staged replacement of the embedded data directory); refuses BACKUP_ADAPTER_MISMATCH across adapters and warns BACKUP_KEY_MISMATCH when the running environment holds keys the backup was not taken with'
87
+ database.restore.production:
88
+ command: pnpm flowdular database restore-production --input <dir> --target <database> --grant <token> --tenant <id> [--allow-key-mismatch] [--platform-url <origin>|--platform-stopped] --apply --confirm restore-database
89
+ risk: destructive
90
+ confirmation: restore-database
91
+ supportsDryRun: true
92
+ effect: 'the same restore without localOnly, so it runs in any environment but only under a verified approval grant (see approvals.destructive-not-local; the grant binds --input, --target and every other capability flag); --target must repeat the database of the migrator DSN (the embedded data directory for pglite) or RESTORE_TARGET_MISMATCH; MIGRATOR_ROLE_REQUIRED when FD_DATABASE_MIGRATOR_URL is absent or equals FD_DATABASE_URL; a key divergence is a BACKUP_KEY_MISMATCH refusal unless --allow-key-mismatch is part of the approved invocation; with --apply, PLATFORM_RUNNING while <origin>/api/health (from --platform-url, else http://127.0.0.1:$FD_PORT) answers, and PLATFORM_STATE_UNKNOWN when no endpoint is named or the probe times out unless --platform-stopped attests the platform is down'
87
93
  workspace.state.migrate:
88
94
  command: pnpm flowdular setup migrate-state [--apply --confirm migrate-legacy-state]
89
95
  risk: destructive
@@ -146,6 +152,24 @@ modules:
146
152
  risk: process
147
153
  supportsDryRun: true
148
154
  note: re-seals stored trigger secrets with the current encryption key; batches of 200 rows in one tenant-scoped transaction each, idempotent, and never prints a secret
155
+ documents.core:
156
+ documents.storage.rotate:
157
+ command: pnpm flowdular documents secrets-rotate [--apply]
158
+ risk: process
159
+ supportsDryRun: true
160
+ note: re-seals stored document objects in place with the current storage encryption key; batches of 200 rows in one tenant-scoped transaction each, idempotent, leaves an object under an unknown key or one that fails authentication as it is, and never prints object content
161
+ exports.core:
162
+ exports.storage.rotate:
163
+ command: pnpm flowdular exports secrets-rotate [--apply]
164
+ risk: process
165
+ supportsDryRun: true
166
+ note: re-seals stored export files in place with the current storage encryption key; batches of 200 rows in one tenant-scoped transaction each, idempotent, leaves a file under an unknown key or one that fails authentication as it is, and never prints file content
167
+ connectors.core:
168
+ connectors.secrets.rotate:
169
+ command: pnpm flowdular connectors secrets-rotate [--apply]
170
+ risk: process
171
+ supportsDryRun: true
172
+ note: re-seals stored connector credentials with the current encryption key and recomputes their fingerprints under it; batches of 200 rows in one tenant-scoped transaction each, idempotent, leaves a row under an unknown key as it is, and never prints a credential
149
173
  notifications.core:
150
174
  notifications.secrets.rotate:
151
175
  command: pnpm flowdular notifications secrets-rotate [--apply]
@@ -240,14 +264,18 @@ modules:
240
264
 
241
265
  # packages/cli/src/runner.ts, runExtensionCommand, in evaluation order.
242
266
  approvals:
243
- external: refused with APPROVAL_VERIFIER_REQUIRED (no signed approval verifier exists)
244
- destructive-not-local: refused with APPROVAL_VERIFIER_REQUIRED
267
+ external: the same grant check as destructive-not-local; APPROVAL_VERIFIER_REQUIRED without a token or key
268
+ destructive-not-local: needs --grant <token> --tenant <id>, an approval grant verified under FD_APPROVAL_GRANT_KEY against the tenant, the capability id and the invocation digest (positional arguments plus every flag except --root, --json, --confirm, --grant and --tenant, so --apply is bound and an approval names the applied run, never a dry run); APPROVAL_VERIFIER_REQUIRED without a token or key, APPROVAL_GRANT_INVALID, APPROVAL_GRANT_EXPIRED or APPROVAL_GRANT_MISMATCH otherwise; the runner records no use, so a grant replays until it expires
245
269
  localOnly: refused with LOCAL_ONLY_CAPABILITY unless FD_ENV or NODE_ENV is development or test (unset counts as development)
246
270
  requiresApprovedSpec: needs --spec <path>; APPROVED_SPEC_REQUIRED, SPEC_VALIDATION_FAILED or SPEC_NOT_APPROVED otherwise
247
271
  destructive-apply: --apply needs --confirm <descriptor.confirmation>; CONFIRMATION_REQUIRED otherwise
248
272
  non-read-without-dry-run: EXPLICIT_APPLY_REQUIRED without --apply
249
273
  dry-run: non-read with supportsDryRun and no --apply runs with apply=false and warns "Dry run only. No writes were authorized."
250
274
 
275
+ harnessGate:
276
+ localOnlyTools: CLI tools whose capability is localOnly are refused by the harness with TOOL_LOCAL_ONLY outside development and test, grant or not
277
+ grantedTools: external tools and non-local destructive CLI tools are offered only when the run carries a grant verified for the tool id and the input digest; one tool call per grant (APPROVAL_GRANT_CONSUMED afterwards); the consent gate still applies
278
+
251
279
  moduleExtensions:
252
280
  discovery: module.json cli.catalog (src/cli/commands.json), schema-validated without executing code
253
281
  loadImplementation: module.json cli.entry imported only when the command runs (packages/cli/src/extensions.ts loadCliCommand)
@@ -259,6 +287,5 @@ moduleExtensions:
259
287
  planned:
260
288
  - migration.plan (served by "migration apply" without --apply, which is a read-only dry run)
261
289
  - migration.apply.remote
262
- - destructive.execute (signed approval receipts)
263
290
  - redaction of *_TOKEN, *_SECRET, *_PASSWORD, DATABASE_URL in a shared logger (only packages/ai-provider/src/errors.ts redactSecrets exists)
264
291
  - persisted CLI audit (CommandEnvelope carries a random auditId and evidence per invocation; nothing stores it)
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "catalog.core",
5
5
  "package": "@flowdular/module-catalog",
6
- "version": "0.7.0",
6
+ "version": "0.7.1",
7
7
  "profile": "full",
8
8
  "capabilities": ["api", "database", "client", "translations"],
9
9
  "platform": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-catalog",
3
- "version": "0.7.0",
3
+ "version": "0.7.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.2.4"
19
+ "@flowdular/sdk": "0.3.0"
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.0
3
+ specVersion: 0.7.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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "catalog.core",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "repository": "Flowdular/official-modules",
5
- "sourceCommit": "b8281b38498b1e3164e6619ec9e8fe922ad7dc10",
6
- "artifactSha256": "6c98785c10fac14cb1a913c6462cb42e18fe824a7d0dd7620746605fd351441d",
5
+ "sourceCommit": "be391ac460e53e06a812fc9f0ea1260e2daa058f",
6
+ "artifactSha256": "7c645f660f44bb4c4540d32dd188d643aafb067f4c505a2722a7f2605a7b54b8",
7
7
  "files": {
8
8
  "LICENSE": "155d722071bad9d0d832482e06fd5a47f7034389cb63aabcb39f4282aa3499fc",
9
9
  "migrations/0001_catalog_core.down.sql": "0962a1edf0bde959cd4facccf77bd5b634d6cc25f125a0c79a61770c8871093f",
@@ -15,9 +15,9 @@
15
15
  "migrations/0004_catalog_idempotency_ledger.down.sql": "8ba72d8edc4d25ecf6d041888cf321690c25afdaf3723812ddd7d391d75370aa",
16
16
  "migrations/0004_catalog_idempotency_ledger.up.sql": "41e86ba2051159c6cc9672bfae17c1d02051da975f1c43c566a2b66592673cb0",
17
17
  "migrations/README.md": "2a15fd001713c2552a3c82b186246aa5fec136f43143cc2d9aa15bd491a09d41",
18
- "module.json": "1b512c9b97bdec2155103f521a6545e558e50491f90c74a93d33cc74511d2125",
19
- "package.json": "67d50dc95723d21bb44bbab54ae14fe89641c52b9f62ba065908f67ebcf0ed7a",
20
- "spec/module.yaml": "ec931c78f351c7fc53f43fdd5ec9cd1a46db485f642648329bf1612883a3c958",
18
+ "module.json": "f2c66d18e3d54ef1ad8dcc3d26d1ee39a2982463dd3bc3fb26b159039933ed86",
19
+ "package.json": "3e05cf299b5cc5d948e4b3569bd93f2648a30b22f4f14d26bb31548a79529092",
20
+ "spec/module.yaml": "633a719d765d9779e2bf4ae0a608251767a62cbb4e15305dc13afcb1e52f14c9",
21
21
  "src/acl/permissions.ts": "3375521a5a229736ffac5a948140ec347dc7a7e4fba80e778c0baaf9e0622590",
22
22
  "src/agent/tools.ts": "3204d20886d2b864482adc1ed3303c653aebd207e16c4fce0c67a4979ea8db07",
23
23
  "src/api/endpoints.ts": "34f1036e759353550be2acf67c6fa254afc790942d09489db2e76a082df5c3a3",
@@ -91,7 +91,7 @@ export default cliExtension;
91
91
 
92
92
  ## 4. What the runner does with the descriptor (`packages/cli/src/runner.ts`, `runExtensionCommand`)
93
93
 
94
- - `risk: 'external'`: refused with `APPROVAL_VERIFIER_REQUIRED`. `risk: 'destructive'` without `localOnly`: the same.
94
+ - `risk: 'external'`, or `'destructive'` without `localOnly`: refused with `APPROVAL_VERIFIER_REQUIRED` unless `--grant <token> --tenant <id>` carries a verified approval grant for this capability and invocation digest (`APPROVAL_GRANT_INVALID`, `APPROVAL_GRANT_EXPIRED`, `APPROVAL_GRANT_MISMATCH` otherwise).
95
95
  - `localOnly: true`: refused with `LOCAL_ONLY_CAPABILITY` unless `FD_ENV` or `NODE_ENV` is `development` or `test` (unset counts as development).
96
96
  - `requiresApprovedSpec: true`: needs `--spec <path>` to a schema-valid spec with `status: approved`, otherwise `APPROVED_SPEC_REQUIRED`, `SPEC_VALIDATION_FAILED` or `SPEC_NOT_APPROVED`.
97
97
  - `destructive` with `--apply`: needs `--confirm <confirmation>` (`CONFIRMATION_REQUIRED`).
@@ -81,9 +81,12 @@ Both are served by `platform/src/server/health.ts`. The shipped Docker and Kuber
81
81
  pnpm flowdular database backup --output <dir> # dry run
82
82
  pnpm flowdular database backup --output <dir> --apply
83
83
  pnpm flowdular database restore --input <dir> --apply --confirm restore-database
84
+ pnpm flowdular database restore-production --input <dir> --target <db> --grant <token> --tenant <id> [--platform-url <origin>|--platform-stopped] --apply --confirm restore-database
84
85
  ```
85
86
 
86
- The trap: **the encryption keys live outside the database.** Agent provider credentials, agent run grants, workflow payloads and cursors, automation secrets and MFA secrets are all stored as ciphertext, and the keys are environment variables (`FD_AGENT_CREDENTIAL_KEY`, `FD_AGENT_RUN_GRANT_KEY`, `FD_WORKFLOWS_PAYLOAD_KEY`, `FD_WORKFLOWS_CURSOR_KEY`, `FD_AUTOMATIONS_CREDENTIAL_KEY`, `FD_AUTH_MFA_KEY`, `FD_NOTIFICATIONS_SECRET_KEY`, `FD_STORAGE_ENCRYPTION_KEY`, `FD_CONNECTORS_SECRET_KEY`, `FD_AUDIT_ANCHOR_KEY`). A database backup without the matching keys restores rows nobody can read, and rotating a key without re-encrypting orphans everything encrypted under the old one. Back the keys up separately, restore them together with the dump, and record which key version a dump belongs to.
87
+ Production restores run `database.restore.production`: an approval grant bound to the exact flags, `--target` equal to the migrator DSN database, a separate migrator DSN, a key mismatch refused unless the approval included `--allow-key-mismatch`, and a refusal while the health endpoint answers. PITR for the compose stack is `infra/docker/pitr.sh` (see `infra/README.md`); in Kubernetes it is the managed provider's job.
88
+
89
+ The trap: **the encryption keys live outside the database.** Agent provider credentials, agent run grants, workflow payloads and cursors, automation secrets, MFA secrets, stored objects and connector credentials are all stored as ciphertext, and the keys are environment variables (`FD_AGENT_CREDENTIAL_KEY`, `FD_AGENT_RUN_GRANT_KEY`, `FD_WORKFLOWS_PAYLOAD_KEY`, `FD_WORKFLOWS_CURSOR_KEY`, `FD_AUTOMATIONS_CREDENTIAL_KEY`, `FD_AUTH_MFA_KEY`, `FD_NOTIFICATIONS_SECRET_KEY`, `FD_STORAGE_ENCRYPTION_KEY`, `FD_CONNECTORS_SECRET_KEY`, `FD_AUDIT_ANCHOR_KEY`). A database backup without the matching keys restores rows nobody can read, and rotating a key without re-encrypting orphans everything encrypted under the old one; every sealing key has a `secrets-rotate` command in the runbook's rotation table, the storage key two (`documents` and `exports`). Back the keys up separately, restore them together with the dump, and record which key version a dump belongs to.
87
90
 
88
91
  ## 6. Rollback
89
92
 
@@ -92,6 +95,8 @@ The trap: **the encryption keys live outside the database.** Agent provider cred
92
95
  3. To take one module out of service without a redeploy: `pnpm flowdular module disable <id> --apply` (it is a dry run without `--apply`), then rebuild the composition and redeploy. Its tables stay.
93
96
  4. If the rollback is because of a key change, restore the previous key first; the image alone will not fix unreadable ciphertext.
94
97
 
98
+ - For data loss between two dumps use PITR (`pitr.sh restore --target-time`), never a `.down.sql`.
99
+
95
100
  ## 7. Production checklist
96
101
 
97
102
  - `pnpm verify` and `pnpm build` pass on the commit being shipped (the image build runs both).
@@ -103,7 +108,7 @@ The trap: **the encryption keys live outside the database.** Agent provider cred
103
108
  - Sign-up closed (`FD_AUTH_ALLOW_SIGN_UP`) unless the deployment is public; the first owner created with `flowdular auth workspace-create` (see `docs/cli.md`).
104
109
  - Liveness on `/api/health`, readiness on `/api/ready`.
105
110
  - `pnpm flowdular migration verify` clean after rollout, and `pnpm flowdular doctor --json` reports `status: healthy`.
106
- - A restore has been rehearsed once, keys included.
111
+ - A restore has been rehearsed once, keys included, and a PITR restore once from the newest base backup.
107
112
 
108
113
  ## Pitfalls
109
114
 
@@ -0,0 +1,4 @@
1
+ -- The issuance ledger carries no data any other table depends on.
2
+ DROP POLICY IF EXISTS approvals_audit_tenant_policy ON approvals_audit;
3
+ DROP INDEX IF EXISTS approvals_audit_request_idx;
4
+ DROP TABLE IF EXISTS approvals_audit;
@@ -0,0 +1,20 @@
1
+ -- The grant an approved request yields is derived from the row and the key on
2
+ -- every read, so nothing about the token itself is stored. What is stored is
3
+ -- the fact of its issuance: one row per resolved request whose subject names
4
+ -- a capability, with the capability, the input digest, the key that signs and
5
+ -- the moment the grant stops verifying, written in the deciding transaction.
6
+ CREATE TABLE IF NOT EXISTS approvals_audit (
7
+ id TEXT PRIMARY KEY,
8
+ tenant_id TEXT NOT NULL,
9
+ request_id TEXT NOT NULL,
10
+ action TEXT NOT NULL,
11
+ metadata_json TEXT NOT NULL,
12
+ occurred_at BIGINT NOT NULL
13
+ );
14
+ CREATE INDEX IF NOT EXISTS approvals_audit_request_idx
15
+ ON approvals_audit (tenant_id, request_id, occurred_at, id);
16
+ ALTER TABLE approvals_audit ENABLE ROW LEVEL SECURITY;
17
+ ALTER TABLE approvals_audit FORCE ROW LEVEL SECURITY;
18
+ CREATE POLICY approvals_audit_tenant_policy ON approvals_audit
19
+ USING (tenant_id = current_setting('coreloom.tenant_id', true))
20
+ WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "approvals.core",
5
5
  "package": "@flowdular/module-approvals",
6
- "version": "0.1.13",
6
+ "version": "0.1.14",
7
7
  "platformApi": "^0.1.0",
8
8
  "profile": "full",
9
9
  "capabilities": ["api", "database", "client", "translations"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-approvals",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  schemaVersion: 2
2
2
  id: approvals.core
3
- specVersion: 0.1.13
3
+ specVersion: 0.1.14
4
4
  status: approved
5
5
  name: Approvals Core
6
6
  description: Turns a policy decision that needs a person into an approval request with named requirements, a decision record that is append-only, notifications to the people who can decide, and a workflow node that waits for the decision, so every module delegates who may decide instead of writing its own approval state machine.
@@ -33,6 +33,7 @@ invariants:
33
33
  - A requirement naming both a role key and a scope is satisfied only by members holding both; a requirement that resolves to no eligible decider, to fewer deciders than the decisions it needs, or to more than 200 deciders is refused at open with a stable code and no request is created.
34
34
  - A member who gains eligibility after a request opened may decide it (eligibility is re-read at decision time) but is not listed as a decider, because the decider list is the snapshot taken at open.
35
35
  - approvals.core declares one data class to the platform registry while it composes, and performs its operations itself on its own lease under its own tenant transaction. approvals.core.requests holds the request row, the eligibility snapshot taken when it opened and every decision recorded on it, keeps them 400 days by default, sweeps only resolved requests in bounded batches so a request still asking is never taken from the people who have to answer it however old it is, and exports one workspace by keyset in bounded pages carrying each request with its decisions and their comments. An erasure removes the resolved requests the subject opened with their eligibility and decision rows, and keeps but redacts every row naming the subject in somebody else's request, a decision as well as an eligibility row of a pending request, writing a fixed marker over a comment and a tombstone unique to the row over the account, because the ledger is append-only and one fixed account value would collide with the unique decider index, or with the eligibility key, as soon as two erased subjects had answered or been eligible for the same request; the remaining deciders keep their own rows and no account of the subject survives; the answer counts the removals and the redactions apart, removed for the rows that went and redacted for the rows that stayed without the subject in them, and both fill the batch the run gave it so a pass that only redacted still moves the run on; the count answers every request the subject opened in any state.
36
+ - A request whose subject reference encodes a capability id and an input digest (the prefix capability, the id and the sha256 hex digest, separated by colons, within the subjectRef bound) yields, once approved, a grant signed with HMAC-SHA256 under FD_APPROVAL_GRANT_KEY that names the workspace, the capability, the digest, the request and a one hour window from the approving decision; the grant is derived from the row and the current key on every read of the grant operation of approvals.requests.v1, handed only to the subject module that opened the request and never stored, a previous key kept in FD_APPROVAL_GRANT_KEY_PREVIOUS still verifies grants it signed, no grant is yielded for a rejected, cancelled or expired request or once the window closed, the approving transaction writes one grant.issued row to the issuance ledger with the capability, the digest, the signing key id and the expiry, the agent harness admits one tool call per grant within a run and refuses the repeat, and the CLI runner, which runs outside the platform and records no use, accepts the same grant for the same invocation until the window closes.
36
37
  - Whether a member may read a request is settled before its state is reported. A member who is neither the requester, nor named by the eligibility snapshot, nor eligible now, nor holding approvals.requests.manage is answered as if the request did not exist, on the read, on a decision and on a cancellation alike, so none of them reveals that the request exists or what became of it.
37
38
  permissions:
38
39
  - id: approvals.requests.read
@@ -42,7 +43,7 @@ permissions:
42
43
  - id: approvals.requests.manage
43
44
  description: Read every request of the active workspace and cancel a pending one.
44
45
  dataOwnership:
45
- - approvals.core owns approval requests, resolved eligibility snapshots and decision rows.
46
+ - approvals.core owns approval requests, resolved eligibility snapshots, decision rows and the grant issuance ledger.
46
47
  - The subject module owns the record the request gates and its own domain state; it changes that state only after reading the request's terminal state.
47
48
  - auth.core owns roles and memberships; eligibility is resolved through its public surface at creation and at decision time.
48
49
  entities:
@@ -267,6 +268,10 @@ decisions:
267
268
  question: Which data classes does approvals.core declare, how long are they kept, and what does an erasure leave behind?
268
269
  answer: One class, approvals.core.requests, covering requests, their eligibility rows and their decisions, kept 400 days by default. The sweep takes resolved requests only, with their eligibility and decision rows; a pending request is never swept however old it is. The export pages requests with their decisions and comments, keyset by id. An erasure removes the resolved requests the subject opened with their rows, and redacts rather than removes the rows naming the subject in somebody else's request, writing a fixed marker over a decision's comment and a per-row tombstone over the account on that decision and on the eligibility rows, in pending requests as well as resolved ones. The append-only ledger keeps its row count, a snapshot goes on reporting how many people could answer, the people who can still answer keep their own rows, and no account of the subject survives anywhere. The count answers how many requests the subject opened, in any state.
269
270
  decidedBy: default
271
+ - id: D-APPROVALS-GRANT
272
+ question: How does an approved request let the CLI runner or the harness run an external or destructive capability?
273
+ answer: The subject reference encodes the capability id and the digest of the exact invocation input; once approved the request yields a signed grant bound to the workspace, the capability, the digest and a one hour window, which the runner takes as the --grant flag and the harness as the run's grants. The token is read through the grant operation by the subject module alone, derived on read from the row and FD_APPROVAL_GRANT_KEY rather than stored, and the issuance is written to the ledger in the approving transaction; the harness spends a grant on one call, the runner cannot record use and is bounded by the window.
274
+ decidedBy: default
270
275
  acceptanceScenarios:
271
276
  - id: APPROVALS-OPEN
272
277
  given: A subject module needs a person to agree to an action on one of its records, with a role requirement and a count of 1.
@@ -312,6 +317,10 @@ acceptanceScenarios:
312
317
  given: A workspace holds more requests than one page of the inbox carries.
313
318
  when: A member pages through the inbox under a scope and a status filter.
314
319
  then: A page carries at most 200 requests (50 unless the request asks for more), ordered by createdAt in the requested direction with the id as the tie-break, and a full page answers an opaque cursor bound to the workspace, the member, the sort and the filters, so a cursor used under another workspace, sort or filter is refused rather than reinterpreted.
320
+ - id: APPROVALS-GRANT
321
+ given: A workspace with FD_APPROVAL_GRANT_KEY configured and a request opened with a subject reference that encodes a capability id and an input digest.
322
+ when: An eligible member approves it, another such request is rejected, a third cancelled and a fourth left to expire, and the key is later rotated with the old key kept as previous.
323
+ then: Reading the grant of the approved request through the capability as the subject module answers a grant that verifies for that workspace, capability and digest until one hour after the approving decision, the same token on every read under the same key and a token under the new key after the rotation that the rotated ring still verifies, the ledger carries one grant.issued row written with the approval naming the capability, the digest, the signing key id and the expiry, the rejected, cancelled and expired requests and an approved request whose subject names a record answer no grant and no ledger row, another module naming the request is answered no grant, and the retention sweep removes the ledger rows with the request.
315
324
  - id: APPROVALS-TENANT-BOUNDARY
316
325
  given: Two workspaces with requests and decisions.
317
326
  when: A runtime transaction bound to one workspace reads or writes any of them, or the expiry routing read runs.
@@ -50,6 +50,18 @@ export interface ApprovalsRequests {
50
50
  */
51
51
  open(input: OpenApprovalInput): Promise<ApprovalRequest>;
52
52
  get(tenantId: string, id: string): Promise<ApprovalRequest | null>;
53
+ /**
54
+ * The signed token an approved request yields while its window is open:
55
+ * what the CLI runner takes as `--grant` and the harness as `grants`. Only
56
+ * a request whose `subjectRef` encodes a capability and an input digest
57
+ * (`encodeCapabilitySubjectRef`) yields one, and only to the module that
58
+ * opened it: `subjectModule` has to match the request's. Null otherwise.
59
+ */
60
+ grant(
61
+ tenantId: string,
62
+ id: string,
63
+ subjectModule: string,
64
+ ): Promise<string | null>;
53
65
  /**
54
66
  * The first `limit` matches, newest first; two requests opened at the same
55
67
  * millisecond come in descending id order.
@@ -0,0 +1,69 @@
1
+ import { APPROVAL_GRANT_INPUT_DIGEST_LENGTH } from '@flowdular/sdk/kernel';
2
+ import { APPROVAL_LIMITS } from './capability.ts';
3
+
4
+ /**
5
+ * How long after the approving decision the grant an approved request yields
6
+ * still verifies. The token is derived from the row and the key on every read,
7
+ * so the window is measured from `resolvedAt`, not from the read.
8
+ */
9
+ export const APPROVAL_GRANT_TTL_MS = 60 * 60 * 1_000;
10
+
11
+ const SUBJECT_PREFIX = 'capability:';
12
+ const CAPABILITY_ID = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
13
+ const INPUT_DIGEST = new RegExp(
14
+ `^[a-f0-9]{${APPROVAL_GRANT_INPUT_DIGEST_LENGTH}}$`,
15
+ );
16
+ /* Prefix, id, one separator and the digest have to fit the subjectRef bound. */
17
+ export const APPROVAL_GRANT_CAPABILITY_ID_LENGTH =
18
+ APPROVAL_LIMITS.subjectRef -
19
+ SUBJECT_PREFIX.length -
20
+ 1 -
21
+ APPROVAL_GRANT_INPUT_DIGEST_LENGTH;
22
+
23
+ /** The capability and the exact input an approval is asked for. */
24
+ export interface CapabilitySubject {
25
+ readonly capabilityId: string;
26
+ /** `approvalInputDigest` of the invocation input, as the runner computes it. */
27
+ readonly inputDigest: string;
28
+ }
29
+
30
+ /**
31
+ * A `subjectRef` of the form `capability:<capabilityId>:<inputDigest>`. A
32
+ * request opened with it yields, once approved, a signed grant for exactly
33
+ * that capability and that input.
34
+ */
35
+ export function encodeCapabilitySubjectRef(subject: CapabilitySubject): string {
36
+ if (
37
+ !CAPABILITY_ID.test(subject.capabilityId) ||
38
+ subject.capabilityId.length > APPROVAL_GRANT_CAPABILITY_ID_LENGTH
39
+ ) {
40
+ throw new RangeError(
41
+ `capabilityId must be a lowercase dot-separated identifier of at most ${APPROVAL_GRANT_CAPABILITY_ID_LENGTH} characters.`,
42
+ );
43
+ }
44
+ if (!INPUT_DIGEST.test(subject.inputDigest)) {
45
+ throw new RangeError(
46
+ `inputDigest must be ${APPROVAL_GRANT_INPUT_DIGEST_LENGTH} lowercase hex characters.`,
47
+ );
48
+ }
49
+ return `${SUBJECT_PREFIX}${subject.capabilityId}:${subject.inputDigest}`;
50
+ }
51
+
52
+ /** Null for a subject reference that names a record rather than a capability. */
53
+ export function decodeCapabilitySubjectRef(
54
+ subjectRef: string,
55
+ ): CapabilitySubject | null {
56
+ if (!subjectRef.startsWith(SUBJECT_PREFIX)) return null;
57
+ const separator = subjectRef.lastIndexOf(':');
58
+ const capabilityId = subjectRef.slice(SUBJECT_PREFIX.length, separator);
59
+ const inputDigest = subjectRef.slice(separator + 1);
60
+ if (
61
+ separator <= SUBJECT_PREFIX.length ||
62
+ !CAPABILITY_ID.test(capabilityId) ||
63
+ capabilityId.length > APPROVAL_GRANT_CAPABILITY_ID_LENGTH ||
64
+ !INPUT_DIGEST.test(inputDigest)
65
+ ) {
66
+ return null;
67
+ }
68
+ return { capabilityId, inputDigest };
69
+ }
@@ -76,6 +76,20 @@ export interface ApprovalDecision {
76
76
  readonly decidedAt: number;
77
77
  }
78
78
 
79
+ /** What the ledger records about a request beyond its decisions. */
80
+ export const APPROVAL_AUDIT_ACTIONS = ['grant.issued'] as const;
81
+
82
+ export type ApprovalAuditAction = (typeof APPROVAL_AUDIT_ACTIONS)[number];
83
+
84
+ export interface ApprovalAuditEntry {
85
+ readonly id: string;
86
+ readonly tenantId: string;
87
+ readonly requestId: string;
88
+ readonly action: ApprovalAuditAction;
89
+ readonly metadata: Readonly<Record<string, string | number>>;
90
+ readonly occurredAt: number;
91
+ }
92
+
79
93
  /** What an erasure writes over the comment of a decision it keeps. */
80
94
  export const ERASED_DECISION_COMMENT = '[erased]';
81
95
 
@@ -30,6 +30,13 @@ export type {
30
30
  ApprovalsRequests,
31
31
  OpenApprovalInput,
32
32
  } from './domain/capability.ts';
33
+ export {
34
+ APPROVAL_GRANT_CAPABILITY_ID_LENGTH,
35
+ APPROVAL_GRANT_TTL_MS,
36
+ decodeCapabilitySubjectRef,
37
+ encodeCapabilitySubjectRef,
38
+ } from './domain/grant.ts';
39
+ export type { CapabilitySubject } from './domain/grant.ts';
33
40
 
34
41
  export {
35
42
  APPROVAL_DECISIONS,
@@ -38,6 +45,8 @@ export {
38
45
  isTerminalApprovalStatus,
39
46
  } from './domain/types.ts';
40
47
  export type {
48
+ ApprovalAuditAction,
49
+ ApprovalAuditEntry,
41
50
  ApprovalDecision,
42
51
  ApprovalDecisionKind,
43
52
  ApprovalMember,
@@ -1,3 +1,4 @@
1
+ import { approvalGrantKeyringFromEnvironment } from '@flowdular/sdk/kernel';
1
2
  import type {
2
3
  PlatformServerComposition,
3
4
  PlatformServerContext,
@@ -96,6 +97,9 @@ export function createServerComposition(
96
97
  context.capabilities.get<NotificationPublisher>(
97
98
  NOTIFICATIONS_PUBLISH_CAPABILITY,
98
99
  ),
100
+ /* Without FD_APPROVAL_GRANT_KEY an approved capability request yields no
101
+ grant, and the runner and the harness keep refusing the capability. */
102
+ grants: approvalGrantKeyringFromEnvironment(context.environment),
99
103
  });
100
104
  /* The runtime opens its database leases lazily, so the capability is a
101
105
  forwarder rather than a resolved object: registration must not force a
@@ -106,6 +110,10 @@ export function createServerComposition(
106
110
  open: async (input) => (await runtime.service()).capability().open(input),
107
111
  get: async (tenantId, id) =>
108
112
  (await runtime.service()).capability().get(tenantId, id),
113
+ grant: async (tenantId, id, subjectModule) =>
114
+ (await runtime.service())
115
+ .capability()
116
+ .grant(tenantId, id, subjectModule),
109
117
  list: async (tenantId, filter) =>
110
118
  (await runtime.service()).capability().list(tenantId, filter),
111
119
  cancel: async (tenantId, id, actorAccountId) =>
@@ -7,6 +7,7 @@ import {
7
7
  DATABASE_CAPABILITY_IDS,
8
8
  DATABASE_DIALECT_IDS,
9
9
  } from '@flowdular/sdk/database';
10
+ import type { ApprovalGrantKeyring } from '@flowdular/sdk/kernel';
10
11
  import type { JobRunner } from '@flowdular/sdk/server';
11
12
  import type { ApprovalMember } from '../domain/types.ts';
12
13
  import {
@@ -42,6 +43,8 @@ export interface ApprovalsRuntimeOptions {
42
43
  readonly notifications?: NotificationPublisherResolver;
43
44
  readonly callbacks?: ApprovalCallbackRegistry;
44
45
  readonly repository?: ApprovalsRepository;
46
+ /** Signs the grant an approved capability request yields; absent means none. */
47
+ readonly grants?: ApprovalGrantKeyring | undefined;
45
48
  readonly now?: () => number;
46
49
  }
47
50
 
@@ -122,6 +125,7 @@ export function createApprovalsRuntime(
122
125
  ...(options.notifications
123
126
  ? { notifications: options.notifications }
124
127
  : {}),
128
+ ...(options.grants ? { grants: options.grants } : {}),
125
129
  ...(options.now ? { now: options.now } : {}),
126
130
  }));
127
131
 
@@ -1,12 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import {
3
+ issueApprovalGrant,
4
+ type ApprovalGrantKeyring,
5
+ } from '@flowdular/sdk/kernel';
2
6
  import {
3
7
  APPROVAL_LIMITS,
4
8
  type ApprovalRequestFilter,
5
9
  type ApprovalsRequests,
6
10
  type OpenApprovalInput,
7
11
  } from '../domain/capability.ts';
12
+ import {
13
+ APPROVAL_GRANT_TTL_MS,
14
+ decodeCapabilitySubjectRef,
15
+ } from '../domain/grant.ts';
8
16
  import {
9
17
  APPROVAL_STATUSES,
18
+ type ApprovalAuditEntry,
10
19
  type ApprovalDecideOutcome,
11
20
  type ApprovalDecision,
12
21
  type ApprovalListPage,
@@ -61,6 +70,8 @@ export interface ApprovalsServiceOptions {
61
70
  readonly defaultExpiryDays: (tenantId: string) => number | Promise<number>;
62
71
  readonly notifications?: NotificationPublisherResolver;
63
72
  readonly callbacks?: ApprovalCallbackRegistry;
73
+ /** Signs the grant an approved capability request yields; absent means none is issued. */
74
+ readonly grants?: ApprovalGrantKeyring | undefined;
64
75
  readonly now?: () => number;
65
76
  }
66
77
 
@@ -71,6 +82,7 @@ export class ApprovalsService {
71
82
  readonly #defaultExpiryDays: ApprovalsServiceOptions['defaultExpiryDays'];
72
83
  readonly #notifications: NotificationPublisherResolver | undefined;
73
84
  readonly #callbacks: ApprovalCallbackRegistry;
85
+ readonly #grants: ApprovalGrantKeyring | undefined;
74
86
  readonly #now: () => number;
75
87
 
76
88
  constructor(options: ApprovalsServiceOptions) {
@@ -80,6 +92,7 @@ export class ApprovalsService {
80
92
  this.#defaultExpiryDays = options.defaultExpiryDays;
81
93
  this.#notifications = options.notifications;
82
94
  this.#callbacks = options.callbacks ?? createApprovalCallbackRegistry();
95
+ this.#grants = options.grants;
83
96
  this.#now = options.now ?? Date.now;
84
97
  }
85
98
 
@@ -186,6 +199,48 @@ export class ApprovalsService {
186
199
  );
187
200
  }
188
201
 
202
+ /**
203
+ * The grant an approved capability request yields, derived from the row and
204
+ * the current key on every read: the same claims sign to the same token
205
+ * under the same key, and a rotated key signs a fresh one that verifies the
206
+ * same. Nothing is issued for any other state, or once the window closed.
207
+ */
208
+ grantFor(request: ApprovalRequest): string | undefined {
209
+ if (!this.#grants || request.status !== 'approved') return undefined;
210
+ if (request.resolvedAt === null) return undefined;
211
+ const subject = decodeCapabilitySubjectRef(request.subjectRef);
212
+ if (!subject) return undefined;
213
+ const expiresAt = request.resolvedAt + APPROVAL_GRANT_TTL_MS;
214
+ if (expiresAt <= this.#now()) return undefined;
215
+ return issueApprovalGrant(this.#grants, {
216
+ tenantId: request.tenantId,
217
+ capabilityId: subject.capabilityId,
218
+ inputDigest: subject.inputDigest,
219
+ requestId: request.id,
220
+ issuedAt: request.resolvedAt,
221
+ expiresAt,
222
+ nonce: request.id,
223
+ }).token;
224
+ }
225
+
226
+ /* The token reaches only the module that asked the question: a holder of
227
+ the capability naming another module's request is answered null. */
228
+ async grant(
229
+ tenantId: string,
230
+ id: string,
231
+ subjectModule: string,
232
+ ): Promise<string | null> {
233
+ const module = bounded(
234
+ subjectModule,
235
+ 'subjectModule',
236
+ 1,
237
+ APPROVAL_LIMITS.subjectModule,
238
+ );
239
+ const request = await this.get(tenantId, id);
240
+ if (!request || request.subjectModule !== module) return null;
241
+ return this.grantFor(request) ?? null;
242
+ }
243
+
189
244
  async detail(
190
245
  tenantId: string,
191
246
  id: string,
@@ -458,6 +513,8 @@ export class ApprovalsService {
458
513
  return {
459
514
  open: (input) => this.open(input),
460
515
  get: (tenantId, id) => this.get(tenantId, id),
516
+ grant: (tenantId, id, subjectModule) =>
517
+ this.grant(tenantId, id, subjectModule),
461
518
  list: (tenantId, filter) => this.list(tenantId, filter),
462
519
  cancel: async (tenantId, id, actorAccountId) =>
463
520
  (await this.cancel(tenantId, id, actorAccountId)).request,
@@ -476,6 +533,28 @@ export class ApprovalsService {
476
533
  return request;
477
534
  }
478
535
 
536
+ /* The moment a capability request is approved is the moment its grant can
537
+ be read, so that is what the ledger records, under the deciding
538
+ transaction, with what the grant will say and which key signs it. */
539
+ #grantIssued(resolved: ApprovalRequest): ApprovalAuditEntry | null {
540
+ if (!this.#grants || resolved.status !== 'approved') return null;
541
+ const subject = decodeCapabilitySubjectRef(resolved.subjectRef);
542
+ if (!subject || resolved.resolvedAt === null) return null;
543
+ return {
544
+ id: randomUUID(),
545
+ tenantId: resolved.tenantId,
546
+ requestId: resolved.id,
547
+ action: 'grant.issued',
548
+ metadata: {
549
+ capabilityId: subject.capabilityId,
550
+ inputDigest: subject.inputDigest,
551
+ keyId: this.#grants.keyId,
552
+ expiresAt: resolved.resolvedAt + APPROVAL_GRANT_TTL_MS,
553
+ },
554
+ occurredAt: resolved.resolvedAt,
555
+ };
556
+ }
557
+
479
558
  /* Only a member who may read the request is told its state; every caller
480
559
  settles readability before this runs. */
481
560
  #assertPending(request: ApprovalRequest): void {
@@ -512,6 +591,7 @@ export class ApprovalsService {
512
591
  },
513
592
  resolve,
514
593
  resolvedAt: decidedAt,
594
+ audit: (resolved) => this.#grantIssued(resolved),
515
595
  });
516
596
  if (result.outcome === 'not-found') {
517
597
  throw new ApprovalsServiceError(