@sanoma-ai/workflows 0.2.0 → 0.4.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 (149) hide show
  1. package/README.md +146 -85
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +14 -5
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/fill.d.ts +4 -0
  51. package/dist/fill.js +13 -0
  52. package/dist/fill.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.js +10 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/ledger.d.ts +12 -1
  57. package/dist/ledger.js +18 -11
  58. package/dist/ledger.js.map +1 -1
  59. package/dist/lint.d.ts +5 -4
  60. package/dist/lint.js +24 -7
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +6 -0
  63. package/dist/op.js.map +1 -1
  64. package/dist/outline.d.ts +2 -0
  65. package/dist/outline.js +1 -1
  66. package/dist/outline.js.map +1 -1
  67. package/dist/paths.d.ts +4 -0
  68. package/dist/paths.js +17 -3
  69. package/dist/paths.js.map +1 -1
  70. package/dist/person.d.ts +61 -0
  71. package/dist/person.js +69 -0
  72. package/dist/person.js.map +1 -0
  73. package/dist/plugin.d.ts +28 -0
  74. package/dist/plugin.js +52 -1
  75. package/dist/plugin.js.map +1 -1
  76. package/dist/policy.d.ts +51 -55
  77. package/dist/policy.js +14 -36
  78. package/dist/policy.js.map +1 -1
  79. package/dist/resource.d.ts +21 -5
  80. package/dist/resource.js +12 -3
  81. package/dist/resource.js.map +1 -1
  82. package/dist/resources.d.ts +58 -6
  83. package/dist/resources.js +119 -26
  84. package/dist/resources.js.map +1 -1
  85. package/dist/run.d.ts +17 -1
  86. package/dist/run.js.map +1 -1
  87. package/dist/scenario.d.ts +16 -9
  88. package/dist/scenario.js +149 -44
  89. package/dist/scenario.js.map +1 -1
  90. package/dist/scenarios.d.ts +10 -6
  91. package/dist/scenarios.js +8 -4
  92. package/dist/scenarios.js.map +1 -1
  93. package/dist/schema.d.ts +32 -0
  94. package/dist/schema.js +225 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/sdk.d.ts +34 -0
  97. package/dist/sdk.js +168 -0
  98. package/dist/sdk.js.map +1 -0
  99. package/dist/shared.d.ts +181 -5
  100. package/dist/shared.js +160 -7
  101. package/dist/shared.js.map +1 -1
  102. package/dist/sites.d.ts +52 -0
  103. package/dist/sites.js +174 -0
  104. package/dist/sites.js.map +1 -0
  105. package/dist/version.d.ts +3 -2
  106. package/dist/version.js +3 -2
  107. package/dist/version.js.map +1 -1
  108. package/dist/worker.d.ts +3 -2
  109. package/dist/worker.js +24 -4
  110. package/dist/worker.js.map +1 -1
  111. package/oxlint.json +7 -20
  112. package/package.json +8 -3
  113. package/src/approvals.ts +49 -13
  114. package/src/call-workflow.ts +44 -0
  115. package/src/call.ts +157 -45
  116. package/src/cedar.ts +493 -0
  117. package/src/client.ts +368 -37
  118. package/src/collection.ts +431 -0
  119. package/src/config.ts +131 -34
  120. package/src/constructors.ts +4 -0
  121. package/src/data.ts +10 -0
  122. package/src/datafile.ts +141 -21
  123. package/src/db.ts +90 -22
  124. package/src/define.ts +15 -10
  125. package/src/describe.ts +113 -10
  126. package/src/direct.ts +15 -0
  127. package/src/errors.ts +36 -1
  128. package/src/expectations.ts +17 -8
  129. package/src/fill.ts +15 -0
  130. package/src/index.ts +33 -2
  131. package/src/ledger.ts +35 -17
  132. package/src/lint.ts +29 -12
  133. package/src/op.ts +6 -0
  134. package/src/outline.ts +1 -1
  135. package/src/paths.ts +18 -3
  136. package/src/person.ts +75 -0
  137. package/src/plugin.ts +74 -5
  138. package/src/policy.ts +59 -63
  139. package/src/resource.ts +35 -8
  140. package/src/resources.ts +165 -24
  141. package/src/run.ts +18 -1
  142. package/src/scenario.ts +186 -57
  143. package/src/scenarios.ts +15 -8
  144. package/src/schema.ts +261 -0
  145. package/src/sdk.ts +211 -0
  146. package/src/shared.ts +271 -8
  147. package/src/sites.ts +220 -0
  148. package/src/version.ts +3 -2
  149. package/src/worker.ts +29 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @sanoma-ai/workflows
2
2
 
3
- Business processes as TypeScript functions, run durably on [DBOS](https://dbos.dev) with Postgres. Each vendor call is a recorded step, and approvals and sleeps survive worker restarts. An oxlint config and `lintWorkflow` guard workflow and policy code against accidental non-determinism and accidental ways around the policy. They are lint, not a sandbox: code written to get around them can.
3
+ Business processes as TypeScript functions, run durably on [DBOS](https://dbos.dev) with Postgres. Each vendor call is a recorded step, and approvals and sleeps survive worker restarts. An oxlint config and `lintWorkflow` guard workflow code against accidental non-determinism and accidental ways around the policy. They are lint, not a sandbox: code written to get around them can.
4
4
 
5
5
  ## Usage
6
6
 
@@ -51,60 +51,28 @@ export const shop = withSetup(defineConnector("shop", specs, info), {
51
51
 
52
52
  `withSetup` lays what it is given over what the connector already carries, and keeps it on the connector's vendor record, so a spread copy of the connector keeps it too. A config that needs a driver made otherwise (another bridge, other options) swaps the driver and keeps the fake: `withSetup(github, { driver: () => githubDriver({ bridge }) })`. It is for connector packages and configs, not for workflows, which the lint does not let import it. Both are optional: a connector without a driver declares its operations and implements none, so a workflow using one is refused; a connector without a fake has none for sandbox runs, which the app's connector page says.
53
53
 
54
- A policy is checked before every operation call, and a ledger records what happened.
54
+ A config names the workflows and the connectors:
55
55
 
56
56
  ```ts
57
- import { allow, approve, approvedFor, defineConfig, definePolicy } from "@sanoma-ai/workflows";
58
-
59
- const policy = definePolicy(
60
- ({ op, effect, run }) => {
61
- if (effect !== "money") return allow();
62
- // Approved by finance-lead for this operation: not any approval, and not one a workflow
63
- // addressed to someone else.
64
- return approvedFor(run.approvals, op.id, "finance-lead") ? allow() : approve("finance-lead");
65
- },
66
- { version: "2026-10-07" },
67
- );
57
+ import { defineConfig } from "@sanoma-ai/workflows";
68
58
 
69
59
  export default defineConfig({
70
60
  workflows: [refund],
71
61
  connectors: [shop],
72
- policy,
73
62
  });
74
63
  ```
75
64
 
76
- That is the whole config; the rest is convention, from its `root` (`defineConfig`'s call site's directory, or `root` where that is not the source's, as in a bundle). Two conventions give what a config no longer says:
65
+ That is the whole config; the rest is convention, from its `root` (`defineConfig`'s call site's directory, or `root` where that is not the source's, as in a bundle). Three conventions give what a config no longer says:
77
66
 
78
67
  - **The app's name** is the `name` in `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names one; otherwise, and for a config with no root, it is `"sanoma"`. Only the root's own file counts, never one above it, so a parent project's name never becomes the app's; a `package.json` that is not JSON is refused, naming it. The name scopes the app's runs, queues and versions in its database.
79
68
  - **The [scenarios](#scenarios-and-sandbox-runs)** are `<root>/scenarios/` when that is a directory; otherwise the config has none.
69
+ - **The [policy](#policies)** is `<root>/policies/**/*.cedar`, decided as the [people](#people) the data files declare; without any, every call and start is allowed (`allowAll`).
80
70
 
81
- The [data files](#data-files) are `resources/` in the root. `ledger` and `databaseUrl` are the only other fields. `drivers`, `fakes`, `appName` and `scenarios` are typed `never` on `SanomaConfig`, each deprecated with where it went, so the compiler and the editor say so; an untyped config that still names `drivers` or `fakes` is refused at run time rather than run live on what it no longer reads.
71
+ The [data files](#data-files) are `resources/` in the root. `ledger` and `databaseUrl` are the only other fields. `drivers`, `fakes`, `appName`, `scenarios` and `policy` are typed `never` on `SanomaConfig`, each deprecated with where it went, so the compiler and the editor say so; an untyped config that still names `drivers` or `fakes` is refused at run time rather than run live on what it no longer reads, and one that names `policy` rather than run allowing everything.
82
72
 
83
- A config must name its policy. `policy: allowAll` allows every operation call, and says so. The audit record goes to the ledger, by default `postgresLedger(databaseUrl)` on the config's database (see [Ledger](#ledger)); `ledger: jsonlLedger(dir)` keeps it in files instead, and `memoryLedger()` in this process only, for tests. `resolveConfig(config)` checks a config and derives what the runtime uses from it: the app name, database, version and queue (`sanoma:<appName>`, for every run, [sandbox runs](#scenarios-and-sandbox-runs) included), the operations by id, the driver each vendor's connector attaches, made once for the resolution (`driversByVendor`, in `connectors` order, and their functions by operation id, `drivers`), the makers of their fakes (`fakes`, by vendor), the scenarios directory, and the config's `ledger` when it gives one; it opens nothing and reads no file but the root's `package.json` and whether `scenarios/` is a directory. It throws, with one message, whatever the worker would refuse: a workflow or driver naming an operation the `connectors` don't declare, a workflow redeclaring an operation with another effect, an operation with no driver (`` `shop.order.refund` has no driver: the connector package must attach one ``), two different drivers or fakes for one vendor, two workflows with one name, no policy, a `ledger` that is not a store, a `drivers` or `fakes` an untyped config still names. `startWorker`, `SanomaClient.connect` and `describeConfig` all call it, so they fail the same way. It adds the built-in [`drift`](#drift) and [`apply`](#apply) workflows when the connectors declare resource types. The worker takes each operation's effect, schemas and retry setting from `connectors`, never from the workflow.
73
+ The audit record goes to the ledger, by default `postgresLedger(databaseUrl)` on the config's database (see [Ledger](#ledger)); `ledger: jsonlLedger(dir)` keeps it in files instead, and `memoryLedger()` in this process only, for tests. `resolveConfig(config)` checks a config and derives what the runtime uses from it: the app name, database, version and queue (`sanoma:<appName>`, for every run, [sandbox runs](#scenarios-and-sandbox-runs) included), the operations by id, the driver each vendor's connector attaches, made once for the resolution (`driversByVendor`, in `connectors` order, and their functions by operation id, `drivers`), the makers of their fakes (`fakes`, by vendor), the scenarios directory, the policy (`policy`, with what is wrong in the policies as `policyProblems`), and the config's `ledger` when it gives one; it opens nothing and reads no file outside the root: its `package.json`, whether `scenarios/` is a directory, and the policies with the people files. It throws, with one message, whatever the worker would refuse: a workflow or driver naming an operation the `connectors` don't declare, a workflow redeclaring an operation with another effect, an operation with no driver (`` `shop.order.refund` has no driver: the connector package must attach one ``), two different drivers or fakes for one vendor, two workflows with one name, a `ledger` that is not a store, a `drivers`, `fakes` or `policy` an untyped config still names. `startWorker`, `SanomaClient.connect` and `describeConfig` all call it, so they fail the same way. It adds the built-in [`drift`](#drift) and [`apply`](#apply) workflows when the connectors declare resource types, and [`call`](#direct-calls) when an operation may be called directly. The worker takes each operation's effect, schemas and retry setting from `connectors`, never from the workflow.
84
74
 
85
- A policy returns `allow()`, `deny(reason)` (the run fails) or `approve(who)` (the run waits for that person, or for anyone in `{ group }`; see [Approvals](#approvals)). `allow(reasons)` and `deny(reason, reasons)` may add a list of reasons for whoever reads the ledger. The policy must decide the same way on every replay: no clock, randomness or network. What it sees is plain data, so a test can build one by hand, with `policyOpOf(op)` for the operation as the runtime passes it:
86
-
87
- ```ts
88
- interface PolicyCall {
89
- op: { id: string; vendor: string; resource: string; name: string; effect: Effect }; // no schemas
90
- effect: Effect; // the same as op.effect
91
- target?: string; // the instance the call acts on, when the operation declares `target`
92
- input: unknown; // checked against the operation's schema
93
- actor: Principal; // who started the run
94
- run: { id: string; workflow: string; approvals: readonly ApprovalState[] };
95
- }
96
-
97
- const call: PolicyCall = {
98
- op: policyOpOf(shop.order.refund),
99
- effect: "money",
100
- input: { id: "ord_1" },
101
- actor: { id: "alice" },
102
- run: { id: "r1", workflow: "refund", approvals: [] },
103
- };
104
- expect(policy(call)).toEqual(approve("finance-lead"));
105
- ```
106
-
107
- An operation names the instance it acts on with `target`, a function of its parsed input, so a policy can decide per post or per order rather than per operation. TypeScript types its input `any` there, so annotate it to have it checked:
75
+ An operation names the instance it acts on with `target`, a function of its parsed input, so a policy can decide per post or per order rather than per operation (its Cedar resource, `Sanoma::Target::"order/ord_1"`). TypeScript types its input `any` there, so annotate it to have it checked:
108
76
 
109
77
  ```ts
110
78
  const shop = defineConnector("shop", {
@@ -119,23 +87,60 @@ const shop = defineConnector("shop", {
119
87
  });
120
88
  ```
121
89
 
122
- A policy's answer is checked; anything else (`{ kind: "approve" }` with no approver, say) fails the run with a message naming the operation. The ledger gets one record for the start of the run, each operation call with its decision and the vendor's reply, each approval requested and decided (a policy's hold names the call it holds by its `op` and `opSeq`, the `seq` that call's own record has), each message an approval ignored (`approval.refused`: sent by someone who may not decide it, or not a decision), each sleep (`sleep.started` with `until`, when it ends in ms since the epoch: the time asked for, or for a duration, the time the sleep started plus the duration, as the runtime saw it; written for a time already past too), and how the run ended, in a JSONL file per run. A decision is recorded with its `reasons` and, for a policy from `definePolicy(fn, { version })`, its `policyVersion`. Every record carries `v: 1`, the `app` and the `actor`, and one written inside a `ctx.all` member its `group` (see [Built-ins](#built-ins)); each call's record (`op.called`, `approval.requested`, `sleep.started`) carries its step's `name` (see [Step names](#step-names)); an error is recorded as `{ code?, name, message }`, with `retryable` when the error says, the vendor's `status` and `vendorCode` only from a `DriverError`, and `data` only from the runtime's own errors (the operation, the approval, the input's issues), so an HTTP client's error that escapes a driver does not put the vendor's reply in the ledger. A ledger store's failed append is tried again up to three times; a store throws an error with `retryable: false` for a failure that would only repeat. When a call's record still cannot be written after the vendor replied, the call fails with "<op id> succeeded, but the ledger could not record it" and the run makes no further calls (`run_ended`), so a workflow that catches the failure cannot repeat the side effect unrecorded.
90
+ The ledger gets one record for the start of the run, each operation call with its decision and the vendor's reply, each approval requested and decided (a policy's hold names the call it holds by its `op` and `opSeq`, the `seq` that call's own record has), each message an approval ignored (`approval.refused`: sent by someone who may not decide it, or not a decision), each sleep (`sleep.started` with `until`, when it ends in ms since the epoch: the time asked for, or for a duration, the record's `at` plus the duration, as the runtime saw it; written for a time already past too), and how the run ended, in a JSONL file per run. A decision is recorded with its `reasons` (the ids of the policies behind it) and, with Cedar policies, their `policyVersion`, a hash of their text. Every record carries `v: 1`, the `app` and the `actor`, and one written inside a `ctx.all` member its `group` (see [Built-ins](#built-ins)); each call's record (`op.called`, `approval.requested`, `sleep.started`) carries its step's `name` (see [Step names](#step-names)); an error is recorded as `{ code?, name, message }`, with `retryable` when the error says, the vendor's `status` and `vendorCode` only from a `DriverError`, and `data` only from the runtime's own errors (the operation, the approval, the input's issues), so an HTTP client's error that escapes a driver does not put the vendor's reply in the ledger. A ledger store's failed append is tried again up to three times; a store throws an error with `retryable: false` for a failure that would only repeat. When a call's record still cannot be written after the vendor replied, the call fails with "<op id> succeeded, but the ledger could not record it" and the run makes no further calls (`run_ended`), so a workflow that catches the failure cannot repeat the side effect unrecorded.
123
91
 
124
- People are `Principal`s: `{ id, groups? }`. A run is started as one and a decision is sent as one:
92
+ People are `Principal`s: `{ id, groups?, onBehalfOf? }`. A run is started as one and a decision is sent as one, by id: the groups are the [people files'](#people), never the caller's.
125
93
 
126
94
  ```ts
127
95
  const client = await SanomaClient.connect(config);
128
- const runId = await client.start(refund, { id: "ord_1" }, { startedBy: { id: "alice", groups: ["support"] } });
96
+ const runId = await client.start(refund, { id: "ord_1" }, { startedBy: { id: "alice" } });
129
97
  const approval = await client.decide(runId, { decision: "approve", by: { id: "finance-lead" } }); // status: "approved"
130
98
  const run = await client.run(runId); // run.status: "queued" | "running" | "waiting" | "finished" | "failed" | "cancelled"
131
99
  ```
132
100
 
133
- `workflow` is a definition or the name of one in the config's `workflows` (`client.start("refund", …)`); a name the config does not have is refused with `invalid_input`, naming the ones it has. `startedBy` is required. `start(workflow, input, { startedBy, runId })` with a `runId` makes a retried start idempotent: an id that exists returns that run when the workflow, the input (as JSON) and `startedBy` match, and is refused with `invalid_input`, naming what differs, when they do not. Of two starts racing with one new id, the first stands and the second is answered the same way. Errors the runtime and the client throw carry a `code` (`policy_denied`, `approval_rejected`, `not_approver`, `no_pending_approval`, `already_decided`, `run_not_found`, `driver_failed`, `invalid_input`, `run_ended`, `run_running`: `client.result` timed out with the run still going) and `data`. Read it with `errorCode(err)`, not `instanceof`: a run's error comes back from the database as a copy, so `errorCode(await client.result(runId).catch((e) => e))` is `"policy_denied"` for a denied call.
101
+ `workflow` is a definition or the name of one in the config's `workflows` (`client.start("refund", …)`); a name the config does not have is refused with `invalid_input`, naming the ones it has. `startedBy` is required. `start(workflow, input, { startedBy, runId })` with a `runId` makes a retried start idempotent: an id that exists returns that run when the workflow, the input (as JSON) and `startedBy` match, and is refused with `invalid_input`, naming what differs, when they do not. Of two starts racing with one new id, the first stands and the second is answered the same way. Errors the runtime and the client throw carry a `code` (`policy_denied`, `approval_rejected`, `not_approver`, `no_pending_approval`, `already_decided`, `run_not_found`, `driver_failed`, `invalid_input`, `run_ended`, `run_running`: `client.result` timed out with the run still going, `approval_pending`: a [direct call](#direct-calls) is held, `not_admitted`: the data files declare people and not this principal's id) and `data`. Read it with `errorCode(err)`, not `instanceof`: a run's error comes back from the database as a copy, so `errorCode(await client.result(runId).catch((e) => e))` is `"policy_denied"` for a denied call.
134
102
 
135
103
  `startWorker(config, { logLevel })` runs workflows and recovers interrupted runs. `SanomaClient` starts runs, lists them, records approval decisions and reads a run's ledger. `describeConfig(config)`, from `@sanoma-ai/workflows/describe`, returns the same config as plain JSON (its version, each workflow's input as JSON Schema, the operations it may call and its [outline](#outline), each operation's effect and contract: its input as a caller sends it, `io: "input"`, and its output as parsed, `io: "output"`), each vendor's title, logo, package and homepage and whether its connector attaches a fake (`vendors`, by vendor id, the logo as `data:image/svg+xml` URLs), the resource types (`resourceTypes`, see [Resources](#resources)), and the resources the data files declare, each with its binding, the lockfile's orphans and what is wrong in them (`resources`, `orphans` and `problems`, see [Data files](#data-files); a workflow's file's problems are on its entry, beside its `file` and `outline`), which is what a UI renders from.
136
104
 
137
105
  The worker, the client and the app find Postgres at the config's `databaseUrl`, else the `SANOMA_DATABASE_URL` environment variable, else `postgresql://postgres:dbos@localhost:5433/sanoma`, the database `pnpm db:up` starts from this repo's docker compose file. Set one of the first two anywhere but on your own machine.
138
106
 
107
+ ### People
108
+
109
+ People, groups and agents are resources of the built-in `sanoma` connector, declared in a [data file](#data-files) (`resources/people.ts` by convention) and read without running it:
110
+
111
+ ```ts
112
+ import { sanoma } from "@sanoma-ai/workflows/resources"; // `sanoma/resources` in a company repo
113
+
114
+ export const ada = sanoma.person({ id: "ada", name: "Ada Ortiz", email: "ada@acme.test", github: "ada-ortiz" });
115
+ export const bo = sanoma.person({ id: "bo" });
116
+ export const refundBot = sanoma.agent({ id: "refund-bot", onBehalfOf: bo });
117
+ export const finance = sanoma.group({ name: "finance", members: [ada] });
118
+ ```
119
+
120
+ A membership is written on the group alone. An agent is in the group `agents` besides its own, with `onBehalfOf` the person whose instruction it carries; the runtime's own agents (`scenarios`, `host-check`, `schedule`) are declared by no file. They have no vendor object: no driver, no drift. `readPeople(root)` reads them (again only when a data file changed), and `principalOf(id, people)` is who an id is: its groups, by inverting the members, and its `onBehalfOf`. Once a person is declared, `SanomaClient.start`, `call`, `decide`, `setCredential`, `setSecret` and `clearCredential` admit only a declared id (`not_admitted`, "<id> is not declared in resources/people.ts"), as the app's login admits only a declared person; while none is, any id is taken as given; while the people files have problems, nobody is admitted but the runtime's agents.
121
+
122
+ ### Policies
123
+
124
+ The policy is [Cedar](https://docs.cedarpolicy.com): `policies/**/*.cedar` in the root, evaluated in process with `@cedar-policy/cedar-wasm` against a schema built from the resolved config and the people (`schemaOf`). Everything is allowed unless a `forbid` refuses it: the runtime adds the one `permit`, so a company's policies are `forbid`s.
125
+
126
+ ```cedar
127
+ @hold("finance")
128
+ @title("Refunds need finance")
129
+ forbid (principal, action in Sanoma::Action::"effect:money", resource)
130
+ unless { principal in Sanoma::Group::"finance" };
131
+
132
+ @id("no-agent-refunds")
133
+ @reason("Agents do not refund")
134
+ forbid (principal in Sanoma::Group::"agents", action == Sanoma::Action::"shop.order.refund", resource);
135
+ ```
136
+
137
+ - **The schema**, namespace `Sanoma`: `Principal` in `Group` (an enum of the declared groups and `agents`, so a misspelt group fails validation), with `onBehalfOf`; each operation's id an action in its effect's group (`effect:money`), on a `Target` (its `target`, else its id; a collection's in `Collection::"<name>"`), with `context.input` from its input schema and, for a collection's save or delete, `context.before`; `workflow:<name>` in the group `start`, on a `Workflow`, with the run's input. `schemaText` prints it; the `sanoma` bin writes it to `.sanoma/schema.cedarschema`.
138
+ - **A hold** is a `forbid` with `@hold("<group>" | "<person id>" | "on-behalf-of")`: the call waits for that approver (`on-behalf-of`: whom the agent acts for), with `@title` as the approval's title. The runtime lifts it once an approval addressed to that approver and covering the operation is approved (`approvedFor`), so a workflow's own `ctx.approval` to the same approver, covering the operation, lets it through. Several holds on one call or start are asked one at a time, in the order written: once one is approved, the policy is asked again. **A deny** is a `forbid` without `@hold`, with `@reason` as its message (else `Refused by <id>`). A policy's id is its `@id`, else `<file>#<n>`; a decision's `reasons` carry the ids into the ledger.
139
+ - **Where it runs**: before every operation call, a run's node, a [direct call](#direct-calls) and [apply](#apply)'s changes (`decideCall`), and before a run starts (see [Approvals](#approvals)). `policy.preview(requests)` asks the same with the input unknown, through Cedar's partial evaluation: `allow`, `hold` with its approver, `deny`, or `depends` on the input, each with the rule behind it; the app's run form and Permissions page show it.
140
+ - **Problems** (a file that does not parse, two policies with one id, a template, what Cedar's strict validation finds, a `permit`, a `@hold` naming no declared group or person) are `resolveConfig`'s `policyProblems`, one line each, `file:line:col: <policy id>: <message>`. While there are any, the policy refuses every call, start and preview ("The policies do not hold: …"): a repo never runs on part of its rules. `describeConfig(config).policy` carries them as `problems`; `startWorker` warns with them (unless `quiet`), `startApp` lists them and its pages say the policies do not hold, `sanoma check` reports them and runs no scenario, and the `sanoma` bin starts nothing.
141
+
142
+ Later, not yet: the person's numeric GitHub id is stored but unused, so a login renamed and taken by someone else would be admitted as them. The app describes the config when it starts, so a person added while it runs shows in the who-are-you picker and the Permissions grid after a restart (login admits them at once). The Permissions grid asks about workflow starts and direct operations only, not the operations a run calls.
143
+
139
144
  ### Ledger
140
145
 
141
146
  The ledger is the audit record of a run: who started it, every operation call with the policy's decision, every approval, and how it ended, as `LedgerRecord`s in `seq` order. `client.ledger(runId)` reads it, and the app's run page shows it. Three stores keep it, each a `LedgerStore` (`append`, `read`, and `close` for one that holds a connection), and a config picks one with `ledger`:
@@ -148,13 +153,15 @@ Every store keeps the first record of an id: a run replayed after a restart writ
148
153
 
149
154
  ### Approvals
150
155
 
151
- An approval comes from the workflow (`ctx.approval(title, { approver, covers?, links?, details? })`) or from the policy holding a call (`approve(approver, { title?, covers? })`). Each is in `run.approvals` with its `status`, so a later policy call can see it.
156
+ An approval comes from the workflow (`ctx.approval(title, { approver, covers?, links?, details? })`) or from the policy holding a call (a `@hold`). Each is in `run.approvals` with its `status`, so a later call it covers is let through once it is approved.
152
157
 
153
- `covers` says which operations an approval stands for, as op ids in the approval's state. A policy hold covers the operation it held, plus any `covers` the policy adds; a workflow's approval covers the operations it names, or none. The idiomatic policy check is `approvedFor(run.approvals, op.id, approver)`: approved, covering this operation, and addressed to the approver the policy would name. The last part matters: a workflow can request an approval covering any operation from anyone it names, so a check on `covers` alone would let a workflow launder a sign-off through its own `ctx.approval`. Covers name operations, not inputs: an approval that covers `shop.order.refund` covers every later refund call in the run. A policy that needs one approval per call compares `a.input` too.
158
+ `covers` says which operations an approval stands for, as op ids in the approval's state. A policy hold covers the operation it held; a workflow's approval covers the operations it names, or none. The runtime lifts a hold with `approvedFor(run.approvals, op.id, approver)`: approved, covering this operation, and addressed to the hold's approver. The last part matters: a workflow can request an approval covering any operation from anyone it names, so a check on `covers` alone would let a workflow launder a sign-off through its own `ctx.approval`. Covers name operations, not inputs: an approval that covers `shop.order.refund` covers every later refund call in the run.
154
159
 
155
- The approver is a person's id (`"finance-lead"`), or `{ group: "finance" }` for anyone whose principal lists that group in `groups`. A group's name is not a person: `{ id: "finance" }` is not in the group `finance`. `mayDecide(approval, principal)` is the check the run and the client both use. A policy hold's default title is `<op id> needs <approver>`, the approver shown as `group finance` for a group.
160
+ The approver is a person's id (`"finance-lead"`), or `{ group: "finance" }` for anyone whose principal lists that group in `groups`. A group's name is not a person: `{ id: "finance" }` is not in the group `finance`. `mayDecide(approval, principal, run, people)` is the check the run, the client and the app's Inbox all use; it also refuses the run's own actor while someone else could decide, that is while the approver is a group with another declared person in `people` (the declared people resources; an agent in the group, declared or the runtime's own, does not count; `own change`: nobody decides on their own change, though a person may decide on a run an agent started for them). When nobody else could (the approver is the starter by id, or a group they alone are in), the starter decides, and the `approval.decided` record and the approval say `alone: true`. A policy hold's title is its `@title`, else `<op id> needs <approver>`, the approver shown as `group finance` for a group.
156
161
 
157
- A policy hold's approval also carries the held call: its `op`, its parsed `input`, and `opSeq`, the ledger `seq` of the call's `op.called` record, so a UI can show the hold on the call it held.
162
+ A policy hold's approval also carries the held call: its `op`, its parsed `input`, `opSeq`, the ledger `seq` of the call's `op.called` record, so a UI can show the hold on the call it held, and `reasons`, the ids of the policies that held it.
163
+
164
+ The policy decides a run's start too, as the action `workflow:<name>` with the input as its context, before `client.start` queues it (the built-in `call`, `apply` and `drift` are not asked; their calls are). A deny throws `policy_denied` and starts nothing; a hold starts the run, which waits on the approval (`op: "workflow:<name>"`, no `opSeq`) before its body runs, and fails if it is rejected.
158
165
 
159
166
  `client.decide(runId, { decision, by, note? }, approvalId?, { timeoutSeconds? })` refuses without sending when the approval is decided already (`already_decided`), when the run has finished, failed or been cancelled (`run_ended`: no run would read the decision), when there is nothing pending (`no_pending_approval`, `run_not_found`) or when `by` may not decide (`not_approver`, naming the approver or group). Otherwise it sends the decision and waits for the run to read it, then returns the approval as decided: the run publishes it on the event `decisionEventOf(approvalId)`. If another decision reached the run first (someone else's, or another of yours), it throws `already_decided` with who decided and how: each message carries an `id` (a new one unless `message.id` is given), and the approval records the one it was decided with as `decidedWith`. The id is also the send's idempotency key, scoped to the approval, so a retried send queues one message. If the run does not read the decision within `timeoutSeconds` (30 by default), for instance because no worker is running, it returns the approval still `pending`; the decision stays queued and the run reads it when it next runs. The run checks the sender again and records anything it refuses as `approval.refused`; when it refuses this decision, `decide` throws `not_approver` rather than returning the approval pending.
160
167
 
@@ -166,7 +173,9 @@ A run's ctx calls run one at a time, in program order. Even inside `Promise.all`
166
173
 
167
174
  A workflow gets these on `ctx` by listing them in `uses`, next to its operations: `"approval"` (see [Approvals](#approvals)), `"sleep"` and `"all"`. `describeConfig` lists them as each workflow's `builtins`.
168
175
 
169
- `ctx.sleep({ until })` takes an ISO 8601 date-time with an offset (`2026-10-07T09:00:00Z`) or epoch milliseconds, and a time already past does not wait; `ctx.sleep({ minutes: 5 })` takes a duration from `ms`, `seconds`, `minutes`, `hours` and `days`, none negative. A request it can't read fails the run with `invalid_input`, as does an operation input its schema refuses. Each sleep writes a `sleep.started` record before it waits, with `until`: the time asked for, or for a duration, the time the sleep started plus the duration, as the runtime saw it.
176
+ Every workflow has `ctx.runId` and `ctx.now()`, without `uses`. `await ctx.now()` is the time in epoch milliseconds, recorded so a replay reads the same. A date is made from it: with `const now = await ctx.now()`, `new Date(now).toISOString()` is ISO 8601 text, which a collection's `date` field takes (`.slice(0, 10)` for the day alone), and `new Date(now + 86_400_000)` is a day later, from the same reading. The lint refuses only the uses of `Date` that read the clock, `Date.now()`, `new Date()` and `Date()` (and `Date` passed on, which it cannot follow); `new Date(value)`, `Date.parse`, `Date.UTC` and `instanceof Date` read none (see [Keeping workflows replay-safe](#keeping-workflows-replay-safe)). Read a date in UTC (`toISOString`, `getUTCHours`) and parse ISO 8601 text with an offset: the local-time methods (`getHours`, `toLocaleString`) and other text depend on the worker's time zone, which a replay on another worker may not share.
177
+
178
+ `ctx.sleep({ until })` takes an ISO 8601 date-time with an offset (`2026-10-07T09:00:00Z`) or epoch milliseconds, and a time already past does not wait; `ctx.sleep({ minutes: 5 })` takes a duration from `ms`, `seconds`, `minutes`, `hours` and `days`, none negative. A request it can't read fails the run with `invalid_input`, as does an operation input its schema refuses. Each sleep writes a `sleep.started` record before it waits, with `until`: the time asked for, or for a duration, the record's `at` plus the duration, as the runtime saw it.
170
179
 
171
180
  `ctx.all([() => …, () => …])` declares a fan-out: work that could go in any order, such as one post per network. It calls the members one after another, in array order, each awaited before the next starts, and returns their outputs in that order, each typed as its member's. It adds no concurrency: calls through `ctx` run one at a time anyway, and `ctx.all` says what belongs together, so the ledger and a graph of the run can show it. Every record written while a member runs (its operation calls, its approvals, its sleeps) carries `group: { id, index, size, node }`: `id` is `all:<seq>`, the ledger `seq` the run's next record would take when `ctx.all` began (so the same on a replay), `index` the member's position, `size` the member count and `node` the `ctx.all` call's [outline](#outline) node. Records outside a group have no `group`. The first member that throws stops the group: its error is rethrown as it is, and the members after it never run. A `ctx.all` inside a member fails with `invalid_input` (`ctx.all cannot be nested`), as does one started while another is still running (`a ctx.all is already running: await it before the next`) and an argument that is not a list of functions; an empty list returns `[]` and writes nothing.
172
181
 
@@ -186,7 +195,7 @@ Every call is a step with a name, which a test and the graph name it by: `ctx.gi
186
195
 
187
196
  ### Drivers
188
197
 
189
- A driver implements a connector's operations with `defineDriver(connector, { resource: { name: (input, call) => … } })`, typed by the connector's schemas, and its package attaches it to the connector it exports (`withSetup`). `call` carries `idempotencyKey`, the same on every retry and replay of one call: pass it to the vendor (or dedupe on it) so a crash between the vendor's reply and the checkpoint does not repeat the side effect. It carries `secrets` too, the store `secret()` markers name (see [Secrets](#secrets)). A driver reads its credentials when it is called, never from the config, and declares them (see [Credentials](#credentials)).
198
+ A driver implements a connector's operations with `defineDriver(connector, { resource: { name: (input, call) => … } })`, typed by the connector's schemas, and its package attaches it to the connector it exports (`withSetup`). `call` carries `idempotencyKey`, the same on every retry and replay of one call: pass it to the vendor (or dedupe on it) so a crash between the vendor's reply and the checkpoint does not repeat the side effect. It carries `secrets` too, the store `secret()` markers name (see [Secrets](#secrets)), and `by`, who the call is made as (`run:<id>` inside a run). A driver reads its credentials when it is called, never from the config, and declares them (see [Credentials](#credentials)).
190
199
 
191
200
  When the vendor says no, throw a `DriverError(message, { retryable, status?, vendorCode? })`. An operation declared `idempotent` is tried up to three times (after 1 and 2 seconds) unless the error says `retryable: false` or the reply fails the output schema; any other operation is tried once. When the tries run out, the run fails with the last try's error, not a wrapper: for a `DriverError`, `errorCode(err)` is `"driver_failed"` and the ledger records the vendor's message, status and code. Anything else a driver throws (a `TypeError`, a plain `Error`) fails the run as it is, with no code.
192
201
 
@@ -234,6 +243,53 @@ Only declared fields are compared for drift, as CloudFormation does: `compareDec
234
243
 
235
244
  `describeConfig` lists the resource types as `resourceTypes`, each `{ id: "<vendor>.<type>", vendor, type, title, identity, fields, schema, ops }`: `schema` is its state as JSON Schema, with `$id` `sanoma:resource-type/<id>`, which its operations' contracts `$ref` rather than repeat. `@sanoma-ai/workflows/shared` exports the `Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource` and `ResourceProblem` types.
236
245
 
246
+ ### Collections
247
+
248
+ A collection holds the company's own records, the state no vendor holds. `collection()`, from `@sanoma-ai/workflows/data` (`sanoma/data` in a company repo), declares one with plain values: a name (a lowercase letter, then letters and digits), its key, and typed fields. A company declares its collections in one file, `resources/data.ts`:
249
+
250
+ ```ts
251
+ // resources/data.ts
252
+ import { collection } from "sanoma/data";
253
+
254
+ export const outbound = collection({
255
+ name: "outbound",
256
+ key: "company", // a required text field
257
+ fields: {
258
+ company: "text",
259
+ stage: { enum: ["researched", "contacted", "won", "lost"] },
260
+ owner: "person",
261
+ lastTouched: { type: "date", optional: true },
262
+ },
263
+ });
264
+ ```
265
+
266
+ A field is `"text"`, `"number"`, `"boolean"`, `"date"` (ISO 8601: a date, or a date and time with its offset) or `"person"` (a person's id), `{ type, optional: true }`, or `{ enum: [...] }` (optional too). A record is typed by them (`RecordOf<typeof outbound.fields>`), and `save` refuses a field that is not declared. The config's `collections` lists them; `sanoma` adds every collection `resources/data.ts` exports when it loads the config, so a company's config lists none.
267
+
268
+ `resources/data.ts` is the one data file that runs: the launcher imports it, and so does a workflow that names a collection's operations (`import { outbound } from "../resources/data.ts"`, the one file outside `workflows/` or `policies/` the workflow lint lets them import, found by its real path). So it may import only `collection` from `sanoma/data` (or `@sanoma-ai/workflows/data`), and each `export const` is a `collection({ … })` with its values written out, which the [data-file lint](#data-files) holds it to. No other data file declares a collection or imports `resources/data.ts`. Their operations are of the vendor `data` (`DATA_VENDOR`, which no connector may take): `data.outbound.get` (`{ key }`: the record, or `null`), `.list` (`{ where?, order?, limit? }`: fields equal to a value or within `{ gt, gte, lt, lte }`, sorted by a field, or `-field` for descending, with missing values last, else by key; values compare as their field's type, text byte by byte (Postgres's `COLLATE "C"`, whatever the database's collation), and one of another type counts as missing; 1 to 1000 records, 100 by default), `.save` (`{ record }`: added, or replacing the one with its key) and `.delete` (`{ key }`: `{ deleted }`). `get` and `list` are effect `read`, `save` and `delete` `write`, all idempotent, and the policy's `target` is `data:outbound/<key>` (`data:outbound` for a list). A workflow names them in `uses` and calls them on `ctx`, as any operation:
269
+
270
+ ```ts
271
+ uses: [outbound.get, outbound.save],
272
+ async run(ctx, { company }) {
273
+ const lead = await ctx.data.outbound.get({ key: company });
274
+ if (lead) await ctx.data.outbound.save({ record: { ...lead.record, stage: "contacted" } });
275
+ },
276
+ ```
277
+
278
+ `get`, `list` and `save` answer `Saved` records, `{ key, record, updatedAt, updatedBy }`, where `updatedBy` is who the call was made as (`CallContext.by`: `run:<id>` inside a run, the person's id for a [direct call](#direct-calls)). The records are JSON rows of one table the runtime creates beside the ledger, `sanoma_data` (`collection`, `key`, `record`, `updated_at`, `updated_by`), in the config's database. A declaration changes with no migration: a record saved under an older one comes back as it is stored, with `issues` (`{ path, message }`) where the fields no longer accept it. A sandbox run keeps the records in memory, in a fake that starts empty: a scenario seeds a record as it seeds a resource, `Given data.outbound "Acme" has` with a table of fields (the key is the quoted name, and what the table leaves out is made up), and removes one with `Given data.outbound "Acme" is gone`. `describeConfig` lists them as `collections`, each `{ name, title?, key, fields, record, ops, declared? }`, `record` being the JSON Schema `save` accepts, with `$id` `sanoma:collection/<name>`, and `declared` where `resources/data.ts` declares it (`{ id, file, name, span }`, as `readDataFiles` reads it), which the app's Resources page lists. `@sanoma-ai/workflows/shared` exports `DATA_VENDOR` and the `Saved` and `ListQuery` types.
279
+
280
+ ### Direct calls
281
+
282
+ An operation may also be called directly, outside a run, as the person on a page: `isDirect(op, types)` says which, the one rule the app and the SDK read (`describeConfig` gives it as each operation's `direct`). A collection's operations may, and a vendor's `read`, but not the runtime's own (`sanoma.source.resolve`) or a resource type's (`isResourceOp`: only drift and apply call those); anything with an effect at a vendor is a workflow's.
283
+
284
+ ```ts
285
+ const saved = await client.call("data.outbound.save", { record }, { actor: { id: "ada" }, id: requestId });
286
+ const customer = await client.call("stripe.customer.get", { id }, { actor: { id: "ada" } });
287
+ ```
288
+
289
+ `client.call(opId, input, { actor, id? })` refuses an unknown operation, one that may not be called directly and input its schema refuses (`invalid_input`). The policy decides the call as the person, in a run `direct:<actor>:<id>` of the workflow `call` with no approvals (`id` is a new UUID unless given). A deny throws `policy_denied` and writes nothing. An allowed call runs the driver once, without DBOS and without a retry, as `CallContext` `{ idempotencyKey, runId }` both that run id and `by` the person's id; a failure is `driver_failed`. A read returns its output and records nothing, so a page polling it does not fill the ledger. A write is one `op.called` record (seq 0) of that run, and with the default ledger it is in one Postgres transaction with the record it saves: both are kept, or neither. The same id with the same input returns the output recorded, without writing again; with another operation or input it is refused (`invalid_input`, `differs`). When the policy asks for an approval, the call becomes a run of the built-in `call` workflow (`CALL_WORKFLOW`), started as the person with run id `call:<actor>:<id>` and input `{ op, input }`: its one call is held like any run's, shows in `client.approvals`, and once approved is made as the person (a save's `updatedBy` is theirs). `client.call` throws `approval_pending` (`ApprovalPendingError`) with `data` `{ runId, workflow: "call", op }`, and so does the same call again (same id) while that run waits; once it has ended, the same call returns what the run returned (or throws its error), without asking the policy again. A write whose driver failed keeps its failure under its id: call again with another id to try again. The `call` workflow `uses` every operation that may be called directly and has a driver; `resolveConfig` adds it when there is one, and a config's own workflow may not be named `call`.
290
+
291
+ `client.latest(workflow)` is the newest finished run of a workflow, `{ runId, output, at }` (`at` when it finished), or `null`: one query, for a page that shows a workflow's last result.
292
+
237
293
  ### Data files
238
294
 
239
295
  A company declares the resources it wants in TypeScript data files under `resources/` beside its config, `resources/<area>/<file>.ts`, as its workflows are in `workflows/`. A data file holds plain values and references only, so Sanoma reads it without running it:
@@ -255,11 +311,11 @@ export default [website, websiteMain];
255
311
 
256
312
  Another data file names `website` by importing it: `import { website } from "./github.ts"`.
257
313
 
258
- What a data file may hold: named imports, of a connector's constructors from its `<package>/resources` entry (exported under the vendor's id), of resources from other `.ts` data files in `resources/`, and of `secret` from `sanoma/resources` (or `@sanoma-ai/workflows/resources`); `export const <name> = <vendor>.<type>({ … })`, one resource per statement and each name once, whose fields are strings (and template literals without `${}`), numbers, booleans, null, objects, arrays and, where the type's `fields.references` allows one, names of resources the file declares or imports, and, where the type's `fields.writeOnly` lists the field, `secret("NAME")` or `secret("NAME", { generate: true })` with literal arguments (see [Secrets](#secrets)); and `export default [a, b]`, the file's resources. Nothing else: no other calls, `new`, functions, spreads, computed keys, member access (`site.name`: name `site`), `process`, `undefined` (leave the field out) or type assertions. Each refusal says what to write instead.
314
+ What a data file may hold: named imports, of a connector's constructors from its `<package>/resources` entry (exported under the vendor's id), of resources from other `.ts` data files in `resources/`, and of `secret` from `sanoma/resources` (or `@sanoma-ai/workflows/resources`); `export const <name> = <vendor>.<type>({ … })`, one resource per statement and each name once, whose fields are strings (and template literals without `${}`), numbers, booleans, null, objects, arrays and, where the type's `fields.references` allows one, names of resources the file declares or imports, and, where the type's `fields.writeOnly` lists the field, `secret("NAME")` or `secret("NAME", { generate: true })` with literal arguments (see [Secrets](#secrets)); and `export default [a, b]`, the file's resources. `resources/data.ts` is the exception: it holds the [collections](#collections) and nothing else. Nothing else: no other calls, `new`, functions, spreads, computed keys, member access (`site.name`: name `site`), `process`, `undefined` (leave the field out) or type assertions. Each refusal says what to write instead.
259
315
 
260
316
  The data files are found from the config's root: `defineConfig` records the file it is called from as the config's `file`, and `resolveConfig` gives its directory as `root`. A config bundled into one file sets `root` itself. A config with neither has no data files, and says so, rather than guess the working directory.
261
317
 
262
- `readDataFiles(root, connectors)`, from `@sanoma-ai/workflows/describe`, reads every regular `.ts` file under `<root>/resources` (but `*.test.ts`, `*.d.ts` and `node_modules`), parsing it with oxc-parser and never importing it. A constructors import must be exactly a connector's `<info.package>/resources`, matched among the config's `connectors`; each resource is checked by calling its type, as running the file would, with each resource it names given as that resource. It returns `{ resources, bindings, orphans, problems }`: one `DeclaredResource` per `export const`, by file and then in file order, less any with a problem and any that names one, and the problems, by file, line and column:
318
+ `readDataFiles(root, connectors)`, from `@sanoma-ai/workflows/describe`, reads every regular `.ts` file under `<root>/resources` (but `*.test.ts`, `*.d.ts` and `node_modules`), parsing it with oxc-parser and never importing it. A constructors import must be exactly a connector's `<info.package>/resources`, matched among the config's `connectors`; each resource is checked by calling its type, as running the file would, with each resource it names given as that resource. It returns `{ resources, bindings, orphans, collections, problems }` (`collections`: where `resources/data.ts` declares each collection, `{ id, file, name, span }`, never matched to a connector, a type or the lockfile): one `DeclaredResource` per `export const`, by file and then in file order, less any with a problem and any that names one, and the problems, by file, line and column:
263
319
 
264
320
  ```ts
265
321
  interface DeclaredResource {
@@ -356,9 +412,9 @@ Queues used to be one `sanoma` queue for every app and are now `sanoma:<appName>
356
412
 
357
413
  ## Keeping workflows replay-safe
358
414
 
359
- A run is replayed after a restart by calling the function again and reading each step's result back, so workflows and policies must do the same thing every time, and must reach vendors only through `ctx` so the policy sees every call. Two checks guard against getting that wrong by accident, on files under `workflows/` and `policies/`. They are not a sandbox: they read the source, and code written to get around them can.
415
+ A run is replayed after a restart by calling the function again and reading each step's result back, so workflows must do the same thing every time, and must reach vendors only through `ctx` so the policy sees every call. Two checks guard against getting that wrong by accident, on files under `workflows/`. They are not a sandbox: they read the source, and code written to get around them can.
360
416
 
361
- oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`Date`, `performance`), randomness (`Math.random`, `crypto`), the network (`fetch`, `WebSocket`), timers, `process`, `globalThis`, `Promise.all` and its kin (use `ctx.all`; nothing races), and imports of `@sanoma-ai/testing`, `@sanoma-ai/app`, `@sanoma-ai/workflows/describe`, `@sanoma-ai/workflows/fake`, `@sanoma-ai/workflows/plugin`, `@sanoma-ai/workflows/scenario`, `@sanoma-ai/workflows/scenarios`, `@sanoma-ai/connector-*/fake` and `@sanoma-ai/connector-*/driver`. From `@sanoma-ai/workflows` it allows only `defineWorkflow`, `definePolicy`, `allow`, `deny`, `approve`, `approvedFor`, `allowAll`, `mayDecide`, `errorCode`, `z` (which `sanoma` re-exports) and types (the list is `allowImportNames` in `oxlint.json`; the `sanoma` package's own `oxlint.json` allows the same names from `sanoma` and refuses its subpaths): the rest could start runs or approve the run's own approvals (`SanomaClient`, `startWorker`), forge the audit record (`jsonlLedger`, `memoryLedger`: a store keeps the first record per id) or read credentials. Each message names the `ctx` replacement. `sanoma check` runs it, through `sanoma/oxlint.json`, which carries these rules; without `sanoma`, extend this package's from your `.oxlintrc.json` by its path: oxlint resolves `extends` as a file, not a package name, so `@sanoma-ai/workflows/oxlint` would not load. The `workflows/**` and `policies/**` globs resolve against your config:
417
+ oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`Date.now()`, `new Date()` and `Date()`, through the plugin's rule `sanoma/date`, which allows `new Date(await ctx.now())`; `performance`), randomness (`Math.random`, `crypto`), the network (`fetch`, `WebSocket`), timers, `process`, `globalThis`, `Promise.all` and its kin (use `ctx.all`; nothing races), and imports of `@sanoma-ai/testing`, `@sanoma-ai/app`, `@sanoma-ai/workflows/describe`, `@sanoma-ai/workflows/fake`, `@sanoma-ai/workflows/plugin`, `@sanoma-ai/workflows/scenario`, `@sanoma-ai/workflows/scenarios`, `@sanoma-ai/connector-*/fake` and `@sanoma-ai/connector-*/driver`. From `@sanoma-ai/workflows` it allows only `defineWorkflow`, `mayDecide`, `errorCode`, `z` (which `sanoma` re-exports) and types (the list is `allowImportNames` in `oxlint.json`; the `sanoma` package's own `oxlint.json` allows the same names from `sanoma` and refuses its subpaths): the rest could start runs or approve the run's own approvals (`SanomaClient`, `startWorker`), forge the audit record (`jsonlLedger`, `memoryLedger`: a store keeps the first record per id) or read credentials. Each message names the `ctx` replacement. `sanoma check` runs it, through `sanoma/oxlint.json`, which carries these rules; without `sanoma`, extend this package's from your `.oxlintrc.json` by its path: oxlint resolves `extends` as a file, not a package name, so `@sanoma-ai/workflows/oxlint` would not load. The `workflows/**` glob resolves against your config:
362
418
 
363
419
  ```json
364
420
  {
@@ -366,7 +422,7 @@ oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`
366
422
  }
367
423
  ```
368
424
 
369
- `describeConfig` (`@sanoma-ai/workflows/describe`) reads each workflow's file once, the one way the worker does, and lists on the workflow's entry, in `problems`, what oxlint can't express: imports come only from `@sanoma-ai/workflows` or `sanoma`, a `@sanoma-ai/connector-<vendor>` package, zod, or a relative file that stays inside the file's `workflows/` or `policies/` directory (so not `../sanoma.config.ts`, which holds the config); no namespace import of either package; no dynamic `import()`; and no `instanceof` against `DriverError`, `SanomaError`, `PolicyDeniedError` or `RejectedError`. On a replay DBOS rethrows a serialized copy of an error, which is no instance of its class, so a branch on `instanceof` would go another way than the first time and the run's steps would fall out of step. Read `errorCode(err)` instead.
425
+ `describeConfig` (`@sanoma-ai/workflows/describe`) reads each workflow's file once, the one way the worker does, and lists on the workflow's entry, in `problems`, what oxlint can't express: imports come only from `@sanoma-ai/workflows` or `sanoma`, a `@sanoma-ai/connector-<vendor>` package, zod, or a relative file that stays inside the file's `workflows/` directory (so not `../sanoma.config.ts`, which holds the config); no namespace import of either package; no dynamic `import()`; and no `instanceof` against `DriverError`, `SanomaError`, `PolicyDeniedError` or `RejectedError`. On a replay DBOS rethrows a serialized copy of an error, which is no instance of its class, so a branch on `instanceof` would go another way than the first time and the run's steps would fall out of step. Read `errorCode(err)` instead.
370
426
 
371
427
  In each workflow's `run` it also lists what the run's graph could not show, the same problems that make the outline `{ error }` and `startWorker` refuse (see [Outline](#outline)): `ctx` used other than called directly or read for `ctx.runId` (passed to a helper, aliased, destructured, or `run` taking it destructured); a call on `ctx` that is no operation or built-in (`ctx.ghost.post()`); `ctx.all` over members made elsewhere (write the list, or `items.map(…)`, in the call); a `ctx` call inside a function the outline does not read (one defined inside `run`, or a callback not passed to `ctx.all`, `.map`, `.forEach` or `.reduce`) or in a `finally`; and, in `run`, in a `finally` and in the callbacks that make `ctx` calls, a `return` before the end, a `throw` outside a `catch`, a `break` out of a loop or to a label, and `continue`; and a [step name](#step-names) that is not a literal or reads as an operation's id or `Sleep`, and two steps given one name. A function without `ctx` calls computes, and may do as it likes. Each message says what to write instead: inline the helper, use `if`/`else` around the rest, name the call. Each problem has its `line` and `column` in the entry's `file` (an unreadable file, or one that holds no workflow of the entry's name, is the outline's error at 1:1); `sanoma check` prints them, as `problemLine` does, and the app's startup warns of them. A test holds a repo to the same:
372
428
 
@@ -416,31 +472,34 @@ Feature: Announce a launch
416
472
 
417
473
  The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expressions) (`{op}` is an operation id, `{workflow}` a workflow name, `{who}` a person's id or a group's name, `{string}` a quoted string, `{resource}` a resource type such as `github.repository`, `{declared}` a declared resource as a drift report names it, `resources/identity/github.ts#website`, and `{field}` a field's dotted path; `And` and `But` take the keyword before them):
418
474
 
419
- | Keyword | Step | What it does |
420
- | ------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
421
- | Given | `{op} was called with` and a JSON doc string or a two-column table (`name \| value`) | Seeds the fake: calls the operation through it before the run |
422
- | Given | `{op} fails once`, `{op} is rate limited once`, `{op} loses its reply once` | Injects the fault into the operation's next call (`failNext`, `rateLimit`, `loseReply`); its step has `fault: true` |
423
- | Given | `{resource} {string} has` and a two-column table (`field \| value`) or a JSON doc string | The vendor's fake holds the object with these fields: `Given github.repository "website" has` then `\| has_wiki \| true \|` |
424
- | Given | `{resource} {string} is gone` | The vendor's fake no longer holds the object: `Given github.repository "docs" is gone` |
425
- | When | `{workflow} runs with` and a JSON doc string or a two-column table (`name \| value`) | The workflow the scenario runs, and its input |
426
- | When | `{workflow} runs` | The same, with the input made up |
427
- | Any | `{string} is approved by {who}`, `… with note {string}`, and the `rejected` forms | How the approval with that title (or id, such as `approval-2`) is decided |
428
- | Then | `{op} was called with` and a JSON doc string or a two-column table | Expects a call whose input has at least these fields |
429
- | Then | `{op} was called`, `{op} was not called` | Expects a call to the operation, or none |
430
- | Then | `step {string} was called with` and a JSON doc string or a two-column table | Expects a call by the [step](#step-names) of that name whose input has at least these fields: `Then step "Draft the post" was called with` |
431
- | Then | `step {string} was called`, `step {string} was not called` | Expects the named step to have run, or not |
432
- | Then | `step {string} ran {int} time(s)` | Expects the named step to have run that often: `Then step "Draft the post" ran 2 times` |
433
- | Then | `{op} was denied` | Expects the policy to have denied a call to it: `Then github.repository.import was denied` |
434
- | Then | `{op} was held for {who}` | Expects an approval for that approver to have held it: `Then stripe.product.import was held for finance` |
435
- | Then | `{declared} is drifted in {field}` | For a drift run: `Then resources/identity/github.ts#website is drifted in has_wiki` |
436
- | Then | `{declared} is clean`, `{declared} is gone`, `{declared} is absent` | For a drift run: `Then resources/identity/github.ts#docs is clean` |
437
- | Then | `apply creates {declared}` | For an apply run: it created the resource (or made a gone one again): `Then apply creates resources/identity/github.ts#docs` |
438
- | Then | `{declared} is bound to {string}` | For an apply run: the lockfile it ended with binds it so: `Then resources/identity/rules.ts#docsMain is bound to "BPR_docs:main"` |
439
- | Then | `the run succeeds`, `the run fails`, `the run fails with {string}` | Expects the run to finish, or to fail (with that error code, such as `"approval_rejected"`) |
475
+ | Keyword | Step | What it does |
476
+ | ------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
477
+ | Given | `{op} was called with` and a JSON doc string or a two-column table (`name \| value`) | Seeds the fake: calls the operation through it before the run |
478
+ | Given | `{op} fails once`, `{op} is rate limited once`, `{op} loses its reply once` | Injects the fault into the operation's next call (`failNext`, `rateLimit`, `loseReply`); its step has `fault: true` |
479
+ | Given | `{resource} {string} has` and a two-column table (`field \| value`) or a JSON doc string | The vendor's fake holds the object with these fields: `Given github.repository "website" has` then `\| has_wiki \| true \|` |
480
+ | Given | `{resource} {string} is gone` | The vendor's fake no longer holds the object: `Given github.repository "docs" is gone` |
481
+ | Given | `{who} is acting` | Starts the run as that declared person or agent: `Given refund-bot is acting` (else `runScenario`'s `startedBy`, `scenarios` by default) |
482
+ | When | `{workflow} runs with` and a JSON doc string or a two-column table (`name \| value`) | The workflow the scenario runs, and its input |
483
+ | When | `{workflow} runs` | The same, with the input made up |
484
+ | Any | `{string} is approved by {who}`, `… with note {string}`, and the `rejected` forms | How the approval with that title (or id, such as `approval-2`) is decided |
485
+ | Then | `{op} was called with` and a JSON doc string or a two-column table | Expects a call whose input has at least these fields, at any depth: an object may give some of its fields |
486
+ | Then | `{op} was called`, `{op} was not called` | Expects a call to the operation, or none |
487
+ | Then | `step {string} was called with` and a JSON doc string or a two-column table | Expects a call by the [step](#step-names) of that name whose input has at least these fields, at any depth: `Then step "Draft the post" was called with` |
488
+ | Then | `step {string} was called`, `step {string} was not called` | Expects the named step to have run, or not |
489
+ | Then | `step {string} ran {int} time(s)` | Expects the named step to have run that often: `Then step "Draft the post" ran 2 times` |
490
+ | Then | `{op} was denied` | Expects the policy to have denied a call to it: `Then github.repository.import was denied` |
491
+ | Then | `{op} was denied with {string}` | The same, with that reason (a Cedar policy's `@reason`): `Then shop.refunds.create was denied with "Agents do not refund"` |
492
+ | Then | `{op} was held for {who}` | Expects an approval for that approver to have held it: `Then stripe.product.import was held for finance` |
493
+ | Then | `{op} was held for group {string}` | The same, for the group alone, never a person of that id: `Then shop.refunds.create was held for group "finance"` |
494
+ | Then | `{declared} is drifted in {field}` | For a drift run: `Then resources/identity/github.ts#website is drifted in has_wiki` |
495
+ | Then | `{declared} is clean`, `{declared} is gone`, `{declared} is absent` | For a drift run: `Then resources/identity/github.ts#docs is clean` |
496
+ | Then | `apply creates {declared}` | For an apply run: it created the resource (or made a gone one again): `Then apply creates resources/identity/github.ts#docs` |
497
+ | Then | `{declared} is bound to {string}` | For an apply run: the lockfile it ended with binds it so: `Then resources/identity/rules.ts#docsMain is bound to "BPR_docs:main"` |
498
+ | Then | `the run succeeds`, `the run fails`, `the run fails with {string}` | Expects the run to finish, or to fail (with that error code, such as `"approval_rejected"`) |
440
499
 
441
500
  A resource step names an object a vendor's fake holds, by resource type and import id (a binding id, or an identity: a GitHub repository's name, a branch protection rule's `repository:pattern`; a fake also finds an object by its binding id, such as a repository's `node_id`), for a fake that holds objects (`@sanoma-ai/bridge`'s `tfFake`, as the GitHub and Stripe fakes are; a sandbox run whose scenario seeds a resource of another vendor's fake fails with `invalid_input`). `has` puts the fields on the object (`put`: over the one the vendor has, keeping its other fields, or a new one), each read as a table cell is for an operation's input and checked against the type's schema; `is gone` removes it (`remove`), and is nothing when there is none. The run's own fake is what is seeded. A fake starts a sandbox run with what its replies were recorded with, so a declared resource the recording lacks is `gone` to a drift run until a scenario puts it.
442
501
 
443
- The policy steps read the run's ledger: `was denied` holds when the policy's decision on some call to the operation is `deny`, and `was held for {who}` when an approval addressed to that person (by id) or group (by name) holds it, whether the policy asked for it on that call or the workflow asked for one that `covers` it. A failed step says what happened instead: `github.repository.import was allowed, not denied`, `stripe.product.import was held for finance, not marketing-lead`. The drift steps read the report a finished [drift](#drift) run returned, so their scenario must run `drift` (a parse error otherwise), as the apply steps read an apply run's report, and an apply run's input is only what `runs with` gives, never made up; a failed one says how the report found the resource: `resources/identity/github.ts#website is drifted in has_wiki, not clean`, `… is gone, not drifted in has_wiki`, or that the report has no such resource.
502
+ The policy steps read the run's ledger: `was denied` holds when the policy's decision on some call to the operation is `deny`, and `was held for {who}` when an approval addressed to that person (by id) or group (by name) holds it, whether the policy asked for it on that call or the workflow asked for one that `covers` it. A failed step says what happened instead: `github.repository.import was allowed, not denied`, `stripe.product.import was held for finance, not marketing-lead`, `shop.refunds.create was denied with "Agents do not refund", not "No"`. When the data files declare people, `is acting` names a declared person or agent (one of the runtime's own too) and `held for group` a declared group or `agents`, else the scenario does not load, naming those declared; while they declare no one, any id and any group go, as a run's start admits anyone then. A scenario has one actor. The drift steps read the report a finished [drift](#drift) run returned, so their scenario must run `drift` (a parse error otherwise), as the apply steps read an apply run's report, and an apply run's input is only what `runs with` gives, never made up; a failed one says how the report found the resource: `resources/identity/github.ts#website is drifted in has_wiki, not clean`, `… is gone, not drifted in has_wiki`, or that the report has no such resource.
444
503
 
445
504
  The `step` steps name a [step](#step-names) of the scenario's workflow, after its `When`: one whose name is no step's, or several's (a default an operation called twice shares), is an error listing the names it has, so a test names one call (an unnamed call with a computed segment, `ctx.ghost.post[action](…)`, is named in a run by the operation it calls, so it shares that operation's id; an approval whose title is no literal is named by its title in a run, which the outline cannot tell apart), and `was called with` checks its operation's input fields. They read the ledger by the `name` its records carry: a step ran when a call by it was made (not denied, rejected or failed for good), a sleep by it started, or the workflow asked for an approval of that title; a policy's hold on a call is that call's, not a run of its own. A call in a loop runs once per pass: `ran 3 times` for three items. A failed one says what happened: `step "Draft the post" ran twice, not 3 times`.
446
505
 
@@ -448,7 +507,7 @@ Cucumber matches a step by its text alone, whatever its keyword; Sanoma then pic
448
507
 
449
508
  Each scenario has exactly one `When … runs`. An operation can add its own steps with `phrases` in its spec: `{ given: "a post titled {title} exists", expect: "a post titled {title} is created" }`. Each `{name}` is a field of the operation's input (a phrase naming another is a configuration error); it matches what `{string}` does (a quoted string) or one word, and is read as a table cell is. A field may have any name, even one a parameter type has (`op`, `string`, `int`): the phrase reads it as the field. A `given` phrase seeds the fake as `was called with` does, and an `expect` phrase (for `Then` steps) expects a call with those fields. `describeConfig` lists them as an operation's `phrases`.
450
509
 
451
- A doc string is a JSON object. A table cell, and a phrase's field, is read as text when its field takes text, else as JSON (a number, `true`, a list): `| title | 123 |` is the text `"123"` for a string field, and `| count | 5 |` the number 5 for a number field. Every field a doc string or a table names must be one the input has (the error lists those it has), and its value one the field takes; a value it takes neither as text nor as JSON, such as `yes` for a boolean, is an error. A `Then` checks each field it names this way, not the whole input.
510
+ A doc string is a JSON object. A table cell, and a phrase's field, is read as text when its field takes text, else as JSON (a number, `true`, a list): `| title | 123 |` is the text `"123"` for a string field, and `| count | 5 |` the number 5 for a number field. Every field a doc string or a table names must be one the input has (the error lists those it has), and its value one the field takes; a value it takes neither as text nor as JSON, such as `yes` for a boolean, is an error. A `Then` checks each field it names this way, not the whole input, and at any depth: an object it gives (in an object field, optional or nullable too) may name some of its fields (`{ "record": { "stage": "won" } }`), each checked against its own schema (`stage in record in data.outbound.save's input cannot be …`), where a `Given`, which seeds a call, needs the whole object; a list, and an object in a list or a union, is given whole. So does an `expect` phrase's field.
452
511
 
453
512
  Input the scenario leaves out is made up from the schema with [zod-schema-faker](https://github.com/soc221b/zod-schema-faker), seeded from the scenario's name, so one scenario always makes up the same values; it is then parsed by the schema. A step no rule matches, or more than one, an unknown operation, workflow or field, a value its field does not take, a missing or second `When`, a doc string that is not JSON, and a file Gherkin cannot parse are errors naming the file and line; the first lists every step there is, each operation's phrases under its id and each connector's steps under its vendor. A file with an error has none of its scenarios; the other files still load.
454
513
 
@@ -481,7 +540,7 @@ The same steps test the connector's own operations, in its `features/`: there th
481
540
  | Keyword | Step | What it does |
482
541
  | ------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
483
542
  | When | `{op} is called with` and a JSON doc string or a two-column table | Calls the operation with that input, the rest made up; the call and its output or error are kept |
484
- | Then | `it returns` and a two-column table of fields of its output | Expects the last call to have returned at least these fields |
543
+ | Then | `it returns` and a two-column table of fields of its output | Expects the last call to have returned at least these fields, at any depth |
485
544
  | Then | `it fails with status {int}` | Expects the last call to have failed with that vendor status |
486
545
 
487
546
  A workflow's scenario refuses `{op} is called with`, and an operation's own test refuses a step that runs a workflow, decides an approval or seeds a resource; a fault needs the fake.
@@ -505,18 +564,20 @@ A sandbox run does not survive a worker restart: its fakes keep their state in t
505
564
 
506
565
  `RunSummary.sandbox` names the scenario a sandbox run was seeded from (kept in DBOS's `attributes` for the run). Reusing a run id with another `sandbox`, or none, is `invalid_input`, as with another input.
507
566
 
508
- `runScenarios(config, { databaseUrl, startedBy?, timeoutMs?, log? })` from `@sanoma-ai/workflows/scenarios` (server-side only, like `/scenario`) runs every scenario of the config as a sandbox run, on a worker it starts on `databaseUrl` with an in-memory ledger and stops after, and returns `{ passed, failed }`: the names of the scenarios whose every check held, and one `{ scenario, step, message }` per step that did not (with `check`'s detail), per run that could not be driven to its end (at its `When` step), and per feature file that did not load (`step: "did not load"`); a config whose root has no `scenarios/` passes and fails nothing. `sanoma check` runs it. `runScenario(scenario, { client, scope, startedBy?, timeoutMs? })` runs one against a worker you started, `scope` being what it was loaded from (the resolved config). To run them as tests, a vitest test each, use `describeScenarios` from [`@sanoma-ai/testing/scenarios`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#scenarios), which wraps the same `runScenario`.
567
+ `runScenarios(config, { databaseUrl, startedBy?, timeoutMs?, log? })` from `@sanoma-ai/workflows/scenarios` (server-side only, like `/scenario`) runs every scenario of the config as a sandbox run, on a worker it starts on `databaseUrl` with an in-memory ledger and stops after, and returns `{ passed, failed }`: the names of the scenarios whose every check held, and one `{ scenario, step, message }` per step that did not (with `check`'s detail), per run that could not be driven to its end (at its `When` step), and per feature file that did not load (`step: "did not load"`); a config whose root has no `scenarios/` passes and fails nothing. `sanoma check` runs it. `startedBy` (an id or a principal) starts each run whose scenario does not say who `is acting`. `runScenario(scenario, { client, scope, startedBy?, timeoutMs? })` runs one against a worker you started, `scope` being what it was loaded from (the resolved config). To run them as tests, a vitest test each, use `describeScenarios` from [`@sanoma-ai/testing/scenarios`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#scenarios), which wraps the same `runScenario`.
509
568
 
510
569
  ## Building your own UI
511
570
 
512
571
  `@sanoma-ai/app` is one UI over a config; another (a Slack bot, an internal tool) can be built on the same pieces, which the package exports for that:
513
572
 
514
- - `describeConfig(config)` and its types (`ConfigDescription`, `WorkflowEntry`, `OpEntry`, `VendorEntry`, `ResourceTypeEntry`, `DeclaredResource`, `ResourceProblem`), and `readDataFiles(root, connectors)` and `readResources(config)`, from `@sanoma-ai/workflows/describe`: what to render, as plain JSON, with each workflow's outline. It is a separate entry so the worker never loads the parser the outline uses, and the lint refuses it in workflow files. `resolveConfig(config)` returns the checked config as a `ResolvedConfig`, with the operations and drivers by id, the resource types by `<vendor>.<type>` (`types`), the workflows by name (the built-ins `drift` and `apply` among them), its `root` and each vendor's `credentials` (`CredentialStatus`); `isOp(x)` tells an operation from a built-in in a workflow's `uses`.
515
- - `SanomaClient`: start runs, list them (`runs({ limit, status, workflow })`), read a run's ledger and approvals, decide approvals, with the checks described above, and start a drift check or an apply and read its report (`drift`, `driftReport`, `apply`, `applyReport`, see [Drift](#drift) and [Apply](#apply)).
573
+ - `describeConfig(config)` and its types (`ConfigDescription`, `WorkflowEntry`, `OpEntry`, `VendorEntry`, `ResourceTypeEntry`, `DeclaredResource`, `ResourceProblem`), and `readDataFiles(root, connectors)` and `readResources(config)`, from `@sanoma-ai/workflows/describe`: what to render, as plain JSON, with each workflow's outline. It is a separate entry so the worker never loads the parser the outline uses, and the lint refuses it in workflow files. `resolveConfig(config)` returns the checked config as a `ResolvedConfig`, with the operations and drivers by id, the resource types by `<vendor>.<type>` (`types`), the workflows by name (the built-ins `drift`, `apply` and `call` among them), its `root` and each vendor's `credentials` (`CredentialStatus`); `isOp(x)` tells an operation from a built-in in a workflow's `uses`.
574
+ - `readSites(root, description)`, from `@sanoma-ai/workflows/describe`, which `describeConfig` returns as `sites`: what each site calls, read with oxc and never run, for a workflow's **Used by**. A site is a folder of `sites/` with a `routes/`, named as a URL segment; its `routes/**` and `components/**` (`.ts`, `.tsx`, less tests) are read for each `<RunForm workflow="…" />` whose `RunForm` is imported from `@sanoma-ai/app/ui`, and each use of `api` imported from `sanoma/site`, called or passed on (`queryFn: api.data.leads.list`): `api.<name>` and `api.<name>.<method>` are the workflow whose `apiNameOf` is `<name>`, `api.data.<collection>.<op>` a collection, `api.<vendor>.<resource>.<op>` the operation of that id. Each is a `SiteCall` (`kind`, `target`, `via`, `file`, `line`, `column`, and `page`, the page's path from its `createFileRoute` or `createLazyFileRoute` id, in a page's file); a `SiteEntry` holds a site's `name`, its `title` (a string `defineSite({ title })` in `site.ts`), its `calls` and its `problems`: each call to a workflow, collection or operation the config does not have (`calls workflow "<name>", which does not exist`), or to an operation a page may not call directly, which `sanoma check` fails on. A name not written out (a variable, a template with holes, a computed member) is skipped, as is a call through a namespace import or a destructured `api`.
575
+ - `generateApi(description)` and `writeApi(root, description)`, from `@sanoma-ai/workflows/describe`: a site's typed `api`, `.sanoma/api.ts` (`API_FILE`), from a `ConfigDescription`, types and JSDoc only and the same text for the same description. It holds a record type per collection and `CompanyApi`: `<apiNameOf(workflow)>: WorkflowFn<input>` per workflow, built-ins too; `<vendor>: { <resource>: { <op>: OpFn<input> } }` for the operations `direct` marks; `data: { <collection>: CollectionApi<record> }`; each member documented from its title or description. Then it augments `sanoma/site`'s `Register`. Inputs are typed from their JSON Schema for the kinds a start form draws, constants, and unions of them (`null` among them); anything else, and every output, is `unknown`. A workflow with `describeConfig`'s api-name problem is left out (`problems`). `writeApi` writes only when the text differs (`wrote`).
576
+ - `SanomaClient`: call an operation directly (`call`, see [Direct calls](#direct-calls)), start runs, list them (`runs({ limit, status, workflow })`) or read a workflow's last result (`latest`), read a run's ledger and approvals, decide approvals, with the checks described above, and start a drift check or an apply and read its report (`drift`, `driftReport`, `apply`, `applyReport`, see [Drift](#drift) and [Apply](#apply)).
516
577
  - `APPROVALS_EVENT` and `decisionEventOf(approvalId)`: the DBOS events a run publishes its approvals and each decision on, for a UI that reads DBOS directly. `ApprovalMessage` is the zod schema of a decision as a run reads it. `RunArgs` is what a run receives: its input, `startedBy` and, for a sandbox run, its scenario as `sandbox`.
517
- - `mayDecide(approval, principal)` and `approverLabel(approver)`: who may decide, and how to name them, the same way the run does. `isEnded(status)` and `ENDED_STATUSES`: the run statuses that read no more decisions; `RUN_STATUSES`: every run status, in the order a run may pass through them.
518
- - Errors: `errorCode(err)` for the code to branch on, `errorMessage(err)` for the text of anything thrown, `invalidInput(what, issues)` to build an `invalid_input` error from zod issues (`InputIssue` is one issue, without symbols in its path), and the classes `SanomaError`, `PolicyDeniedError`, `RejectedError` and `DriverError`. Read codes with `errorCode`, never `instanceof`.
519
- - `@sanoma-ai/workflows/shared` exports `mayDecide`, `approverLabel`, `errorMessage`, `isEnded`, `ENDED_STATUSES`, `RUN_STATUSES`, `DRIFT_WORKFLOW`, `isLoopback` (whether a host is this machine: `localhost`, `127.x.x.x`, `::1`), `problemAt` (a data-file problem's `file:line:column`), `problemLine` (a problem as one line: `file:line:column: message`, `file: message` or the message), `credentialReady` (a variable is set, or optional and unset) and the `RunStatus` and `CredentialStatus` types, the resource types' types (`Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource`, `ResourceProblem`) and the drift report's (`DriftReport`, `DriftResult`, `DriftField`, `DriftStatus`), with nothing else: no DBOS or Node imports, so a browser bundle can use them. The main entry exports them too.
578
+ - `mayDecide(approval, principal, run, people)` and `approverLabel(approver)`: who may decide, and how to name them, the same way the run does. `isEnded(status)` and `ENDED_STATUSES`: the run statuses that read no more decisions; `RUN_STATUSES`: every run status, in the order a run may pass through them.
579
+ - Errors: `errorCode(err)` for the code to branch on, `errorMessage(err)` for the text of anything thrown, `invalidInput(what, issues)` to build an `invalid_input` error from zod issues (`InputIssue` is one issue, without symbols in its path), and the classes `SanomaError`, `PolicyDeniedError`, `RejectedError`, `ApprovalPendingError` and `DriverError`. Read codes with `errorCode`, never `instanceof`.
580
+ - `@sanoma-ai/workflows/shared` exports `mayDecide`, `approverLabel`, `errorMessage`, `isEnded`, `ENDED_STATUSES`, `RUN_STATUSES`, `DRIFT_WORKFLOW`, `CALL_WORKFLOW`, `isLoopback` (whether a host is this machine: `localhost`, `127.x.x.x`, `::1`), `problemAt` (a data-file problem's `file:line:column`), `problemLine` (a problem as one line: `file:line:column: message`, `file: message` or the message), `credentialReady` (a variable is set, or optional and unset) and the `RunStatus` and `CredentialStatus` types, the resource types' types (`Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource`, `ResourceProblem`) and the drift report's (`DriftReport`, `DriftResult`, `DriftField`, `DriftStatus`), with nothing else: no DBOS or Node imports, so a browser bundle can use them. The main entry exports them too.
520
581
  - `LedgerRecord` and `LedgerStore` for the audit record (`LedgerBody` is a record without the fields every record carries, and `LedgerGroup` a record's `ctx.all` tag), `postgresLedger(databaseUrl)` (the default), `jsonlLedger(dir)` and `memoryLedger()` to keep it, and `RUNTIME_VERSION`, this package's version as the runtime reports it.
521
582
 
522
583
  ### Outline
@@ -32,15 +32,20 @@ export type ApprovalMessage = z.infer<typeof ApprovalMessage>;
32
32
  export interface CheckedApproval extends Omit<ApprovalRequest, "covers"> {
33
33
  covers: string[];
34
34
  }
35
- /** A policy's hold: the call held, and the ledger `seq` its `op.called` record takes. */
35
+ /**
36
+ * A policy's hold: the call held (an operation, or `workflow:<name>` for a run's start), the
37
+ * ledger `seq` its `op.called` record takes (a start has none), and the rules that held it.
38
+ */
36
39
  export interface HeldCall {
37
40
  op: string;
38
- seq: number;
41
+ seq?: number;
39
42
  input: unknown;
43
+ reasons?: string[] | undefined;
40
44
  }
41
45
  /**
42
- * Waits, durably, for a decision from someone who may make it. Throws `RejectedError` on a
43
- * rejection. Without `held`, the workflow asked (`ctx.approval`); with it, the policy did, and
44
- * `place` is the held call's outline node and name.
46
+ * Waits, durably, for a decision from someone who may make it (`mayDecide`: the run's own actor
47
+ * only while nobody else could). Throws `RejectedError` on a rejection. Without `held`, the workflow asked
48
+ * (`ctx.approval`); with it, the policy did, and `place` is the held call's outline node and name
49
+ * (none for a run's start, which is no call in the outline).
45
50
  */
46
- export declare function awaitApproval(run: Run, title: string, req: CheckedApproval, place: Placed, held?: HeldCall): Promise<ApprovalResult>;
51
+ export declare function awaitApproval(run: Run, title: string, req: CheckedApproval, place: Placed | undefined, held?: HeldCall): Promise<ApprovalResult>;